From cb863b44d5b2d0df4b50d480675a71761b0d57cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:27:48 +0000 Subject: [PATCH 01/28] [PM-26430] Remove Type property from PolicyRequestModel to use route parameter only (#16960) * Remove Type property from PolicyRequestModel to use route parameter only * Remove PolicyType property from policy update request in auto-confirm edit policy dialog * Run prettier --- .../policies/auto-confirm-edit-policy-dialog.component.ts | 1 - .../organizations/policies/base-policy-edit.component.ts | 1 - .../vnext-organization-data-ownership.component.ts | 1 - libs/common/src/admin-console/models/request/policy.request.ts | 3 --- 4 files changed, 6 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts index bdc664e208e..99d484f04f2 100644 --- a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts @@ -234,7 +234,6 @@ export class AutoConfirmPolicyDialogComponent private async submitSingleOrg(): Promise { const singleOrgRequest: PolicyRequest = { - type: PolicyType.SingleOrg, enabled: true, data: null, }; 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 54d4491156c..c1b175fa988 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 @@ -109,7 +109,6 @@ export abstract class BasePolicyEditComponent implements OnInit { } const request: PolicyRequest = { - type: this.policy.type, enabled: this.enabled.value ?? false, data: this.buildRequestData(), }; 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 627f5762eda..a15c51ebf70 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 @@ -74,7 +74,6 @@ export class vNextOrganizationDataOwnershipPolicyComponent const request: VNextPolicyRequest = { policy: { - type: this.policy.type, enabled: this.enabled.value ?? false, data: this.buildRequestData(), }, diff --git a/libs/common/src/admin-console/models/request/policy.request.ts b/libs/common/src/admin-console/models/request/policy.request.ts index 7b2e4f76063..36ffadff128 100644 --- a/libs/common/src/admin-console/models/request/policy.request.ts +++ b/libs/common/src/admin-console/models/request/policy.request.ts @@ -1,7 +1,4 @@ -import { PolicyType } from "../../enums"; - export type PolicyRequest = { - type: PolicyType; enabled: boolean; data: any; }; From e7995256cd58acb88a990071eb2931ab617ae746 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:29:58 -0500 Subject: [PATCH 02/28] [deps] Platform: Update @types/node to v22.19.0 (#17297) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- .../native-messaging-test-runner/package-lock.json | 8 ++++---- apps/desktop/native-messaging-test-runner/package.json | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index b6e402a3ef6..a4286aabed9 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -19,7 +19,7 @@ "yargs": "18.0.0" }, "devDependencies": { - "@types/node": "22.18.11", + "@types/node": "22.19.0", "typescript": "5.4.2" } }, @@ -117,9 +117,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.18.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz", - "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==", + "version": "22.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz", + "integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==", "license": "MIT", "peer": true, "dependencies": { diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 285997f6482..55699af47dd 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -24,7 +24,7 @@ "yargs": "18.0.0" }, "devDependencies": { - "@types/node": "22.18.11", + "@types/node": "22.19.0", "typescript": "5.4.2" }, "_moduleAliases": { diff --git a/package-lock.json b/package-lock.json index e456e257ca4..07e98938cd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,7 +112,7 @@ "@types/koa-json": "2.0.23", "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", - "@types/node": "22.18.11", + "@types/node": "22.19.0", "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.11", "@types/papaparse": "5.3.16", @@ -14391,9 +14391,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.18.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.11.tgz", - "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==", + "version": "22.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz", + "integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" diff --git a/package.json b/package.json index e224fd00213..21eb2b0c06d 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "@types/koa-json": "2.0.23", "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", - "@types/node": "22.18.11", + "@types/node": "22.19.0", "@types/node-fetch": "2.6.4", "@types/node-forge": "1.3.11", "@types/papaparse": "5.3.16", From 2f3f2c2105943cd9b752f3955750e4dc58ca1e21 Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:33:43 -0500 Subject: [PATCH 03/28] [PM-27737] text and triangle color (#17198) --- apps/web/src/locales/en/messages.json | 3 +++ .../access-intelligence/activity/all-activity.component.html | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 5c712c98e0d..9ffa175af04 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -349,6 +349,9 @@ "applicationsNeedingReview": { "message": "Applications needing review" }, + "newApplicationsCardTitle": { + "message": "Review new applications" + }, "newApplicationsWithCount": { "message": "$COUNT$ new applications", "placeholders": { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html index d0751556517..d8ad785ff14 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html @@ -46,7 +46,7 @@
  • Date: Mon, 10 Nov 2025 09:51:51 -0600 Subject: [PATCH 04/28] [PM-23713] always append query param to premium redirect (#17240) --- .../premium-upgrade-dialog.component.spec.ts | 35 +------------------ .../premium-upgrade-dialog.component.ts | 12 +++---- 2 files changed, 5 insertions(+), 42 deletions(-) diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts index f2991cc41b4..107eb068e76 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts @@ -158,12 +158,7 @@ describe("PremiumUpgradeDialogComponent", () => { }); describe("upgrade()", () => { - it("should launch URI with query parameter for cloud-hosted environments", async () => { - mockEnvironmentService.environment$ = of({ - getWebVaultUrl: () => "https://vault.bitwarden.com", - getRegion: () => Region.US, - } as any); - + it("should launch URI with query parameter", async () => { await component["upgrade"](); expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith( @@ -171,34 +166,6 @@ describe("PremiumUpgradeDialogComponent", () => { ); expect(mockDialogRef.close).toHaveBeenCalled(); }); - - it("should launch URI without query parameter for self-hosted environments", async () => { - mockEnvironmentService.environment$ = of({ - getWebVaultUrl: () => "https://self-hosted.example.com", - getRegion: () => Region.SelfHosted, - } as any); - - await component["upgrade"](); - - expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith( - "https://self-hosted.example.com/#/settings/subscription/premium", - ); - expect(mockDialogRef.close).toHaveBeenCalled(); - }); - - it("should launch URI with query parameter for EU cloud region", async () => { - mockEnvironmentService.environment$ = of({ - getWebVaultUrl: () => "https://vault.bitwarden.eu", - getRegion: () => Region.EU, - } as any); - - await component["upgrade"](); - - expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith( - "https://vault.bitwarden.eu/#/settings/subscription/premium?callToAction=upgradeToPremium", - ); - expect(mockDialogRef.close).toHaveBeenCalled(); - }); }); it("should close dialog when close button clicked", () => { diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts index d20c0d668c4..48286a5d18c 100644 --- a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts @@ -11,10 +11,7 @@ import { SubscriptionCadence, SubscriptionCadenceIds, } from "@bitwarden/common/billing/types/subscription-pricing-tier"; -import { - EnvironmentService, - Region, -} from "@bitwarden/common/platform/abstractions/environment.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { @@ -82,10 +79,9 @@ export class PremiumUpgradeDialogComponent { protected async upgrade(): Promise { const environment = await firstValueFrom(this.environmentService.environment$); - let vaultUrl = environment.getWebVaultUrl() + "/#/settings/subscription/premium"; - if (environment.getRegion() !== Region.SelfHosted) { - vaultUrl += "?callToAction=upgradeToPremium"; - } + const vaultUrl = + environment.getWebVaultUrl() + + "/#/settings/subscription/premium?callToAction=upgradeToPremium"; this.platformUtilsService.launchUri(vaultUrl); this.dialogRef.close(); } From 5aa6d38d802001f8a28d921798db16e42f788b14 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Mon, 10 Nov 2025 10:54:25 -0500 Subject: [PATCH 05/28] feat(prelogin): [Auth/PM-23801] Move Prelogin Request (#17080) * feat(prelogin): [PM-23801] Move Prelogin Request - Initial implementation. * test(prelogin): [PM-23801] Move Prelogin Request - Removed unneeded test. --- .../src/angular/login/login.component.html | 2 +- .../src/angular/login/login.component.spec.ts | 102 ++++++ .../auth/src/angular/login/login.component.ts | 19 ++ .../abstractions/login-strategy.service.ts | 6 +- .../password-login.strategy.spec.ts | 2 +- .../password-login.strategy.ts | 5 +- .../login-strategy.service.spec.ts | 323 +++++++++++++++++- .../login-strategy.service.ts | 156 ++++++++- libs/common/src/enums/feature-flag.enum.ts | 2 + 9 files changed, 597 insertions(+), 20 deletions(-) create mode 100644 libs/auth/src/angular/login/login.component.spec.ts diff --git a/libs/auth/src/angular/login/login.component.html b/libs/auth/src/angular/login/login.component.html index 9faa582c071..e33872829ad 100644 --- a/libs/auth/src/angular/login/login.component.html +++ b/libs/auth/src/angular/login/login.component.html @@ -1,4 +1,4 @@ - (false); @@ -52,6 +52,22 @@ export class NavItemComponent extends NavBaseComponent { return this.forceActiveStyles() || (this._isActive && !this.hideActiveStyles()); } + /** + * adding calculation for tree variant due to needing visual alignment on different indentation levels needed between the first level and subsequent levels + */ + protected readonly navItemIndentationPadding = computed(() => { + const open = this.sideNavService.open; + const depth = this.treeDepth() ?? 0; + + if (open && this.variant() === "tree") { + return depth === 1 + ? `${this.TREE_BASE_PADDING}rem` + : `${this.TREE_BASE_PADDING + (depth - 1) * this.TREE_DEPTH_PADDING}rem`; + } + + return `${this.TREE_BASE_PADDING * depth}rem`; + }); + /** * Allow overriding of the RouterLink['ariaCurrentWhenActive'] property. * From c22cba76ecffbe8f9a1b222c0ac4540fdf5b5bae Mon Sep 17 00:00:00 2001 From: Vicki League Date: Mon, 10 Nov 2025 17:11:35 -0500 Subject: [PATCH 14/28] [PM-26984] Use medium instead of semibold or bold, and for headings (#17184) --- apps/desktop/src/scss/environment.scss | 2 +- apps/desktop/src/scss/modal.scss | 4 ++-- .../layouts/header/web-header.component.html | 2 +- .../access/access-list.component.html | 2 +- .../service-accounts-list.component.html | 2 +- .../shared/projects-list.component.html | 2 +- .../shared/secrets-list.component.html | 2 +- .../src/multi-select/scss/bw.theme.scss | 4 ++-- libs/components/src/tw-theme-preflight.css | 20 +++++++++++++------ .../src/typography/typography.directive.ts | 12 +++++------ 10 files changed, 30 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/scss/environment.scss b/apps/desktop/src/scss/environment.scss index e1356178208..699f2246b4a 100644 --- a/apps/desktop/src/scss/environment.scss +++ b/apps/desktop/src/scss/environment.scss @@ -21,7 +21,7 @@ padding-left: 15px; span { - font-weight: 600; + font-weight: 500; font-size: $font-size-small; } } diff --git a/apps/desktop/src/scss/modal.scss b/apps/desktop/src/scss/modal.scss index 1d86b1e880a..b3994946394 100644 --- a/apps/desktop/src/scss/modal.scss +++ b/apps/desktop/src/scss/modal.scss @@ -47,7 +47,7 @@ $modal-sm: 300px !default; $modal-transition: transform 0.3s ease-out !default; $close-font-size: $font-size-base * 1.5 !default; -$close-font-weight: bold !default; +$close-font-weight: 500 !default; $close-color: $black !default; $close-text-shadow: 0 1px 0 $white !default; @@ -218,7 +218,7 @@ $close-text-shadow: 0 1px 0 $white !default; h5 { font-size: $font-size-base; - font-weight: bold; + font-weight: 500; display: flex; align-items: center; diff --git a/apps/web/src/app/layouts/header/web-header.component.html b/apps/web/src/app/layouts/header/web-header.component.html index 992ba147075..4b833e771dd 100644 --- a/apps/web/src/app/layouts/header/web-header.component.html +++ b/apps/web/src/app/layouts/header/web-header.component.html @@ -12,7 +12,7 @@

    diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html index 3399b550ba5..6172ec22b65 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html @@ -21,7 +21,7 @@ -

    - -

    {{ "vaultTimeoutHeader" | i18n }}

    -
    + @if (consolidatedSessionTimeoutComponent$ | async) { + +

    {{ "sessionTimeoutHeader" | i18n }}

    +
    - - + + } @else { + +

    {{ "vaultTimeoutHeader" | i18n }}

    +
    - - {{ "vaultTimeoutAction1" | i18n }} - - + + + + {{ + "vaultTimeoutAction1" | i18n + }} + + + + + + - - + {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
    + +
    - - {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
    + + {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} - - - - {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} - + }
    diff --git a/apps/desktop/src/app/accounts/settings.component.spec.ts b/apps/desktop/src/app/accounts/settings.component.spec.ts index cafc4138628..115f7436979 100644 --- a/apps/desktop/src/app/accounts/settings.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings.component.spec.ts @@ -191,7 +191,7 @@ describe("SettingsComponent", () => { desktopAutotypeService.autotypeEnabledUserSetting$ = of(false); desktopAutotypeService.autotypeKeyboardShortcut$ = of(["Control", "Shift", "B"]); billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); - configService.getFeatureFlag$.mockReturnValue(of(true)); + configService.getFeatureFlag$.mockReturnValue(of(false)); }); afterEach(() => { diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index abebdfa5fc3..3db6c08a6c8 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -55,6 +55,7 @@ import { TypographyModule, } from "@bitwarden/components"; import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management"; +import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui"; import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault"; import { SetPinComponent } from "../../auth/components/set-pin.component"; @@ -95,6 +96,7 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man SelectModule, TypographyModule, VaultTimeoutInputComponent, + SessionTimeoutSettingsComponent, PermitCipherDetailsPopoverComponent, PremiumBadgeComponent, ], @@ -146,6 +148,8 @@ export class SettingsComponent implements OnInit, OnDestroy { pinEnabled$: Observable = of(true); isWindowsV2BiometricsEnabled: boolean = false; + consolidatedSessionTimeoutComponent$: Observable; + form = this.formBuilder.group({ // Security vaultTimeout: [null as VaultTimeout | null], @@ -184,7 +188,7 @@ export class SettingsComponent implements OnInit, OnDestroy { locale: [null as string | null], }); - private refreshTimeoutSettings$ = new BehaviorSubject(undefined); + protected refreshTimeoutSettings$ = new BehaviorSubject(undefined); private destroy$ = new Subject(); constructor( @@ -282,12 +286,17 @@ export class SettingsComponent implements OnInit, OnDestroy { value: SshAgentPromptType.RememberUntilLock, }, ]; + + this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + ); } async ngOnInit() { + this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions(); + this.isWindowsV2BiometricsEnabled = await this.biometricsService.isWindowsV2BiometricsEnabled(); - this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions(); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); // Autotype is for Windows initially diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index be91c309875..03d6eb5c908 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -109,7 +109,10 @@ import { BiometricStateService, BiometricsService, } from "@bitwarden/key-management"; -import { LockComponentService } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + SessionTimeoutSettingsComponentService, +} from "@bitwarden/key-management-ui"; import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; @@ -125,6 +128,7 @@ import { DesktopBiometricsService } from "../../key-management/biometrics/deskto import { RendererBiometricsService } from "../../key-management/biometrics/renderer-biometrics.service"; import { ElectronKeyService } from "../../key-management/electron-key.service"; import { DesktopLockComponentService } from "../../key-management/lock/services/desktop-lock-component.service"; +import { DesktopSessionTimeoutSettingsComponentService } from "../../key-management/session-timeout/services/desktop-session-timeout-settings-component.service"; import { flagEnabled } from "../../platform/flags"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service"; @@ -480,6 +484,11 @@ const safeProviders: SafeProvider[] = [ useClass: DesktopAutotypeDefaultSettingPolicy, deps: [AccountServiceAbstraction, AuthServiceAbstraction, InternalPolicyService, ConfigService], }), + safeProvider({ + provide: SessionTimeoutSettingsComponentService, + useClass: DesktopSessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts new file mode 100644 index 00000000000..91c8126cdd7 --- /dev/null +++ b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts @@ -0,0 +1,48 @@ +import { defer, from, map, Observable } from "rxjs"; + +import { + VaultTimeout, + VaultTimeoutOption, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; + +export class DesktopSessionTimeoutSettingsComponentService + implements SessionTimeoutSettingsComponentService +{ + availableTimeoutOptions$: Observable = defer(() => + from(ipc.platform.powermonitor.isLockMonitorAvailable()).pipe( + map((isLockMonitorAvailable) => { + const options: VaultTimeoutOption[] = [ + { name: this.i18nService.t("oneMinute"), value: 1 }, + { name: this.i18nService.t("fiveMinutes"), value: 5 }, + { name: this.i18nService.t("fifteenMinutes"), value: 15 }, + { name: this.i18nService.t("thirtyMinutes"), value: 30 }, + { name: this.i18nService.t("oneHour"), value: 60 }, + { name: this.i18nService.t("fourHours"), value: 240 }, + { name: this.i18nService.t("onIdle"), value: VaultTimeoutStringType.OnIdle }, + { name: this.i18nService.t("onSleep"), value: VaultTimeoutStringType.OnSleep }, + ]; + + if (isLockMonitorAvailable) { + options.push({ + name: this.i18nService.t("onLocked"), + value: VaultTimeoutStringType.OnLocked, + }); + } + + options.push( + { name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart }, + { name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }, + ); + + return options; + }), + ), + ); + + constructor(private readonly i18nService: I18nService) {} + + onTimeoutSave(_: VaultTimeout): void {} +} diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index da8d9ea0e34..981066d9612 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4220,5 +4220,11 @@ }, "upgradeToPremium": { "message": "Upgrade to Premium" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" + }, + "sessionTimeoutHeader": { + "message": "Session timeout" } } diff --git a/apps/web/src/app/auth/settings/security/security-routing.module.ts b/apps/web/src/app/auth/settings/security/security-routing.module.ts index ba476dc9106..dbcfc7cb18b 100644 --- a/apps/web/src/app/auth/settings/security/security-routing.module.ts +++ b/apps/web/src/app/auth/settings/security/security-routing.module.ts @@ -2,7 +2,10 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component"; +import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { SessionTimeoutComponent } from "../../../key-management/session-timeout/session-timeout.component"; import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component"; import { PasswordSettingsComponent } from "./password-settings/password-settings.component"; @@ -15,7 +18,20 @@ const routes: Routes = [ component: SecurityComponent, data: { titleId: "security" }, children: [ - { path: "", pathMatch: "full", redirectTo: "password" }, + { path: "", pathMatch: "full", redirectTo: "session-timeout" }, + { + path: "session-timeout", + component: SessionTimeoutComponent, + canActivate: [ + canAccessFeature( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + true, + "/settings/security/password", + false, + ), + ], + data: { titleId: "sessionTimeoutHeader" }, + }, { path: "password", component: PasswordSettingsComponent, diff --git a/apps/web/src/app/auth/settings/security/security.component.html b/apps/web/src/app/auth/settings/security/security.component.html index 355a33d4427..6942713443f 100644 --- a/apps/web/src/app/auth/settings/security/security.component.html +++ b/apps/web/src/app/auth/settings/security/security.component.html @@ -1,8 +1,11 @@ - + @if (consolidatedSessionTimeoutComponent$ | async) { + {{ "sessionTimeoutHeader" | i18n }} + } + @if (showChangePassword) { {{ "masterPassword" | i18n }} - + } {{ "twoStepLogin" | i18n }} {{ "devices" | i18n }} {{ "keys" | i18n }} diff --git a/apps/web/src/app/auth/settings/security/security.component.ts b/apps/web/src/app/auth/settings/security/security.component.ts index ff13515eec0..629de32efc4 100644 --- a/apps/web/src/app/auth/settings/security/security.component.ts +++ b/apps/web/src/app/auth/settings/security/security.component.ts @@ -1,6 +1,9 @@ import { Component, OnInit } from "@angular/core"; +import { Observable } from "rxjs"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; @@ -14,8 +17,16 @@ import { SharedModule } from "../../../shared"; export class SecurityComponent implements OnInit { showChangePassword = true; changePasswordRoute = "password"; + consolidatedSessionTimeoutComponent$: Observable; - constructor(private userVerificationService: UserVerificationService) {} + constructor( + private userVerificationService: UserVerificationService, + private configService: ConfigService, + ) { + this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + ); + } async ngOnInit() { this.showChangePassword = await this.userVerificationService.hasMasterPassword(); diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index bf741132b00..c0716d99716 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -117,10 +117,14 @@ import { KeyService as KeyServiceAbstraction, BiometricsService, } from "@bitwarden/key-management"; -import { LockComponentService } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + SessionTimeoutSettingsComponentService, +} from "@bitwarden/key-management-ui"; import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; import { WebOrganizationInviteService } from "@bitwarden/web-vault/app/auth/core/services/organization-invite/web-organization-invite.service"; +import { WebSessionTimeoutSettingsComponentService } from "@bitwarden/web-vault/app/key-management/session-timeout/services/web-session-timeout-settings-component.service"; import { WebVaultPremiumUpgradePromptService } from "@bitwarden/web-vault/app/vault/services/web-premium-upgrade-prompt.service"; import { flagEnabled } from "../../utils/flags"; @@ -465,6 +469,11 @@ const safeProviders: SafeProvider[] = [ useClass: WebSystemService, deps: [], }), + safeProvider({ + provide: SessionTimeoutSettingsComponentService, + useClass: WebSessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction, PlatformUtilsService], + }), ]; @NgModule({ diff --git a/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts new file mode 100644 index 00000000000..61836c98252 --- /dev/null +++ b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts @@ -0,0 +1,39 @@ +import { defer, Observable, of } from "rxjs"; + +import { + VaultTimeout, + VaultTimeoutOption, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; + +export class WebSessionTimeoutSettingsComponentService + implements SessionTimeoutSettingsComponentService +{ + availableTimeoutOptions$: Observable = defer(() => { + const options: VaultTimeoutOption[] = [ + { name: this.i18nService.t("oneMinute"), value: 1 }, + { name: this.i18nService.t("fiveMinutes"), value: 5 }, + { name: this.i18nService.t("fifteenMinutes"), value: 15 }, + { name: this.i18nService.t("thirtyMinutes"), value: 30 }, + { name: this.i18nService.t("oneHour"), value: 60 }, + { name: this.i18nService.t("fourHours"), value: 240 }, + { name: this.i18nService.t("onRefresh"), value: VaultTimeoutStringType.OnRestart }, + ]; + + if (this.platformUtilsService.isDev()) { + options.push({ name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }); + } + + return of(options); + }); + + constructor( + private readonly i18nService: I18nService, + private readonly platformUtilsService: PlatformUtilsService, + ) {} + + onTimeoutSave(_: VaultTimeout): void {} +} diff --git a/apps/web/src/app/key-management/session-timeout/session-timeout.component.html b/apps/web/src/app/key-management/session-timeout/session-timeout.component.html new file mode 100644 index 00000000000..0ca6267da50 --- /dev/null +++ b/apps/web/src/app/key-management/session-timeout/session-timeout.component.html @@ -0,0 +1,5 @@ +

    {{ "sessionTimeoutHeader" | i18n }}

    + +
    + +
    diff --git a/apps/web/src/app/key-management/session-timeout/session-timeout.component.ts b/apps/web/src/app/key-management/session-timeout/session-timeout.component.ts new file mode 100644 index 00000000000..566484ddcee --- /dev/null +++ b/apps/web/src/app/key-management/session-timeout/session-timeout.component.ts @@ -0,0 +1,11 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui"; + +@Component({ + templateUrl: "session-timeout.component.html", + imports: [SessionTimeoutSettingsComponent, JslibModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SessionTimeoutComponent {} diff --git a/apps/web/src/app/layouts/user-layout.component.html b/apps/web/src/app/layouts/user-layout.component.html index 23f22d263cf..9f474062120 100644 --- a/apps/web/src/app/layouts/user-layout.component.html +++ b/apps/web/src/app/layouts/user-layout.component.html @@ -13,7 +13,11 @@ - + @if (consolidatedSessionTimeoutComponent$ | async) { + + } @else { + + } ; protected showSponsoredFamilies$: Observable; protected showSubscription$: Observable; + protected consolidatedSessionTimeoutComponent$: Observable; constructor( private syncService: SyncService, @@ -74,6 +75,10 @@ export class UserLayoutComponent implements OnInit { }), ), ); + + this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + ); } async ngOnInit() { diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 4db6e50bc6d..b40b9143991 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -14,6 +14,7 @@ import { import { LoginViaWebAuthnComponent } from "@bitwarden/angular/auth/login-via-webauthn/login-via-webauthn.component"; import { ChangePasswordComponent } from "@bitwarden/angular/auth/password-management/change-password"; import { SetInitialPasswordComponent } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.component"; +import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { DevicesIcon, RegistrationUserAddIcon, @@ -48,6 +49,7 @@ import { NewDeviceVerificationComponent, } from "@bitwarden/auth/angular"; import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; import { LockComponent } from "@bitwarden/key-management-ui"; import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/guards/premium-interest-redirect/premium-interest-redirect.guard"; @@ -82,6 +84,7 @@ import { FrontendLayoutComponent } from "./layouts/frontend-layout.component"; import { UserLayoutComponent } from "./layouts/user-layout.component"; import { RequestSMAccessComponent } from "./secrets-manager/secrets-manager-landing/request-sm-access.component"; import { SMLandingComponent } from "./secrets-manager/secrets-manager-landing/sm-landing.component"; +import { AppearanceComponent } from "./settings/appearance.component"; import { DomainRulesComponent } from "./settings/domain-rules.component"; import { PreferencesComponent } from "./settings/preferences.component"; import { CredentialGeneratorComponent } from "./tools/credential-generator/credential-generator.component"; @@ -663,9 +666,30 @@ const routes: Routes = [ component: AccountComponent, data: { titleId: "myAccount" } satisfies RouteDataProperties, }, + { + path: "appearance", + component: AppearanceComponent, + canActivate: [ + canAccessFeature( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + true, + "/settings/preferences", + false, + ), + ], + data: { titleId: "appearance" } satisfies RouteDataProperties, + }, { path: "preferences", component: PreferencesComponent, + canActivate: [ + canAccessFeature( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + false, + "/settings/appearance", + false, + ), + ], data: { titleId: "preferences" } satisfies RouteDataProperties, }, { diff --git a/apps/web/src/app/settings/appearance.component.html b/apps/web/src/app/settings/appearance.component.html new file mode 100644 index 00000000000..840895eea42 --- /dev/null +++ b/apps/web/src/app/settings/appearance.component.html @@ -0,0 +1,48 @@ + + + +
    + + {{ "theme" | i18n }} + + @for (option of themeOptions; track option.value) { + + } + + {{ "themeDesc" | i18n }} + + + + {{ "language" | i18n }} + + + + + + @for (option of localeOptions; track option.value) { + + } + + {{ "languageDesc" | i18n }} + +
    + + + + {{ "showIconsChangePasswordUrls" | i18n }} + + +
    + +
    +
    +
    + diff --git a/apps/web/src/app/settings/appearance.component.spec.ts b/apps/web/src/app/settings/appearance.component.spec.ts new file mode 100644 index 00000000000..53ae9f81a80 --- /dev/null +++ b/apps/web/src/app/settings/appearance.component.spec.ts @@ -0,0 +1,215 @@ +import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; + +import { AppearanceComponent } from "./appearance.component"; + +describe("AppearanceComponent", () => { + let component: AppearanceComponent; + let fixture: ComponentFixture; + let mockI18nService: MockProxy; + let mockThemeStateService: MockProxy; + let mockDomainSettingsService: MockProxy; + + const mockShowFavicons$ = new BehaviorSubject(true); + const mockSelectedTheme$ = new BehaviorSubject(ThemeTypes.Light); + const mockUserSetLocale$ = new BehaviorSubject("en"); + + const mockSupportedLocales = ["en", "es", "fr", "de"]; + const mockLocaleNames = new Map([ + ["en", "English"], + ["es", "Español"], + ["fr", "Français"], + ["de", "Deutsch"], + ]); + + beforeEach(async () => { + mockI18nService = mock(); + mockThemeStateService = mock(); + mockDomainSettingsService = mock(); + + mockI18nService.supportedTranslationLocales = mockSupportedLocales; + mockI18nService.localeNames = mockLocaleNames; + mockI18nService.collator = { + compare: jest.fn((a: string, b: string) => a.localeCompare(b)), + } as any; + mockI18nService.t.mockImplementation((key: string) => `${key}-used-i18n`); + mockI18nService.userSetLocale$ = mockUserSetLocale$; + + mockThemeStateService.selectedTheme$ = mockSelectedTheme$; + mockDomainSettingsService.showFavicons$ = mockShowFavicons$; + + mockDomainSettingsService.setShowFavicons.mockResolvedValue(undefined); + mockThemeStateService.setSelectedTheme.mockResolvedValue(undefined); + mockI18nService.setLocale.mockResolvedValue(undefined); + + await TestBed.configureTestingModule({ + imports: [AppearanceComponent, ReactiveFormsModule, NoopAnimationsModule], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { provide: ThemeStateService, useValue: mockThemeStateService }, + { provide: DomainSettingsService, useValue: mockDomainSettingsService }, + ], + }) + .overrideComponent(AppearanceComponent, { + set: { + template: "", + imports: [], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(AppearanceComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("constructor", () => { + describe("locale options setup", () => { + it("should create locale options sorted by name from supported locales with display names", () => { + expect(component.localeOptions).toHaveLength(5); + expect(component.localeOptions[0]).toEqual({ name: "default-used-i18n", value: null }); + expect(component.localeOptions[1]).toEqual({ name: "de - Deutsch", value: "de" }); + expect(component.localeOptions[2]).toEqual({ name: "en - English", value: "en" }); + expect(component.localeOptions[3]).toEqual({ name: "es - Español", value: "es" }); + expect(component.localeOptions[4]).toEqual({ name: "fr - Français", value: "fr" }); + }); + }); + + describe("theme options setup", () => { + it("should create theme options with Light, Dark, and System", () => { + expect(component.themeOptions).toEqual([ + { name: "themeLight-used-i18n", value: ThemeTypes.Light }, + { name: "themeDark-used-i18n", value: ThemeTypes.Dark }, + { name: "themeSystem-used-i18n", value: ThemeTypes.System }, + ]); + }); + }); + }); + + describe("ngOnInit", () => { + it("should initialize form with values", fakeAsync(() => { + mockShowFavicons$.next(false); + mockSelectedTheme$.next(ThemeTypes.Dark); + mockUserSetLocale$.next("es"); + + fixture.detectChanges(); + flush(); + + expect(component.form.value).toEqual({ + enableFavicons: false, + theme: ThemeTypes.Dark, + locale: "es", + }); + })); + + it("should set locale to null when user locale not set", fakeAsync(() => { + mockUserSetLocale$.next(undefined); + + fixture.detectChanges(); + flush(); + + expect(component.form.value.locale).toBeNull(); + })); + }); + + describe("enableFavicons value changes", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + jest.clearAllMocks(); + })); + + it("should call setShowFavicons when enableFavicons changes to true", fakeAsync(() => { + component.form.controls.enableFavicons.setValue(true); + flush(); + + expect(mockDomainSettingsService.setShowFavicons).toHaveBeenCalledWith(true); + })); + + it("should call setShowFavicons when enableFavicons changes to false", fakeAsync(() => { + component.form.controls.enableFavicons.setValue(false); + flush(); + + expect(mockDomainSettingsService.setShowFavicons).toHaveBeenCalledWith(false); + })); + + it("should not call setShowFavicons when value is null", fakeAsync(() => { + component.form.controls.enableFavicons.setValue(null); + flush(); + + expect(mockDomainSettingsService.setShowFavicons).not.toHaveBeenCalled(); + })); + }); + + describe("theme value changes", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + jest.clearAllMocks(); + })); + + it.each([ThemeTypes.Light, ThemeTypes.Dark, ThemeTypes.System])( + "should call setSelectedTheme when theme changes to %s", + fakeAsync((themeType: Theme) => { + component.form.controls.theme.setValue(themeType); + flush(); + + expect(mockThemeStateService.setSelectedTheme).toHaveBeenCalledWith(themeType); + }), + ); + + it("should not call setSelectedTheme when value is null", fakeAsync(() => { + component.form.controls.theme.setValue(null); + flush(); + + expect(mockThemeStateService.setSelectedTheme).not.toHaveBeenCalled(); + })); + }); + + describe("locale value changes", () => { + let reloadMock: jest.Mock; + + beforeEach(fakeAsync(() => { + reloadMock = jest.fn(); + Object.defineProperty(window, "location", { + value: { reload: reloadMock }, + writable: true, + }); + + fixture.detectChanges(); + flush(); + jest.clearAllMocks(); + })); + + it("should call setLocale and reload window when locale changes to english", fakeAsync(() => { + component.form.controls.locale.setValue("es"); + flush(); + + expect(mockI18nService.setLocale).toHaveBeenCalledWith("es"); + expect(reloadMock).toHaveBeenCalled(); + })); + + it("should call setLocale and reload window when locale changes to default", fakeAsync(() => { + component.form.controls.locale.setValue(null); + flush(); + + expect(mockI18nService.setLocale).toHaveBeenCalledWith(null); + expect(reloadMock).toHaveBeenCalled(); + })); + }); +}); diff --git a/apps/web/src/app/settings/appearance.component.ts b/apps/web/src/app/settings/appearance.component.ts new file mode 100644 index 00000000000..d1bcf2c28f4 --- /dev/null +++ b/apps/web/src/app/settings/appearance.component.ts @@ -0,0 +1,107 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder } from "@angular/forms"; +import { filter, firstValueFrom, switchMap } from "rxjs"; + +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault"; + +import { HeaderModule } from "../layouts/header/header.module"; +import { SharedModule } from "../shared"; + +type LocaleOption = { + name: string; + value: string | null; +}; + +type ThemeOption = { + name: string; + value: Theme; +}; + +@Component({ + selector: "app-appearance", + templateUrl: "appearance.component.html", + imports: [SharedModule, HeaderModule, PermitCipherDetailsPopoverComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AppearanceComponent implements OnInit { + localeOptions: LocaleOption[]; + themeOptions: ThemeOption[]; + + form = this.formBuilder.group({ + enableFavicons: true, + theme: [ThemeTypes.Light as Theme], + locale: [null as string | null], + }); + + constructor( + private formBuilder: FormBuilder, + private i18nService: I18nService, + private themeStateService: ThemeStateService, + private domainSettingsService: DomainSettingsService, + private destroyRef: DestroyRef, + ) { + const localeOptions: LocaleOption[] = []; + i18nService.supportedTranslationLocales.forEach((locale) => { + let name = locale; + if (i18nService.localeNames.has(locale)) { + name += " - " + i18nService.localeNames.get(locale); + } + localeOptions.push({ name: name, value: locale }); + }); + localeOptions.sort(Utils.getSortFunction(i18nService, "name")); + localeOptions.splice(0, 0, { name: i18nService.t("default"), value: null }); + this.localeOptions = localeOptions; + this.themeOptions = [ + { name: i18nService.t("themeLight"), value: ThemeTypes.Light }, + { name: i18nService.t("themeDark"), value: ThemeTypes.Dark }, + { name: i18nService.t("themeSystem"), value: ThemeTypes.System }, + ]; + } + + async ngOnInit() { + this.form.setValue( + { + enableFavicons: await firstValueFrom(this.domainSettingsService.showFavicons$), + theme: await firstValueFrom(this.themeStateService.selectedTheme$), + locale: (await firstValueFrom(this.i18nService.userSetLocale$)) ?? null, + }, + { emitEvent: false }, + ); + + this.form.controls.enableFavicons.valueChanges + .pipe( + filter((enableFavicons) => enableFavicons != null), + switchMap(async (enableFavicons) => { + await this.domainSettingsService.setShowFavicons(enableFavicons); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + + this.form.controls.theme.valueChanges + .pipe( + filter((theme) => theme != null), + switchMap(async (theme) => { + await this.themeStateService.setSelectedTheme(theme); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + + this.form.controls.locale.valueChanges + .pipe( + switchMap(async (locale) => { + await this.i18nService.setLocale(locale); + window.location.reload(); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + } +} diff --git a/apps/web/src/app/settings/preferences.component.html b/apps/web/src/app/settings/preferences.component.html index 40f2f596a13..4af7e51b800 100644 --- a/apps/web/src/app/settings/preferences.component.html +++ b/apps/web/src/app/settings/preferences.component.html @@ -48,8 +48,8 @@ - {{ "language" | i18n }} + + {{ "language" | i18n }} ; abstract locale$: Observable; - abstract setLocale(locale: string): Promise; + abstract setLocale(locale: string | null): Promise; abstract init(): Promise; } diff --git a/libs/common/src/platform/services/i18n.service.ts b/libs/common/src/platform/services/i18n.service.ts index 87c9e211ed1..e9396b907f2 100644 --- a/libs/common/src/platform/services/i18n.service.ts +++ b/libs/common/src/platform/services/i18n.service.ts @@ -29,7 +29,7 @@ export class I18nService extends TranslationService implements I18nServiceAbstra this.locale$ = this.userSetLocale$.pipe(map((locale) => locale ?? this.translationLocale)); } - async setLocale(locale: string): Promise { + async setLocale(locale: string | null): Promise { await this.translationLocaleState.update(() => locale); } diff --git a/libs/key-management-ui/src/index.ts b/libs/key-management-ui/src/index.ts index 6754722440a..fee3b3250e4 100644 --- a/libs/key-management-ui/src/index.ts +++ b/libs/key-management-ui/src/index.ts @@ -9,3 +9,5 @@ export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.co export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component"; export { RemovePasswordComponent } from "./key-connector/remove-password.component"; export { ConfirmKeyConnectorDomainComponent } from "./key-connector/confirm-key-connector-domain.component"; +export { SessionTimeoutSettingsComponent } from "./session-timeout/components/session-timeout-settings.component"; +export { SessionTimeoutSettingsComponentService } from "./session-timeout/services/session-timeout-settings-component.service"; diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html new file mode 100644 index 00000000000..467a51ee1b0 --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html @@ -0,0 +1,31 @@ +
    + + + + + {{ "sessionTimeoutSettingsAction" | i18n }} + + @for (action of availableTimeoutActions(); track action) { + + } + + + @if (!canLock) { + {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
    + } +
    + + @if (hasVaultTimeoutPolicy$ | async) { + + {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} + + } +
    diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts new file mode 100644 index 00000000000..379a2c982c8 --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts @@ -0,0 +1,522 @@ +import { ComponentFixture, fakeAsync, flush, TestBed, waitForAsync } from "@angular/core/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, filter, firstValueFrom, of } from "rxjs"; + +import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { + MaximumVaultTimeoutPolicyData, + VaultTimeout, + VaultTimeoutAction, + VaultTimeoutOption, + VaultTimeoutSettingsService, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; + +import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service"; + +import { SessionTimeoutSettingsComponent } from "./session-timeout-settings.component"; + +describe("SessionTimeoutSettingsComponent", () => { + let component: SessionTimeoutSettingsComponent; + let fixture: ComponentFixture; + + // Mock services + let mockVaultTimeoutSettingsService: MockProxy; + let mockSessionTimeoutSettingsComponentService: MockProxy; + let mockI18nService: MockProxy; + let mockToastService: MockProxy; + let mockPolicyService: MockProxy; + let accountService: FakeAccountService; + let mockDialogService: MockProxy; + let mockLogService: MockProxy; + + const mockUserId = "user-id" as UserId; + const mockEmail = "test@example.com"; + const mockInitialTimeout = 5; + const mockInitialTimeoutAction = VaultTimeoutAction.Lock; + let refreshTimeoutActionSettings$: BehaviorSubject; + let availableTimeoutOptions$: BehaviorSubject; + + beforeEach(async () => { + refreshTimeoutActionSettings$ = new BehaviorSubject(undefined); + availableTimeoutOptions$ = new BehaviorSubject([ + { name: "oneMinute-used-i18n", value: 1 }, + { name: "fiveMinutes-used-i18n", value: 5 }, + { name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart }, + { name: "onLocked-used-i18n", value: VaultTimeoutStringType.OnLocked }, + { name: "onSleep-used-i18n", value: VaultTimeoutStringType.OnSleep }, + { name: "onIdle-used-i18n", value: VaultTimeoutStringType.OnIdle }, + { name: "never-used-i18n", value: VaultTimeoutStringType.Never }, + ]); + + mockVaultTimeoutSettingsService = mock(); + mockSessionTimeoutSettingsComponentService = mock(); + mockI18nService = mock(); + mockToastService = mock(); + mockPolicyService = mock(); + accountService = mockAccountServiceWith(mockUserId, { email: mockEmail }); + mockDialogService = mock(); + mockLogService = mock(); + + mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`); + + mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() => + of(mockInitialTimeout), + ); + mockVaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockImplementation(() => + of(mockInitialTimeoutAction), + ); + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]), + ); + mockSessionTimeoutSettingsComponentService.availableTimeoutOptions$ = + availableTimeoutOptions$.asObservable(); + mockPolicyService.policiesByType$.mockImplementation(() => of([])); + + await TestBed.configureTestingModule({ + imports: [ + SessionTimeoutSettingsComponent, + ReactiveFormsModule, + VaultTimeoutInputComponent, + NoopAnimationsModule, + ], + providers: [ + { provide: VaultTimeoutSettingsService, useValue: mockVaultTimeoutSettingsService }, + { + provide: SessionTimeoutSettingsComponentService, + useValue: mockSessionTimeoutSettingsComponentService, + }, + { provide: I18nService, useValue: mockI18nService }, + { provide: ToastService, useValue: mockToastService }, + { provide: PolicyService, useValue: mockPolicyService }, + { provide: AccountService, useValue: accountService }, + { provide: LogService, useValue: mockLogService }, + { provide: DialogService, useValue: mockDialogService }, + ], + }) + .overrideComponent(SessionTimeoutSettingsComponent, { + set: { + providers: [{ provide: DialogService, useValue: mockDialogService }], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(SessionTimeoutSettingsComponent); + component = fixture.componentInstance; + + fixture.componentRef.setInput("refreshTimeoutActionSettings", refreshTimeoutActionSettings$); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("canLock", () => { + it("should return true when Lock action is available", fakeAsync(() => { + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]), + ); + + fixture.detectChanges(); + flush(); + + expect(component.canLock).toBe(true); + })); + + it("should return false when Lock action is not available", fakeAsync(() => { + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.LogOut]), + ); + + fixture.detectChanges(); + flush(); + + expect(component.canLock).toBe(false); + })); + }); + + describe("ngOnInit", () => { + it("should initialize available timeout options", fakeAsync(async () => { + fixture.detectChanges(); + flush(); + + const options = await firstValueFrom( + component["availableTimeoutOptions$"].pipe(filter((options) => options.length > 0)), + ); + + expect(options).toContainEqual({ name: "oneMinute-used-i18n", value: 1 }); + expect(options).toContainEqual({ name: "fiveMinutes-used-i18n", value: 5 }); + expect(options).toContainEqual({ + name: "onIdle-used-i18n", + value: VaultTimeoutStringType.OnIdle, + }); + expect(options).toContainEqual({ + name: "onSleep-used-i18n", + value: VaultTimeoutStringType.OnSleep, + }); + expect(options).toContainEqual({ + name: "onLocked-used-i18n", + value: VaultTimeoutStringType.OnLocked, + }); + expect(options).toContainEqual({ + name: "onRestart-used-i18n", + value: VaultTimeoutStringType.OnRestart, + }); + expect(options).toContainEqual({ + name: "never-used-i18n", + value: VaultTimeoutStringType.Never, + }); + })); + + it("should initialize available timeout actions", fakeAsync(() => { + const expectedActions = [VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]; + + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of(expectedActions), + ); + + fixture.detectChanges(); + flush(); + + expect(component["availableTimeoutActions"]()).toEqual(expectedActions); + })); + + it("should initialize timeout and action", fakeAsync(() => { + const expectedTimeout = 15; + const expectedAction = VaultTimeoutAction.Lock; + + mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() => + of(expectedTimeout), + ); + mockVaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockImplementation(() => + of(expectedAction), + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.value.timeout).toBe(expectedTimeout); + expect(component.formGroup.value.timeoutAction).toBe(expectedAction); + })); + + it("should fall back to OnRestart when current option is not available", fakeAsync(() => { + availableTimeoutOptions$.next([ + { name: "oneMinute-used-i18n", value: 1 }, + { name: "fiveMinutes-used-i18n", value: 5 }, + { name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart }, + ]); + + const unavailableTimeout = VaultTimeoutStringType.Never; + + mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() => + of(unavailableTimeout), + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.value.timeout).toBe(VaultTimeoutStringType.OnRestart); + })); + + it("should disable timeout action control when policy enforces action", fakeAsync(() => { + const policyData: MaximumVaultTimeoutPolicyData = { + minutes: 15, + action: VaultTimeoutAction.LogOut, + }; + mockPolicyService.policiesByType$.mockImplementation(() => + of([{ id: "1", data: policyData }] as Policy[]), + ); + + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]), + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.controls.timeoutAction.disabled).toBe(true); + })); + + it("should disable timeout action control when only one action is available", fakeAsync(() => { + mockPolicyService.policiesByType$.mockImplementation(() => of([])); + + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock]), + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.controls.timeoutAction.disabled).toBe(true); + })); + + it("should disable timeout action control when policy enforces action and refreshed", fakeAsync(() => { + const policies$ = new BehaviorSubject([]); + mockPolicyService.policiesByType$.mockReturnValue(policies$); + + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]), + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.controls.timeoutAction.enabled).toBe(true); + + const policyData: MaximumVaultTimeoutPolicyData = { + minutes: 15, + action: VaultTimeoutAction.LogOut, + }; + policies$.next([{ id: "1", data: policyData }] as Policy[]); + + refreshTimeoutActionSettings$.next(undefined); + flush(); + + expect(component.formGroup.controls.timeoutAction.disabled).toBe(true); + })); + + it("should disable timeout action control when only one action is available and refreshed", fakeAsync(() => { + mockPolicyService.policiesByType$.mockImplementation(() => of([])); + + const availableActions$ = new BehaviorSubject([ + VaultTimeoutAction.Lock, + VaultTimeoutAction.LogOut, + ]); + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockReturnValue( + availableActions$, + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.controls.timeoutAction.enabled).toBe(true); + + availableActions$.next([VaultTimeoutAction.Lock]); + + refreshTimeoutActionSettings$.next(undefined); + flush(); + + expect(component.formGroup.controls.timeoutAction.disabled).toBe(true); + })); + + it("should enable timeout action control when multiple actions available and no policy and refreshed", fakeAsync(() => { + mockPolicyService.policiesByType$.mockImplementation(() => of([])); + + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock]), + ); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.controls.timeoutAction.disabled).toBe(true); + + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => + of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]), + ); + + refreshTimeoutActionSettings$.next(undefined); + flush(); + + expect(component.formGroup.controls.timeoutAction.enabled).toBe(true); + })); + + it("should subscribe to timeout value changes", fakeAsync(() => { + const saveSpy = jest.spyOn(component, "saveTimeout").mockResolvedValue(undefined); + + fixture.detectChanges(); + flush(); + + const newTimeout = 30; + component.formGroup.controls.timeout.setValue(newTimeout); + flush(); + + expect(saveSpy).toHaveBeenCalledWith(mockInitialTimeout, newTimeout); + })); + + it("should subscribe to timeout action value changes", fakeAsync(() => { + const saveSpy = jest.spyOn(component, "saveTimeoutAction").mockResolvedValue(undefined); + + fixture.detectChanges(); + flush(); + + component.formGroup.controls.timeoutAction.setValue(VaultTimeoutAction.LogOut); + flush(); + + expect(saveSpy).toHaveBeenCalledWith(VaultTimeoutAction.LogOut); + })); + }); + + describe("saveTimeout", () => { + it("should not save when form control timeout is invalid", fakeAsync(async () => { + fixture.detectChanges(); + flush(); + + component.formGroup.controls.timeout.setValue(null); + + await component.saveTimeout(mockInitialTimeout, 30); + flush(); + + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled(); + })); + + it("should set new value and show confirmation dialog when setting timeout to Never and dialog confirmed", waitForAsync(async () => { + mockDialogService.openSimpleDialog.mockResolvedValue(true); + + fixture.detectChanges(); + await fixture.whenStable(); + + const previousTimeout = component.formGroup.controls.timeout.value!; + const newTimeout = VaultTimeoutStringType.Never; + + await component.saveTimeout(previousTimeout, newTimeout); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "warning" }, + content: { key: "neverLockWarning" }, + type: "warning", + }); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith( + mockUserId, + newTimeout, + mockInitialTimeoutAction, + ); + expect(mockSessionTimeoutSettingsComponentService.onTimeoutSave).toHaveBeenCalledWith( + newTimeout, + ); + })); + + it("should revert to previous value when Never confirmation is declined", waitForAsync(async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + const previousTimeout = component.formGroup.controls.timeout.value!; + const newTimeout = VaultTimeoutStringType.Never; + + await component.saveTimeout(previousTimeout, newTimeout); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "warning" }, + content: { key: "neverLockWarning" }, + type: "warning", + }); + expect(component.formGroup.controls.timeout.value).toBe(previousTimeout); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled(); + expect(mockSessionTimeoutSettingsComponentService.onTimeoutSave).not.toHaveBeenCalled(); + })); + + it.each([ + 30, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnIdle, + ])( + "should set new value when setting timeout to %s", + fakeAsync(async (timeout: VaultTimeout) => { + fixture.detectChanges(); + flush(); + + const previousTimeout = component.formGroup.controls.timeout.value!; + await component.saveTimeout(previousTimeout, timeout); + flush(); + + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith( + mockUserId, + timeout, + mockInitialTimeoutAction, + ); + expect(mockSessionTimeoutSettingsComponentService.onTimeoutSave).toHaveBeenCalledWith( + timeout, + ); + }), + ); + }); + + describe("saveTimeoutAction", () => { + it("should set new value and show confirmation dialog when setting action to LogOut and dialog confirmed", waitForAsync(async () => { + mockDialogService.openSimpleDialog.mockResolvedValue(true); + + fixture.detectChanges(); + await fixture.whenStable(); + + await component.saveTimeoutAction(VaultTimeoutAction.LogOut); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "vaultTimeoutLogOutConfirmationTitle" }, + content: { key: "vaultTimeoutLogOutConfirmation" }, + type: "warning", + }); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith( + mockUserId, + mockInitialTimeout, + VaultTimeoutAction.LogOut, + ); + })); + + it("should revert to Lock when LogOut confirmation is declined", waitForAsync(async () => { + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + fixture.detectChanges(); + await fixture.whenStable(); + + await component.saveTimeoutAction(VaultTimeoutAction.LogOut); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "vaultTimeoutLogOutConfirmationTitle" }, + content: { key: "vaultTimeoutLogOutConfirmation" }, + type: "warning", + }); + expect(component.formGroup.controls.timeoutAction.value).toBe(VaultTimeoutAction.Lock); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled(); + })); + + it("should set timeout action to Lock value when setting timeout action to Lock", fakeAsync(async () => { + fixture.detectChanges(); + flush(); + + component.formGroup.controls.timeoutAction.setValue(VaultTimeoutAction.LogOut, { + emitEvent: false, + }); + + await component.saveTimeoutAction(VaultTimeoutAction.Lock); + flush(); + + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).toHaveBeenCalledWith( + mockUserId, + mockInitialTimeout, + VaultTimeoutAction.Lock, + ); + })); + + it("should not save and show error toast when timeout has policy error", fakeAsync(async () => { + fixture.detectChanges(); + flush(); + + component.formGroup.controls.timeout.setErrors({ policyError: true }); + + await component.saveTimeoutAction(VaultTimeoutAction.Lock); + flush(); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "vaultTimeoutTooLarge-used-i18n", + }); + expect(mockVaultTimeoutSettingsService.setVaultTimeoutOptions).not.toHaveBeenCalled(); + })); + }); +}); diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts new file mode 100644 index 00000000000..7124e3f14c5 --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts @@ -0,0 +1,278 @@ +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, input, OnInit, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from "@angular/forms"; +import { RouterModule } from "@angular/router"; +import { + BehaviorSubject, + combineLatest, + concatMap, + distinctUntilChanged, + filter, + firstValueFrom, + map, + Observable, + of, + pairwise, + startWith, + switchMap, +} from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { + MaximumVaultTimeoutPolicyData, + VaultTimeout, + VaultTimeoutAction, + VaultTimeoutOption, + VaultTimeoutSettingsService, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + CheckboxModule, + DialogService, + FormFieldModule, + IconButtonModule, + ItemModule, + LinkModule, + SelectModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; + +import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "bit-session-timeout-settings", + templateUrl: "session-timeout-settings.component.html", + imports: [ + CheckboxModule, + CommonModule, + FormFieldModule, + FormsModule, + ReactiveFormsModule, + IconButtonModule, + ItemModule, + JslibModule, + LinkModule, + RouterModule, + SelectModule, + TypographyModule, + VaultTimeoutInputComponent, + ], +}) +export class SessionTimeoutSettingsComponent implements OnInit { + // TODO remove once https://bitwarden.atlassian.net/browse/PM-27283 is completed + // This is because vaultTimeoutSettingsService.availableVaultTimeoutActions$ is not reactive, hence the change detection + // needs to be manually triggered to refresh available timeout actions + readonly refreshTimeoutActionSettings = input>( + new BehaviorSubject(undefined), + ); + + formGroup = new FormGroup({ + timeout: new FormControl(null, [Validators.required]), + timeoutAction: new FormControl(VaultTimeoutAction.Lock, [ + Validators.required, + ]), + }); + protected readonly availableTimeoutActions = signal([]); + protected readonly availableTimeoutOptions$ = + this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$.pipe( + startWith([] as VaultTimeoutOption[]), + ); + protected hasVaultTimeoutPolicy$: Observable = of(false); + + private userId!: UserId; + + constructor( + private readonly vaultTimeoutSettingsService: VaultTimeoutSettingsService, + private readonly sessionTimeoutSettingsComponentService: SessionTimeoutSettingsComponentService, + private readonly i18nService: I18nService, + private readonly toastService: ToastService, + private readonly policyService: PolicyService, + private readonly accountService: AccountService, + private readonly dialogService: DialogService, + private readonly logService: LogService, + private readonly destroyRef: DestroyRef, + ) {} + + get canLock() { + return this.availableTimeoutActions().includes(VaultTimeoutAction.Lock); + } + + async ngOnInit(): Promise { + const availableTimeoutOptions = await firstValueFrom( + this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$, + ); + + this.logService.debug( + "[SessionTimeoutSettings] Available timeout options", + availableTimeoutOptions, + ); + + this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + + const maximumVaultTimeoutPolicy$ = this.policyService + .policiesByType$(PolicyType.MaximumVaultTimeout, this.userId) + .pipe(getFirstPolicy); + + this.hasVaultTimeoutPolicy$ = maximumVaultTimeoutPolicy$.pipe(map((policy) => policy != null)); + + let timeout = await firstValueFrom( + this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(this.userId), + ); + + // Fallback if current timeout option is not available on this platform + // Only applies to string-based timeout types, not numeric values + const hasCurrentOption = availableTimeoutOptions.some((opt) => opt.value === timeout); + if (!hasCurrentOption && typeof timeout !== "number") { + this.logService.debug( + "[SessionTimeoutSettings] Current timeout option not available, falling back from", + { timeout }, + ); + timeout = VaultTimeoutStringType.OnRestart; + } + + this.formGroup.patchValue( + { + timeout: timeout, + timeoutAction: await firstValueFrom( + this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId), + ), + }, + { emitEvent: false }, + ); + + this.refreshTimeoutActionSettings() + .pipe( + startWith(undefined), + switchMap(() => + combineLatest([ + this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(this.userId), + this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId), + maximumVaultTimeoutPolicy$, + ]), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(([availableActions, action, policy]) => { + this.availableTimeoutActions.set(availableActions); + this.formGroup.controls.timeoutAction.setValue(action, { emitEvent: false }); + + const policyData = policy?.data as MaximumVaultTimeoutPolicyData | undefined; + + // Enable/disable the action control based on policy or available actions + if (policyData?.action != null || availableActions.length <= 1) { + this.formGroup.controls.timeoutAction.disable({ emitEvent: false }); + } else { + this.formGroup.controls.timeoutAction.enable({ emitEvent: false }); + } + }); + + this.formGroup.controls.timeout.valueChanges + .pipe( + startWith(timeout), // emit to init pairwise + filter((value) => value != null), + distinctUntilChanged(), + pairwise(), + concatMap(async ([previousValue, newValue]) => { + await this.saveTimeout(previousValue, newValue); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + + this.formGroup.controls.timeoutAction.valueChanges + .pipe( + filter((value) => value != null), + map(async (value) => { + await this.saveTimeoutAction(value); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + } + + async saveTimeout(previousValue: VaultTimeout, newValue: VaultTimeout) { + this.formGroup.controls.timeout.markAllAsTouched(); + if (this.formGroup.controls.timeout.invalid) { + return; + } + + this.logService.debug("[SessionTimeoutSettings] Saving timeout", { previousValue, newValue }); + + if (newValue === VaultTimeoutStringType.Never) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "warning" }, + content: { key: "neverLockWarning" }, + type: "warning", + }); + + if (!confirmed) { + this.formGroup.controls.timeout.setValue(previousValue, { emitEvent: false }); + return; + } + } + + const vaultTimeoutAction = await firstValueFrom( + this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId), + ); + + await this.vaultTimeoutSettingsService.setVaultTimeoutOptions( + this.userId, + newValue, + vaultTimeoutAction, + ); + + this.sessionTimeoutSettingsComponentService.onTimeoutSave(newValue); + } + + async saveTimeoutAction(value: VaultTimeoutAction) { + this.logService.debug("[SessionTimeoutSettings] Saving timeout action", value); + + if (value === VaultTimeoutAction.LogOut) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "vaultTimeoutLogOutConfirmationTitle" }, + content: { key: "vaultTimeoutLogOutConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + this.formGroup.controls.timeoutAction.setValue(VaultTimeoutAction.Lock, { + emitEvent: false, + }); + return; + } + } + + if (this.formGroup.controls.timeout.hasError("policyError")) { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("vaultTimeoutTooLarge"), + }); + return; + } + + await this.vaultTimeoutSettingsService.setVaultTimeoutOptions( + this.userId, + this.formGroup.controls.timeout.value!, + value, + ); + } +} diff --git a/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts b/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts new file mode 100644 index 00000000000..7b9efeac9cb --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts @@ -0,0 +1,9 @@ +import { Observable } from "rxjs"; + +import { VaultTimeout, VaultTimeoutOption } from "@bitwarden/common/key-management/vault-timeout"; + +export abstract class SessionTimeoutSettingsComponentService { + abstract availableTimeoutOptions$: Observable; + + abstract onTimeoutSave(timeout: VaultTimeout): void; +} From ec5081a7e985aaf85a5472e0a5d79a68e6e54211 Mon Sep 17 00:00:00 2001 From: Matt Andreko Date: Tue, 11 Nov 2025 09:58:49 -0500 Subject: [PATCH 19/28] Clean up workflow files for Zizmor (#17318) --- .github/workflows/deploy-web.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 5aa0918048b..1deeea12f88 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -54,8 +54,7 @@ on: type: string required: false -permissions: - deployments: write +permissions: {} jobs: setup: @@ -373,10 +372,16 @@ jobs: - name: Login to Azure uses: bitwarden/gh-actions/azure-login@main + env: + # The following 2 values are ignored in Zizmor, because they have to be dynamically mapped from secrets + # The only way around this is to create separate steps per environment with static secret references, which is not maintainable + SUBSCRIPTION_ID: ${{ secrets[ needs.setup.outputs.azure_login_subscription_id_key_name ] }} # zizmor: ignore[overprovisioned-secrets] + CLIENT_ID: ${{ secrets[ needs.setup.outputs.azure_login_client_key_name ] }} # zizmor: ignore[overprovisioned-secrets] + TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} with: - subscription_id: ${{ secrets[needs.setup.outputs.azure_login_subscription_id_key_name] }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets[needs.setup.outputs.azure_login_client_key_name] }} + subscription_id: ${{ env.SUBSCRIPTION_ID }} + tenant_id: ${{ env.TENANT_ID }} + client_id: ${{ env.CLIENT_ID }} - name: Retrieve Storage Account name id: retrieve-secrets-azcopy From e5775ffe7de68a37f00829a7e501349746d7e286 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Tue, 11 Nov 2025 09:05:38 -0600 Subject: [PATCH 20/28] [PM-23375] Replace drawer with dialog (#17176) --- .../models/drawer-models.types.ts | 17 +-- .../risk-insights.component.html | 108 ------------------ .../risk-insights.component.ts | 78 ++++++------- ...risk-insights-drawer-dialog.component.html | 98 ++++++++++++++++ ...k-insights-drawer-dialog.component.spec.ts | 96 ++++++++++++++++ .../risk-insights-drawer-dialog.component.ts | 23 ++++ 6 files changed, 262 insertions(+), 158 deletions(-) create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.html create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.spec.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.ts diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts index dffb22af3ee..fc500f6fd1f 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/models/drawer-models.types.ts @@ -1,14 +1,15 @@ import { MemberDetails } from "./report-models"; // -------------------- Drawer and UI Models -------------------- -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum DrawerType { - None = 0, - AppAtRiskMembers = 1, - OrgAtRiskMembers = 2, - OrgAtRiskApps = 3, -} + +export const DrawerType = { + None: 0, + AppAtRiskMembers: 1, + OrgAtRiskMembers: 2, + OrgAtRiskApps: 3, +} as const; + +export type DrawerType = (typeof DrawerType)[keyof typeof DrawerType]; export type DrawerDetails = { open: boolean; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index 4b7d51af174..9dbfe582ac9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -109,112 +109,4 @@
    } } - - @if (dataService.drawerDetails$ | async; as drawerDetails) { - - - - - - {{ - (drawerDetails.atRiskMemberDetails.length > 0 - ? "atRiskMembersDescription" - : "atRiskMembersDescriptionNone" - ) | i18n - }} - - -
    -
    - {{ "email" | i18n }} -
    -
    - {{ "atRiskPasswords" | i18n }} -
    -
    - -
    -
    {{ member.email }}
    -
    {{ member.atRiskPasswordCount }}
    -
    -
    -
    -
    -
    - - @if (dataService.isActiveDrawerType(drawerTypes.AppAtRiskMembers)) { - - - -
    - {{ "atRiskMembersWithCount" | i18n: drawerDetails.appAtRiskMembers.members.length }} -
    -
    - {{ - (drawerDetails.appAtRiskMembers.members.length > 0 - ? "atRiskMembersDescriptionWithApp" - : "atRiskMembersDescriptionWithAppNone" - ) | i18n: drawerDetails.appAtRiskMembers.applicationName - }} -
    -
    - -
    {{ member.email }}
    -
    -
    -
    - } - - @if (dataService.isActiveDrawerType(drawerTypes.OrgAtRiskApps)) { - - - - - {{ - (drawerDetails.atRiskAppDetails.length > 0 - ? "atRiskApplicationsDescription" - : "atRiskApplicationsDescriptionNone" - ) | i18n - }} - - -
    -
    - {{ "application" | i18n }} -
    -
    - {{ "atRiskPasswords" | i18n }} -
    -
    - -
    -
    {{ app.applicationName }}
    -
    {{ app.atRiskPasswordCount }}
    -
    -
    -
    -
    - } -
    - } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index 5a5efa8225d..eddc26cbc77 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -1,10 +1,17 @@ import { animate, style, transition, trigger } from "@angular/animations"; import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core"; +import { + Component, + DestroyRef, + OnDestroy, + OnInit, + inject, + ChangeDetectionStrategy, +} from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { combineLatest, EMPTY, firstValueFrom } from "rxjs"; -import { map, tap } from "rxjs/operators"; +import { distinctUntilChanged, map, tap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -21,9 +28,8 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, ButtonModule, - DrawerBodyComponent, - DrawerComponent, - DrawerHeaderComponent, + DialogRef, + DialogService, TabsModule, } from "@bitwarden/components"; import { ExportHelper } from "@bitwarden/vault-export-core"; @@ -36,11 +42,11 @@ import { CriticalApplicationsComponent } from "./critical-applications/critical- import { EmptyStateCardComponent } from "./empty-state-card.component"; import { RiskInsightsTabType } from "./models/risk-insights.models"; import { PageLoadingComponent } from "./shared/page-loading.component"; +import { RiskInsightsDrawerDialogComponent } from "./shared/risk-insights-drawer-dialog.component"; import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.component"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "./risk-insights.component.html", imports: [ AllApplicationsComponent, @@ -52,9 +58,6 @@ import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.com JslibModule, HeaderModule, TabsModule, - DrawerComponent, - DrawerBodyComponent, - DrawerHeaderComponent, AllActivityComponent, ApplicationsLoadingComponent, PageLoadingComponent, @@ -70,7 +73,6 @@ import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.com }) export class RiskInsightsComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); - private _isDrawerOpen: boolean = false; protected ReportStatusEnum = ReportStatus; tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps; @@ -94,6 +96,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { protected emptyStateVideoSrc: string | null = "/videos/risk-insights-mark-as-critical.mp4"; protected IMPORT_ICON = "bwi bwi-download"; + protected currentDialogRef: DialogRef | null = null; // TODO: See https://github.com/bitwarden/clients/pull/16832#discussion_r2474523235 @@ -103,6 +106,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { private configService: ConfigService, protected dataService: RiskInsightsDataService, protected i18nService: I18nService, + protected dialogService: DialogService, private fileDownloadService: FileDownloadService, private logService: LogService, ) { @@ -151,14 +155,32 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { // Subscribe to drawer state changes this.dataService.drawerDetails$ - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe( + distinctUntilChanged( + (prev, curr) => + prev.activeDrawerType === curr.activeDrawerType && prev.invokerId === curr.invokerId, + ), + takeUntilDestroyed(this.destroyRef), + ) .subscribe((details) => { - this._isDrawerOpen = details.open; + if (details.activeDrawerType !== DrawerType.None) { + this.currentDialogRef = this.dialogService.openDrawer(RiskInsightsDrawerDialogComponent, { + data: details, + }); + } else { + this.currentDialogRef?.close(); + } }); + + // if any dialogs are open close it + // this happens when navigating between orgs + // or just navigating away from the page and back + this.currentDialogRef?.close(); } ngOnDestroy(): void { this.dataService.destroy(); + this.currentDialogRef?.close(); } /** @@ -179,35 +201,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { }); // close drawer when tabs are changed - this.dataService.closeDrawer(); - } - - // Get a list of drawer types - get drawerTypes(): typeof DrawerType { - return DrawerType; - } - - /** - * Special case getter for syncing drawer state from service to component. - * This allows the template to use two-way binding while staying reactive. - */ - get isDrawerOpen() { - return this._isDrawerOpen; - } - - /** - * Special case setter for syncing drawer state from component to service. - * When the drawer component closes the drawer, this syncs the state back to the service. - */ - set isDrawerOpen(value: boolean) { - if (this._isDrawerOpen !== value) { - this._isDrawerOpen = value; - - // Close the drawer in the service if the drawer component closed the drawer - if (!value) { - this.dataService.closeDrawer(); - } - } + this.currentDialogRef?.close(); } // Empty state methods diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.html new file mode 100644 index 00000000000..d4ab4c5e98f --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.html @@ -0,0 +1,98 @@ +@if (isActiveDrawerType(drawerTypes.OrgAtRiskMembers)) { + + + {{ + "atRiskMembersWithCount" | i18n: drawerDetails.atRiskMemberDetails?.length ?? 0 + }} + + + {{ + (drawerDetails.atRiskMemberDetails?.length > 0 + ? "atRiskMembersDescription" + : "atRiskMembersDescriptionNone" + ) | i18n + }} + + @if (drawerDetails.atRiskMemberDetails?.length > 0) { + +
    +
    + {{ "email" | i18n }} +
    +
    + {{ "atRiskPasswords" | i18n }} +
    +
    + @for (member of drawerDetails.atRiskMemberDetails; track member.email) { +
    +
    {{ member.email }}
    +
    {{ member.atRiskPasswordCount }}
    +
    + } +
    + } +
    +
    +} + +@if (isActiveDrawerType(drawerTypes.AppAtRiskMembers)) { + + + {{ drawerDetails.appAtRiskMembers?.applicationName }} + + +
    + {{ "atRiskMembersWithCount" | i18n: drawerDetails.appAtRiskMembers?.members.length }} +
    +
    + {{ + (drawerDetails.appAtRiskMembers?.members.length > 0 + ? "atRiskMembersDescriptionWithApp" + : "atRiskMembersDescriptionWithAppNone" + ) | i18n: drawerDetails.appAtRiskMembers?.applicationName + }} +
    +
    + @for (member of drawerDetails.appAtRiskMembers?.members; track $index) { +
    {{ member.email }}
    + } +
    +
    +
    +} + +@if (isActiveDrawerType(drawerTypes.OrgAtRiskApps)) { + + + {{ + "atRiskApplicationsWithCount" | i18n: drawerDetails.atRiskAppDetails?.length ?? 0 + }} + + + {{ + (drawerDetails.atRiskAppDetails?.length > 0 + ? "atRiskApplicationsDescription" + : "atRiskApplicationsDescriptionNone" + ) | i18n + }} + @if (drawerDetails.atRiskAppDetails?.length > 0) { + +
    +
    + {{ "application" | i18n }} +
    +
    + {{ "atRiskPasswords" | i18n }} +
    +
    + @for (app of drawerDetails.atRiskAppDetails; track app.applicationName) { +
    +
    {{ app.applicationName }}
    +
    {{ app.atRiskPasswordCount }}
    +
    + } +
    + } +
    +
    +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.spec.ts new file mode 100644 index 00000000000..2b5910ed99e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.spec.ts @@ -0,0 +1,96 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; + +import { DrawerDetails, DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DIALOG_DATA } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { RiskInsightsDrawerDialogComponent } from "./risk-insights-drawer-dialog.component"; + +beforeAll(() => { + // Mock element.animate for jsdom + // the animate function is not available in jsdom, so we provide a mock implementation + // This is necessary for tests that rely on animations + // This mock does not perform any actual animations, it just provides a structure that allows tests + // to run without throwing errors related to missing animate function + if (!HTMLElement.prototype.animate) { + HTMLElement.prototype.animate = function () { + return { + play: () => {}, + pause: () => {}, + finish: () => {}, + cancel: () => {}, + reverse: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + onfinish: null, + oncancel: null, + startTime: 0, + currentTime: 0, + playbackRate: 1, + playState: "idle", + replaceState: "active", + effect: null, + finished: Promise.resolve(), + id: "", + remove: () => {}, + timeline: null, + ready: Promise.resolve(), + } as unknown as Animation; + }; + } +}); + +describe("RiskInsightsDrawerDialogComponent", () => { + let component: RiskInsightsDrawerDialogComponent; + let fixture: ComponentFixture; + const mockI18nService = mock(); + const drawerDetails: DrawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.None, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: null, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RiskInsightsDrawerDialogComponent, BrowserAnimationsModule], + providers: [ + { provide: DIALOG_DATA, useValue: drawerDetails }, + { provide: I18nPipe, useValue: mock() }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RiskInsightsDrawerDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("drawerTypes getter", () => { + it("should return DrawerType enum", () => { + expect(component.drawerTypes).toBe(DrawerType); + }); + }); + + describe("isActiveDrawerType", () => { + it("should return true if type matches activeDrawerType", () => { + component.drawerDetails.activeDrawerType = DrawerType.None; + expect(component.isActiveDrawerType(DrawerType.None)).toBeTruthy(); + }); + + it("should return false if type does not match activeDrawerType", () => { + component.drawerDetails.activeDrawerType = DrawerType.None; + expect(component.isActiveDrawerType(DrawerType.AppAtRiskMembers)).toBeFalsy(); + }); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.ts new file mode 100644 index 00000000000..82cddda542c --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.ts @@ -0,0 +1,23 @@ +import { Component, ChangeDetectionStrategy, Inject } from "@angular/core"; + +import { DrawerDetails, DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { DIALOG_DATA } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +@Component({ + imports: [SharedModule], + templateUrl: "./risk-insights-drawer-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RiskInsightsDrawerDialogComponent { + constructor(@Inject(DIALOG_DATA) public drawerDetails: DrawerDetails) {} + + // Get a list of drawer types + get drawerTypes(): typeof DrawerType { + return DrawerType; + } + + isActiveDrawerType(type: DrawerType): boolean { + return this.drawerDetails.activeDrawerType === type; + } +} From a017e27890295b86a5b47573e9707d5e6d0ca9d9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:43:18 +0100 Subject: [PATCH 21/28] [deps] Autofill: Update tabbable to v6.3.0 (#17296) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1fce6e458e6..f0c3f2ace93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,7 +67,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "semver": "7.7.2", - "tabbable": "6.2.0", + "tabbable": "6.3.0", "tldts": "7.0.1", "ts-node": "10.9.2", "utf-8-validate": "6.0.5", @@ -38494,9 +38494,9 @@ } }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", "license": "MIT" }, "node_modules/tablesort": { diff --git a/package.json b/package.json index 250e4e4f43c..3eb6b1619cc 100644 --- a/package.json +++ b/package.json @@ -204,7 +204,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "semver": "7.7.2", - "tabbable": "6.2.0", + "tabbable": "6.3.0", "tldts": "7.0.1", "ts-node": "10.9.2", "utf-8-validate": "6.0.5", From 84340cba5c7bdfdd0de0033e6a43ef8ba8f00058 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 11 Nov 2025 16:56:59 +0100 Subject: [PATCH 22/28] Log actual import error (#17327) --- libs/importer/src/components/chrome/import-chrome.component.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/importer/src/components/chrome/import-chrome.component.ts b/libs/importer/src/components/chrome/import-chrome.component.ts index fd41d495132..5467b08ee61 100644 --- a/libs/importer/src/components/chrome/import-chrome.component.ts +++ b/libs/importer/src/components/chrome/import-chrome.component.ts @@ -142,6 +142,8 @@ export class ImportChromeComponent implements OnInit, OnDestroy { // If any of the login items has a failure return a generic error message // Introduced because we ran into a new type of V3 encryption added on Chrome that we don't yet support if (logins.some((l) => l.failure != null)) { + const error = logins.find((l) => l.failure != null); + this.logService.error("Chromium importer failure:", error.failure.error); return { errors: { message: this.i18nService.t("errorOccurred"), From 3c1262c9990eceee42c3f39c3e02b4ce785a57fb Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:32:07 -0600 Subject: [PATCH 23/28] [PM-27854] close dialog when redirecting to premium page (#17243) --- .../services/web-premium-upgrade-prompt.service.spec.ts | 7 +++++++ .../vault/services/web-premium-upgrade-prompt.service.ts | 3 +++ 2 files changed, 10 insertions(+) diff --git a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts index ad16baee42e..f9319e87656 100644 --- a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.spec.ts @@ -187,6 +187,13 @@ describe("WebVaultPremiumUpgradePromptService", () => { expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]); expect(dialogServiceMock.openSimpleDialog).not.toHaveBeenCalled(); }); + + it("should close dialog when redirecting to subscription page", async () => { + await service.promptForPremium(); + + expect(dialogRefMock.close).toHaveBeenCalledWith(VaultItemDialogResult.PremiumUpgrade); + expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]); + }); }); describe("when not self-hosted", () => { diff --git a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts index c456cf6cc13..917a2761e24 100644 --- a/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts +++ b/apps/web/src/app/vault/services/web-premium-upgrade-prompt.service.ts @@ -107,6 +107,9 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt private async redirectToSubscriptionPage() { await this.router.navigate([this.subscriptionPageRoute]); + if (this.dialog) { + this.dialog.close(VaultItemDialogResult.PremiumUpgrade); + } } private async openUpgradeDialog(account: Account) { From 09b6c35d9f5b1c336a2af416dd06fbf904af17ef Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:51:46 -0600 Subject: [PATCH 24/28] [PM-279699] Clear premium interest when user subscribes or closes dialog (#17221) * Clear premium interest when user subscribes to premium or backs out of dialog * Kyle's feedback --- .../unified-upgrade-dialog.component.spec.ts | 184 +++++++++++++++++- .../unified-upgrade-dialog.component.ts | 27 ++- 2 files changed, 205 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts index 32c67df1434..7f698ae50d1 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts @@ -1,8 +1,10 @@ import { Component, input, output } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { Router } from "@angular/router"; import { mock } from "jest-mock-extended"; +import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { PersonalSubscriptionPricingTierId, @@ -58,6 +60,8 @@ describe("UnifiedUpgradeDialogComponent", () => { let component: UnifiedUpgradeDialogComponent; let fixture: ComponentFixture; const mockDialogRef = mock(); + const mockRouter = mock(); + const mockPremiumInterestStateService = mock(); const mockAccount: Account = { id: "user-id" as UserId, @@ -74,11 +78,16 @@ describe("UnifiedUpgradeDialogComponent", () => { }; beforeEach(async () => { + // Reset mocks + jest.clearAllMocks(); + await TestBed.configureTestingModule({ imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], providers: [ { provide: DialogRef, useValue: mockDialogRef }, { provide: DIALOG_DATA, useValue: defaultDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, ], }) .overrideComponent(UnifiedUpgradeDialogComponent, { @@ -121,6 +130,8 @@ describe("UnifiedUpgradeDialogComponent", () => { providers: [ { provide: DialogRef, useValue: mockDialogRef }, { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, ], }) .overrideComponent(UnifiedUpgradeDialogComponent, { @@ -161,6 +172,8 @@ describe("UnifiedUpgradeDialogComponent", () => { providers: [ { provide: DialogRef, useValue: mockDialogRef }, { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, ], }) .overrideComponent(UnifiedUpgradeDialogComponent, { @@ -191,11 +204,11 @@ describe("UnifiedUpgradeDialogComponent", () => { }); describe("previousStep", () => { - it("should go back to plan selection and clear selected plan", () => { + it("should go back to plan selection and clear selected plan", async () => { component["step"].set(UnifiedUpgradeDialogStep.Payment); component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium); - component["previousStep"](); + await component["previousStep"](); expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection); expect(component["selectedPlan"]()).toBeNull(); @@ -222,6 +235,8 @@ describe("UnifiedUpgradeDialogComponent", () => { providers: [ { provide: DialogRef, useValue: mockDialogRef }, { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, ], }) .overrideComponent(UnifiedUpgradeDialogComponent, { @@ -241,4 +256,169 @@ describe("UnifiedUpgradeDialogComponent", () => { expect(customComponent["hideContinueWithoutUpgradingButton"]()).toBe(true); }); }); + + describe("onComplete with premium interest", () => { + it("should check premium interest, clear it, and route to /vault when premium interest exists", async () => { + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(true); + mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); + mockRouter.navigate.mockResolvedValue(true); + + const result: UpgradePaymentResult = { + status: "upgradedToPremium", + organizationId: null, + }; + + await component["onComplete"](result); + + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockRouter.navigate).toHaveBeenCalledWith(["/vault"]); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToPremium", + organizationId: null, + }); + }); + + it("should not clear premium interest when upgrading to families", async () => { + const result: UpgradePaymentResult = { + status: "upgradedToFamilies", + organizationId: "org-123", + }; + + await component["onComplete"](result); + + expect(mockPremiumInterestStateService.getPremiumInterest).not.toHaveBeenCalled(); + expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToFamilies", + organizationId: "org-123", + }); + }); + + it("should use standard redirect when no premium interest exists", async () => { + TestBed.resetTestingModule(); + + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + redirectOnCompletion: true, + }; + + mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false); + mockRouter.navigate.mockResolvedValue(true); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const customComponent = customFixture.componentInstance; + customFixture.detectChanges(); + + const result: UpgradePaymentResult = { + status: "upgradedToPremium", + organizationId: null, + }; + + await customComponent["onComplete"](result); + + expect(mockPremiumInterestStateService.getPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalledWith([ + "/settings/subscription/user-subscription", + ]); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + status: "upgradedToPremium", + organizationId: null, + }); + }); + }); + + describe("onCloseClicked with premium interest", () => { + it("should clear premium interest when modal is closed", async () => { + mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); + + await component["onCloseClicked"](); + + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); + }); + }); + + describe("previousStep with premium interest", () => { + it("should NOT clear premium interest when navigating between steps", async () => { + component["step"].set(UnifiedUpgradeDialogStep.Payment); + component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium); + + await component["previousStep"](); + + expect(mockPremiumInterestStateService.clearPremiumInterest).not.toHaveBeenCalled(); + expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection); + expect(component["selectedPlan"]()).toBeNull(); + }); + + it("should clear premium interest when backing out of dialog completely", async () => { + TestBed.resetTestingModule(); + + const customDialogData: UnifiedUpgradeDialogParams = { + account: mockAccount, + initialStep: UnifiedUpgradeDialogStep.Payment, + selectedPlan: PersonalSubscriptionPricingTierIds.Premium, + }; + + mockPremiumInterestStateService.clearPremiumInterest.mockResolvedValue(); + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { provide: DIALOG_DATA, useValue: customDialogData }, + { provide: Router, useValue: mockRouter }, + { provide: PremiumInterestStateService, useValue: mockPremiumInterestStateService }, + ], + }) + .overrideComponent(UnifiedUpgradeDialogComponent, { + remove: { + imports: [UpgradeAccountComponent, UpgradePaymentComponent], + }, + add: { + imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent], + }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent); + const customComponent = customFixture.componentInstance; + customFixture.detectChanges(); + + await customComponent["previousStep"](); + + expect(mockPremiumInterestStateService.clearPremiumInterest).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" }); + }); + }); }); diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index 07b21a9fb4b..02d48e8d8f4 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, Inject, OnInit, signal } from "@angular/core"; import { Router } from "@angular/router"; +import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { PersonalSubscriptionPricingTierId } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; @@ -94,6 +95,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit { private dialogRef: DialogRef, @Inject(DIALOG_DATA) private params: UnifiedUpgradeDialogParams, private router: Router, + private premiumInterestStateService: PremiumInterestStateService, ) {} ngOnInit(): void { @@ -110,7 +112,9 @@ export class UnifiedUpgradeDialogComponent implements OnInit { this.selectedPlan.set(planId); this.nextStep(); } - protected onCloseClicked(): void { + protected async onCloseClicked(): Promise { + // Clear premium interest when user closes/abandons modal + await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); this.close({ status: UnifiedUpgradeDialogStatus.Closed }); } @@ -124,18 +128,20 @@ export class UnifiedUpgradeDialogComponent implements OnInit { } } - protected previousStep(): void { + protected async previousStep(): Promise { // If we are on the payment step and there was no initial step, go back to plan selection this is to prevent // going back to payment step if the dialog was opened directly to payment step if (this.step() === UnifiedUpgradeDialogStep.Payment && this.params?.initialStep == null) { this.step.set(UnifiedUpgradeDialogStep.PlanSelection); this.selectedPlan.set(null); } else { + // Clear premium interest when backing out of dialog completely + await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); this.close({ status: UnifiedUpgradeDialogStatus.Closed }); } } - protected onComplete(result: UpgradePaymentResult): void { + protected async onComplete(result: UpgradePaymentResult): Promise { let status: UnifiedUpgradeDialogStatus; switch (result.status) { case "upgradedToPremium": @@ -153,6 +159,19 @@ export class UnifiedUpgradeDialogComponent implements OnInit { this.close({ status, organizationId: result.organizationId }); + // Check premium interest and route to vault for marketing-initiated premium upgrades + if (status === UnifiedUpgradeDialogStatus.UpgradedToPremium) { + const hasPremiumInterest = await this.premiumInterestStateService.getPremiumInterest( + this.params.account.id, + ); + if (hasPremiumInterest) { + await this.premiumInterestStateService.clearPremiumInterest(this.params.account.id); + await this.router.navigate(["/vault"]); + return; // Exit early, don't use redirectOnCompletion + } + } + + // Use redirectOnCompletion for standard upgrade flows if ( this.params.redirectOnCompletion && (status === UnifiedUpgradeDialogStatus.UpgradedToPremium || @@ -162,7 +181,7 @@ export class UnifiedUpgradeDialogComponent implements OnInit { status === UnifiedUpgradeDialogStatus.UpgradedToFamilies ? `/organizations/${result.organizationId}/vault` : "/settings/subscription/user-subscription"; - void this.router.navigate([redirectUrl]); + await this.router.navigate([redirectUrl]); } } From b05abdb99cf81c47584ab760a77f7db8f9935e98 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Tue, 11 Nov 2025 09:26:57 -0800 Subject: [PATCH 25/28] [PM-24066] - handle unknown ciphers in individual vault (#17323) * handle unknown ciphers in individual vault * handle in edit --- .../vault/individual-vault/vault.component.ts | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 4c23119f1eb..07e810a0cbf 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -550,15 +550,7 @@ export class VaultComponent implements OnInit, OnDestr await this.editCipherId(cipherId); } } else { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("unknownCipher"), - }); - await this.router.navigate([], { - queryParams: { itemId: null, cipherId: null }, - queryParamsHandling: "merge", - }); + await this.handleUnknownCipher(); } } }), @@ -714,6 +706,18 @@ export class VaultComponent implements OnInit, OnDestr } } + async handleUnknownCipher() { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("unknownCipher"), + }); + await this.router.navigate([], { + queryParams: { itemId: null, cipherId: null }, + queryParamsHandling: "merge", + }); + } + async archive(cipher: C) { const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); @@ -997,6 +1001,10 @@ export class VaultComponent implements OnInit, OnDestr async editCipherId(id: string, cloneMode?: boolean) { const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); const cipher = await this.cipherService.get(id, activeUserId); + if (!cipher) { + await this.handleUnknownCipher(); + return; + } if ( cipher && @@ -1034,6 +1042,10 @@ export class VaultComponent implements OnInit, OnDestr async viewCipherById(id: string) { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const cipher = await this.cipherService.get(id, activeUserId); + if (!cipher) { + await this.handleUnknownCipher(); + return; + } // If cipher exists (cipher is null when new) and MP reprompt // is on for this cipher, then show password reprompt. if ( From 089caf57c2b4864717c3dfb2f285434912e326b2 Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Tue, 11 Nov 2025 13:11:33 -0500 Subject: [PATCH 26/28] [PM-27757] init new apps state (#17200) * feat(dirt): add "needs review" state for applications needing initial review - Add showNeedsReviewState to display warning when all apps lack review dates - Track noAppsHaveReviewDate flag to identify unreviewed applications - Add i18n strings for organization items count and review prompt - Update activity card to show 3 states: all caught up, needs review, new apps - Apply tw-col-span-2 to needs review card for better visibility * refactor: split activity card states into separate @if blocks for readability * fix: set hasLoadedApplicationData when summary data arrives Previously, hasLoadedApplicationData was only set in the enrichedReportData$ subscription, which fired after reportSummary$ and newApplications$. This caused a timing issue where showNeedsReviewState would remain false even when newApplicationsCount === totalApplicationCount because the flag wasn't set yet. Now we set hasLoadedApplicationData=true as soon as reportSummary$ arrives with totalApplicationCount > 0, ensuring proper synchronization. --------- Co-authored-by: Tom --- apps/web/src/locales/en/messages.json | 15 ++++ .../activity/all-activity.component.html | 69 +++++++++++++------ .../activity/all-activity.component.ts | 23 +++++++ 3 files changed, 86 insertions(+), 21 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 761cc2941d4..49e29f00748 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -373,6 +373,21 @@ "noNewApplicationsToReviewAtThisTime": { "message": "No new applications to review at this time" }, + "organizationHasItemsSavedForApplications": { + "message": "Your organization has items saved for $COUNT$ applications", + "placeholders": { + "count": { + "content": "$1", + "example": "310" + } + } + }, + "reviewApplicationsToSecureItems": { + "message": "Review applications to secure the items most critical to your organization's security" + }, + "reviewApplications": { + "message": "Review applications" + }, "prioritizeCriticalApplications": { "message": "Prioritize critical applications" }, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html index d8ad785ff14..43cf936e1a1 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html @@ -44,26 +44,53 @@
  • -
  • - - -
  • + + @if (isAllCaughtUp) { +
  • + + +
  • + } + + @else if (showNeedsReviewState) { +
  • + + +
  • + } + + @else { +
  • + + +
  • + } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts index 06073d93c85..907e8883a43 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts @@ -39,12 +39,14 @@ export class AllActivityComponent implements OnInit { totalCriticalAppsAtRiskMemberCount = 0; totalCriticalAppsCount = 0; totalCriticalAppsAtRiskCount = 0; + totalApplicationCount = 0; newApplicationsCount = 0; newApplications: ApplicationHealthReportDetail[] = []; extendPasswordChangeWidget = false; allAppsHaveReviewDate = false; isAllCaughtUp = false; hasLoadedApplicationData = false; + showNeedsReviewState = false; destroyRef = inject(DestroyRef); @@ -65,6 +67,12 @@ export class AllActivityComponent implements OnInit { this.totalCriticalAppsAtRiskMemberCount = summary.totalCriticalAtRiskMemberCount; this.totalCriticalAppsCount = summary.totalCriticalApplicationCount; this.totalCriticalAppsAtRiskCount = summary.totalCriticalAtRiskApplicationCount; + this.totalApplicationCount = summary.totalApplicationCount; + // If we have application data, mark as loaded + if (summary.totalApplicationCount > 0) { + this.hasLoadedApplicationData = true; + } + this.updateShowNeedsReviewState(); }); this.dataService.newApplications$ @@ -73,6 +81,7 @@ export class AllActivityComponent implements OnInit { this.newApplications = newApps; this.newApplicationsCount = newApps.length; this.updateIsAllCaughtUp(); + this.updateShowNeedsReviewState(); }); this.allActivitiesService.extendPasswordChangeWidget$ @@ -112,6 +121,20 @@ export class AllActivityComponent implements OnInit { this.allAppsHaveReviewDate; } + /** + * Updates the showNeedsReviewState flag based on current state. + * This state is shown when: + * - Data has been loaded + * - There are applications (totalApplicationCount > 0) + * - ALL apps do NOT have a review date (newApplicationsCount === totalApplicationCount) + */ + private updateShowNeedsReviewState(): void { + this.showNeedsReviewState = + this.hasLoadedApplicationData && + this.totalApplicationCount > 0 && + this.newApplicationsCount === this.totalApplicationCount; + } + /** * Handles the review new applications button click. * Opens a dialog showing the list of new applications that can be marked as critical. From 785b1cfdd27aca54920c7056da2a82bb671a7eed Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:20:53 -0600 Subject: [PATCH 27/28] add skeleton loader feature flag (#17337) --- libs/common/src/enums/feature-flag.enum.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index cce339beaf1..2d071259aba 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -62,6 +62,7 @@ export enum FeatureFlag { CipherKeyEncryption = "cipher-key-encryption", AutofillConfirmation = "pm-25083-autofill-confirm-from-search", RiskInsightsForPremium = "pm-23904-risk-insights-for-premium", + VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", /* Platform */ IpcChannelFramework = "ipc-channel-framework", @@ -111,6 +112,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, [FeatureFlag.AutofillConfirmation]: FALSE, [FeatureFlag.RiskInsightsForPremium]: FALSE, + [FeatureFlag.VaultLoadingSkeletons]: FALSE, /* Auth */ [FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE, From 421edfb0203f05ec1c6e19a06ddff5690f16a401 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 11 Nov 2025 15:51:01 -0500 Subject: [PATCH 28/28] [PM-28034] Pre-Launch Payment Defect Solution (#17331) * fix(billing): update to password manager to signal * fix(billing): take first value so the dialog doesn't show again * fix(billing): add families plan to request builder * fix(billing): feedback and type update * fix(billing): fix selectedplan call --- .../cloud-hosted-premium-vnext.component.ts | 2 + .../upgrade-payment.component.html | 22 ++++---- .../upgrade-payment.component.ts | 52 ++++++++++--------- .../services/organization-billing.service.ts | 1 + 4 files changed, 41 insertions(+), 36 deletions(-) diff --git a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.ts b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.ts index 9fb34a6ccf0..d78451e4f3a 100644 --- a/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.ts +++ b/apps/web/src/app/billing/individual/premium/cloud-hosted-premium-vnext.component.ts @@ -11,6 +11,7 @@ import { of, shareReplay, switchMap, + take, } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -182,6 +183,7 @@ export class CloudHostedPremiumVNextComponent { this.shouldShowUpgradeDialogOnInit$ .pipe( + take(1), switchMap((shouldShowUpgradeDialogOnInit) => { if (shouldShowUpgradeDialogOnInit) { return from(this.openUpgradeDialog("Premium")); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html index 4911c1f1983..45a68136a00 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html @@ -1,6 +1,6 @@
    - {{ upgradeToMessage }} + {{ upgradeToMessage() }}
    @if (isFamiliesPlan) { @@ -50,17 +50,15 @@
    - @if (passwordManager) { - - @if (isFamiliesPlan) { -

    - {{ "paymentChargedWithTrial" | i18n }} -

    - } + + @if (isFamiliesPlan) { +

    + {{ "paymentChargedWithTrial" | i18n }} +

    }
    diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index 208d046caa7..a824e850db6 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -1,6 +1,7 @@ import { AfterViewInit, Component, + computed, DestroyRef, input, OnInit, @@ -34,7 +35,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; -import { CartSummaryComponent, LineItem } from "@bitwarden/pricing"; +import { CartSummaryComponent } from "@bitwarden/pricing"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { @@ -104,8 +105,6 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { protected readonly account = input.required(); protected goBack = output(); protected complete = output(); - protected selectedPlan: PlanDetails | null = null; - protected hasEnoughAccountCredit$!: Observable; readonly paymentComponent = viewChild.required(EnterPaymentMethodComponent); readonly cartSummaryComponent = viewChild.required(CartSummaryComponent); @@ -116,15 +115,26 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { billingAddress: EnterBillingAddressComponent.getFormGroup(), }); + protected readonly selectedPlan = signal(null); protected readonly loading = signal(true); - private pricingTiers$!: Observable; - + protected readonly upgradeToMessage = signal(""); // Cart Summary data - protected passwordManager!: LineItem; - protected estimatedTax$!: Observable; + protected readonly passwordManager = computed(() => { + if (!this.selectedPlan()) { + return { name: "", cost: 0, quantity: 0, cadence: "year" as const }; + } - // Display data - protected upgradeToMessage = ""; + return { + name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", + cost: this.selectedPlan()!.details.passwordManager.annualPrice, + quantity: 1, + cadence: "year" as const, + }; + }); + + protected hasEnoughAccountCredit$!: Observable; + private pricingTiers$!: Observable; + protected estimatedTax$!: Observable; constructor( private i18nService: I18nService, @@ -162,19 +172,13 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { const planDetails = plans.find((plan) => plan.id === this.selectedPlanId()); if (planDetails) { - this.selectedPlan = { + this.selectedPlan.set({ tier: this.selectedPlanId(), details: planDetails, - }; - this.passwordManager = { - name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", - cost: this.selectedPlan.details.passwordManager.annualPrice, - quantity: 1, - cadence: "year", - }; + }); - this.upgradeToMessage = this.i18nService.t( - this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium", + this.upgradeToMessage.set( + this.i18nService.t(this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium"), ); } else { this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); @@ -228,7 +232,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { return; } - if (!this.selectedPlan) { + if (!this.selectedPlan()) { throw new Error("No plan selected"); } @@ -260,7 +264,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { } private async processUpgrade(): Promise { - if (!this.selectedPlan) { + if (!this.selectedPlan()) { throw new Error("No plan selected"); } @@ -308,7 +312,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { const response = await this.upgradePaymentService.upgradeToFamilies( this.account(), - this.selectedPlan!, + this.selectedPlan()!, paymentMethod, paymentFormValues, ); @@ -344,7 +348,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { // Create an observable for tax calculation private refreshSalesTax$(): Observable { - if (this.formGroup.invalid || !this.selectedPlan) { + if (this.formGroup.invalid || !this.selectedPlan()) { return of(this.INITIAL_TAX_VALUE); } @@ -353,7 +357,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { return of(this.INITIAL_TAX_VALUE); } return from( - this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan, billingAddress), + this.upgradePaymentService.calculateEstimatedTax(this.selectedPlan()!, billingAddress), ).pipe( catchError((error: unknown) => { this.logService.error("Tax calculation failed:", error); diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts index 4120047a15f..fdc9c6215c9 100644 --- a/libs/common/src/billing/services/organization-billing.service.ts +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -135,6 +135,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs case PlanType.Free: case PlanType.FamiliesAnnually: case PlanType.FamiliesAnnually2019: + case PlanType.FamiliesAnnually2025: case PlanType.TeamsStarter2023: case PlanType.TeamsStarter: return true;