From dcabc0165a952cb2de13f9d8557255529d25b4ae Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Tue, 11 Nov 2025 09:33:27 +0100 Subject: [PATCH 01/12] lint: apply zizmor auto-fixes (#17306) --- .github/workflows/build-desktop.yml | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 7902cdb1534..68fa2ac255e 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -725,6 +725,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up Node uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 @@ -826,22 +827,22 @@ jobs: - name: Rename appx files for store if: ${{ needs.setup.outputs.has_secrets == 'true' }} run: | - Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx" ` - -Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx" - Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx" ` - -Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx" - Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx" ` - -Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx" + Copy-Item "./dist/Bitwarden-Beta-$env:_PACKAGE_VERSION-ia32.appx" ` + -Destination "./dist/Bitwarden-Beta-$env:_PACKAGE_VERSION-ia32-store.appx" + Copy-Item "./dist/Bitwarden-Beta-$env:_PACKAGE_VERSION-x64.appx" ` + -Destination "./dist/Bitwarden-Beta-$env:_PACKAGE_VERSION-x64-store.appx" + Copy-Item "./dist/Bitwarden-Beta-$env:_PACKAGE_VERSION-arm64.appx" ` + -Destination "./dist/Bitwarden-Beta-$env:_PACKAGE_VERSION-arm64-store.appx" - name: Fix NSIS artifact names for auto-updater if: ${{ needs.setup.outputs.has_secrets == 'true' }} run: | - Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z ` - -NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z - Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z ` - -NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z - Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z ` - -NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z + Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-$env:_PACKAGE_VERSION-ia32.nsis.7z ` + -NewName bitwarden-beta-$env:_PACKAGE_VERSION-ia32.nsis.7z + Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-$env:_PACKAGE_VERSION-x64.nsis.7z ` + -NewName bitwarden-beta-$env:_PACKAGE_VERSION-x64.nsis.7z + Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-$env:_PACKAGE_VERSION-arm64.nsis.7z ` + -NewName bitwarden-beta-$env:_PACKAGE_VERSION-arm64.nsis.7z - name: Upload portable exe artifact uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 From 97ce47b832bbcd51e18546eda160e2512cdf1293 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Tue, 11 Nov 2025 07:57:58 -0500 Subject: [PATCH 02/12] build: swap to a workflow_dispatch trigger for sdk breaking change detection (#17314) * swap to repository dispatch Introduces GitHub Actions workflow that detects TypeScript breaking changes when SDK artifacts are updated. Workflow is triggered via repository_dispatch from SDK repository and runs npm test:types with newly built SDK artifacts. The workflow downloads SDK build artifacts, installs them locally, and executes the existing TypeScript type checking process. Exit codes determine success/failure for SDK repository monitoring via gh run watch. Addresses issue where breaking changes in SDK are discovered only when clients attempt SDK version updates, rather than during SDK development. * review: claude fixes --- .../workflows/sdk-breaking-change-check.yml | 81 +++++++++---------- 1 file changed, 39 insertions(+), 42 deletions(-) diff --git a/.github/workflows/sdk-breaking-change-check.yml b/.github/workflows/sdk-breaking-change-check.yml index 49b91d2d1a1..29a25181b75 100644 --- a/.github/workflows/sdk-breaking-change-check.yml +++ b/.github/workflows/sdk-breaking-change-check.yml @@ -1,10 +1,26 @@ # This workflow runs TypeScript compatibility checks when the SDK is updated. -# Triggered automatically by the SDK repository via repository_dispatch when SDK PRs are created/updated. +# Triggered automatically by the SDK repository via workflow_dispatch when SDK PRs are created/updated. name: SDK Breaking Change Check -run-name: "SDK breaking change check (${{ github.event.client_payload.sdk_version }})" +run-name: "SDK breaking change check (${{ github.event.inputs.sdk_version }})" on: - repository_dispatch: - types: [sdk-breaking-change-check] + workflow_dispatch: + inputs: + sdk_version: + description: "SDK version being tested" + required: true + type: string + source_repo: + description: "Source repository" + required: true + type: string + artifacts_run_id: + description: "Artifacts run ID" + required: true + type: string + artifact_name: + description: "Artifact name" + required: true + type: string permissions: contents: read @@ -17,12 +33,11 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 env: - _SOURCE_REPO: ${{ github.event.client_payload.source_repo }} - _SDK_VERSION: ${{ github.event.client_payload.sdk_version }} - _ARTIFACTS_RUN_ID: ${{ github.event.client_payload.artifacts_info.run_id }} - _ARTIFACT_NAME: ${{ github.event.client_payload.artifacts_info.artifact_name }} - _CLIENT_LABEL: ${{ github.event.client_payload.client_label }} - + _SOURCE_REPO: ${{ github.event.inputs.source_repo }} + _SDK_VERSION: ${{ github.event.inputs.sdk_version }} + _ARTIFACTS_RUN_ID: ${{ github.event.inputs.artifacts_run_id }} + _ARTIFACT_NAME: ${{ github.event.inputs.artifact_name }} + steps: - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -45,21 +60,7 @@ jobs: private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main - - name: Validate inputs - run: | - echo "🔍 Validating required client_payload fields..." - if [ -z "${_SOURCE_REPO}" ] || [ -z "${_SDK_VERSION}" ] || [ -z "${_ARTIFACTS_RUN_ID}" ] || [ -z "${_ARTIFACT_NAME}" ]; then - echo "::error::Missing required client_payload fields" - echo "SOURCE_REPO: ${_SOURCE_REPO}" - echo "SDK_VERSION: ${_SDK_VERSION}" - echo "ARTIFACTS_RUN_ID: ${_ARTIFACTS_RUN_ID}" - echo "ARTIFACT_NAME: ${_ARTIFACT_NAME}" - echo "CLIENT_LABEL: ${_CLIENT_LABEL}" - exit 1 - fi - - echo "✅ All required payload fields are present" - name: Check out clients repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: @@ -134,34 +135,30 @@ jobs: - name: Run TypeScript compatibility check run: | - echo "🔍 Running TypeScript type checking for ${_CLIENT_LABEL} client with SDK version: ${_SDK_VERSION}" + echo "🔍 Running TypeScript type checking with SDK version: ${_SDK_VERSION}" echo "🎯 Type checking command: npm run test:types" # Add GitHub Step Summary output - { - echo "## 📊 TypeScript Compatibility Check (${_CLIENT_LABEL})" - echo "- **Client**: ${_CLIENT_LABEL}" - echo "- **SDK Version**: ${_SDK_VERSION}" - echo "- **Source Repository**: ${_SOURCE_REPO}" - echo "- **Artifacts Run ID**: ${_ARTIFACTS_RUN_ID}" - echo "" - } >> "$GITHUB_STEP_SUMMARY" - - + echo "## 📊 TypeScript Compatibility Check" >> $GITHUB_STEP_SUMMARY + echo "- **SDK Version**: ${_SDK_VERSION}" >> $GITHUB_STEP_SUMMARY + echo "- **Source Repository**: ${_SOURCE_REPO}" >> $GITHUB_STEP_SUMMARY + echo "- **Artifacts Run ID**: ${_ARTIFACTS_RUN_ID}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + TYPE_CHECK_START=$(date +%s) # Run type check with timeout - exit code determines gh run watch result if timeout 10m npm run test:types; then TYPE_CHECK_END=$(date +%s) TYPE_CHECK_DURATION=$((TYPE_CHECK_END - TYPE_CHECK_START)) - echo "✅ TypeScript compilation successful for ${_CLIENT_LABEL} client (${TYPE_CHECK_DURATION}s)" - echo "✅ **Result**: TypeScript compilation successful" >> "$GITHUB_STEP_SUMMARY" - echo "No breaking changes detected in ${_CLIENT_LABEL} client for SDK version ${_SDK_VERSION}" >> "$GITHUB_STEP_SUMMARY" + echo "✅ TypeScript compilation successful (${TYPE_CHECK_DURATION}s)" + echo "✅ **Result**: TypeScript compilation successful" >> $GITHUB_STEP_SUMMARY + echo "No breaking changes detected for SDK version ${_SDK_VERSION}" >> $GITHUB_STEP_SUMMARY else TYPE_CHECK_END=$(date +%s) TYPE_CHECK_DURATION=$((TYPE_CHECK_END - TYPE_CHECK_START)) - echo "❌ TypeScript compilation failed for ${_CLIENT_LABEL} client after ${TYPE_CHECK_DURATION}s - breaking changes detected" - echo "❌ **Result**: TypeScript compilation failed" >> "$GITHUB_STEP_SUMMARY" - echo "Breaking changes detected in ${_CLIENT_LABEL} client for SDK version ${_SDK_VERSION}" >> "$GITHUB_STEP_SUMMARY" + echo "❌ TypeScript compilation failed after ${TYPE_CHECK_DURATION}s - breaking changes detected" + echo "❌ **Result**: TypeScript compilation failed" >> $GITHUB_STEP_SUMMARY + echo "Breaking changes detected for SDK version ${_SDK_VERSION}" >> $GITHUB_STEP_SUMMARY exit 1 - fi \ No newline at end of file + fi From a66227638e5c7582e8680a918f70240fcea98b27 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Tue, 11 Nov 2025 15:01:03 +0100 Subject: [PATCH 03/12] Update sdk-internal to 374 (fix breaking changes) (#17325) * feat: update sdk-internal * fix: symbol.dispose breaking change * fix: TextEncoder/Decoder breaking change * fix: move polyfills to a global setup script * typo --- libs/common/src/platform/ipc/ipc-message.ts | 3 ++- .../services/sdk/default-sdk.service.spec.ts | 1 + libs/shared/jest.config.ts.js | 2 ++ libs/shared/polyfill-node-globals.ts | 11 +++++++++++ .../vault-export-core/jest.config.js | 1 + package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 7 files changed, 27 insertions(+), 11 deletions(-) create mode 100644 libs/shared/polyfill-node-globals.ts diff --git a/libs/common/src/platform/ipc/ipc-message.ts b/libs/common/src/platform/ipc/ipc-message.ts index abd352da1c0..c3ac6360597 100644 --- a/libs/common/src/platform/ipc/ipc-message.ts +++ b/libs/common/src/platform/ipc/ipc-message.ts @@ -5,7 +5,8 @@ export interface IpcMessage { message: SerializedOutgoingMessage; } -export interface SerializedOutgoingMessage extends Omit { +export interface SerializedOutgoingMessage + extends Omit { payload: number[]; } diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts index c78e6370ffb..769e8521d88 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts @@ -249,6 +249,7 @@ function createMockClient(): MockProxy { state: jest.fn().mockReturnValue(mock()), load_flags: jest.fn().mockReturnValue(mock()), free: mock(), + [Symbol.dispose]: jest.fn(), }); return client; } diff --git a/libs/shared/jest.config.ts.js b/libs/shared/jest.config.ts.js index 04ab80859ba..b558a86611f 100644 --- a/libs/shared/jest.config.ts.js +++ b/libs/shared/jest.config.ts.js @@ -9,6 +9,8 @@ module.exports = { // Also anecdotally improves performance when run locally maxWorkers: 3, + setupFiles: ["/../../libs/shared/polyfill-node-globals.ts"], + transform: { "^.+\\.tsx?$": [ "ts-jest", diff --git a/libs/shared/polyfill-node-globals.ts b/libs/shared/polyfill-node-globals.ts new file mode 100644 index 00000000000..5f021875638 --- /dev/null +++ b/libs/shared/polyfill-node-globals.ts @@ -0,0 +1,11 @@ +import { TextEncoder, TextDecoder } from "util"; + +// SDK/WASM code relies on TextEncoder/TextDecoder being available globally +// We can't use `test.environment.ts` because that breaks other tests that rely on +// the default jest jsdom environment +if (!(globalThis as any).TextEncoder) { + (globalThis as any).TextEncoder = TextEncoder; +} +if (!(globalThis as any).TextDecoder) { + (globalThis as any).TextDecoder = TextDecoder as unknown as typeof globalThis.TextDecoder; +} diff --git a/libs/tools/export/vault-export/vault-export-core/jest.config.js b/libs/tools/export/vault-export/vault-export-core/jest.config.js index 68c286de3d3..61ffa2ccf52 100644 --- a/libs/tools/export/vault-export/vault-export-core/jest.config.js +++ b/libs/tools/export/vault-export/vault-export-core/jest.config.js @@ -7,6 +7,7 @@ module.exports = { testMatch: ["**/+(*.)+(spec).+(ts)"], preset: "ts-jest", testEnvironment: "jsdom", + setupFiles: ["/../../../../../libs/shared/polyfill-node-globals.ts"], moduleNameMapper: pathsToModuleNameMapper( { "@bitwarden/common/spec": ["libs/common/spec"], ...(compilerOptions?.paths ?? {}) }, { diff --git a/package-lock.json b/package-lock.json index 07e98938cd2..1fce6e458e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,8 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.369", - "@bitwarden/sdk-internal": "0.2.0-main.369", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.374", + "@bitwarden/sdk-internal": "0.2.0-main.374", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -4607,9 +4607,9 @@ "link": true }, "node_modules/@bitwarden/commercial-sdk-internal": { - "version": "0.2.0-main.369", - "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.369.tgz", - "integrity": "sha512-O+EaPQJQah9j3yWzgw+dwFk5iOxPXdKf1FDeykbt+cxygSYbWTR60RXenG1LysknOdy8fiTfHEaPD+LP1LxrdA==", + "version": "0.2.0-main.374", + "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.374.tgz", + "integrity": "sha512-OYNjEv9Z9Y1vCDWtlp7m49+Fu0WxCyJt+DDupF8T73JqWIl2SdY3ugLtLnCUnqause5VY7OAfa4eOxwn2ONKZg==", "license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT", "dependencies": { "type-fest": "^4.41.0" @@ -4712,9 +4712,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.369", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.369.tgz", - "integrity": "sha512-gyp4Wd1YbkANA0/RNxHfVk+DuiJqxItzk/YUyQ2HsLeP07xOljftmA0XspLQz59ovs7e1jHMCpH1r/XcyKiQSw==", + "version": "0.2.0-main.374", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.374.tgz", + "integrity": "sha512-P9td//6M22Eg8YcVOVtcvkD9wfdbnwNe7lZ1HGn74o3CTgDtNq0mE5x00rDeNZq0ctBaUDaqw6XS0jC/tehcag==", "license": "GPL-3.0", "dependencies": { "type-fest": "^4.41.0" diff --git a/package.json b/package.json index 21eb2b0c06d..250e4e4f43c 100644 --- a/package.json +++ b/package.json @@ -160,8 +160,8 @@ "@angular/platform-browser": "19.2.14", "@angular/platform-browser-dynamic": "19.2.14", "@angular/router": "19.2.14", - "@bitwarden/sdk-internal": "0.2.0-main.369", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.369", + "@bitwarden/sdk-internal": "0.2.0-main.374", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.374", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", From 021d3e53aad5cf84f7c95b089ed0ab71975a7a93 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:15:36 +0100 Subject: [PATCH 04/12] [PM-26056] Consolidated session timeout component (#16988) * consolidated session timeout settings component * rename preferences to appearance * race condition bug on computed signal * outdated header for browser * unnecessary padding * remove required on action, fix build * rename localization key * missing user id * required * cleanup task * eslint fix signals rollback * takeUntilDestroyed, null checks * move browser specific logic outside shared component * explicit input type * input name * takeUntilDestroyed, no toast * unit tests * cleanup * cleanup, correct link to deprecation jira * tech debt todo with jira * missing web localization key when policy is on * relative import * extracting timeout options to component service * duplicate localization key * fix failing test * subsequent timeout action selecting opening without dialog on first dialog cancellation * default locale can be null * unit tests failing * rename, simplifications * one if else feature flag * timeout input component rendering before async pipe completion --- apps/browser/src/_locales/en/messages.json | 9 + .../settings/account-security.component.html | 88 +-- .../account-security.component.spec.ts | 3 + .../settings/account-security.component.ts | 16 +- ...sion-timeout-settings-component.service.ts | 58 ++ .../src/popup/services/services.module.ts | 11 +- .../src/app/accounts/settings.component.html | 64 ++- .../app/accounts/settings.component.spec.ts | 2 +- .../src/app/accounts/settings.component.ts | 13 +- .../src/app/services/services.module.ts | 11 +- ...sion-timeout-settings-component.service.ts | 48 ++ apps/desktop/src/locales/en/messages.json | 6 + .../security/security-routing.module.ts | 18 +- .../settings/security/security.component.html | 7 +- .../settings/security/security.component.ts | 13 +- apps/web/src/app/core/core.module.ts | 11 +- ...sion-timeout-settings-component.service.ts | 39 ++ .../session-timeout.component.html | 5 + .../session-timeout.component.ts | 11 + .../app/layouts/user-layout.component.html | 6 +- .../src/app/layouts/user-layout.component.ts | 5 + apps/web/src/app/oss-routing.module.ts | 24 + .../app/settings/appearance.component.html | 48 ++ .../app/settings/appearance.component.spec.ts | 215 ++++++++ .../src/app/settings/appearance.component.ts | 107 ++++ .../app/settings/preferences.component.html | 4 +- .../src/app/settings/preferences.component.ts | 7 + apps/web/src/locales/en/messages.json | 36 +- libs/common/src/enums/feature-flag.enum.ts | 2 + .../src/key-management/vault-timeout/index.ts | 1 + .../maximum-vault-timeout-policy.type.ts | 6 + .../src/platform/abstractions/i18n.service.ts | 2 +- .../src/platform/services/i18n.service.ts | 2 +- libs/key-management-ui/src/index.ts | 2 + .../session-timeout-settings.component.html | 31 ++ ...session-timeout-settings.component.spec.ts | 522 ++++++++++++++++++ .../session-timeout-settings.component.ts | 278 ++++++++++ ...sion-timeout-settings-component.service.ts | 9 + 38 files changed, 1660 insertions(+), 80 deletions(-) create mode 100644 apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts create mode 100644 apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts create mode 100644 apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts create mode 100644 apps/web/src/app/key-management/session-timeout/session-timeout.component.html create mode 100644 apps/web/src/app/key-management/session-timeout/session-timeout.component.ts create mode 100644 apps/web/src/app/settings/appearance.component.html create mode 100644 apps/web/src/app/settings/appearance.component.spec.ts create mode 100644 apps/web/src/app/settings/appearance.component.ts create mode 100644 libs/common/src/key-management/vault-timeout/types/maximum-vault-timeout-policy.type.ts create mode 100644 libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html create mode 100644 libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts create mode 100644 libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts create mode 100644 libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index a8743b0db68..4ea69404024 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -796,6 +796,12 @@ "onLocked": { "message": "On system lock" }, + "onIdle": { + "message": "On system idle" + }, + "onSleep": { + "message": "On system sleep" + }, "onRestart": { "message": "On browser restart" }, @@ -5809,5 +5815,8 @@ }, "cardNumberLabel": { "message": "Card number" + }, + "sessionTimeoutSettingsAction": { + "message": "Timeout action" } } diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index 44900acc065..2babd2a7ef6 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -20,9 +20,9 @@ - {{ - "unlockWithBiometrics" | i18n - }} + + {{ "unlockWithBiometrics" | i18n }} + {{ biometricUnavailabilityReason }} @@ -38,9 +38,9 @@ type="checkbox" formControlName="enableAutoBiometricsPrompt" /> - {{ - "enableAutoBiometricsPrompt" | i18n - }} + + {{ "enableAutoBiometricsPrompt" | i18n }} + - {{ - "lockWithMasterPassOnRestart1" | i18n - }} + + {{ "lockWithMasterPassOnRestart1" | i18n }} + - -

{{ "vaultTimeoutHeader" | i18n }}

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

+ {{ "sessionTimeoutHeader" | i18n }} +

+
- - - + + + + } @else { + +

+ {{ "vaultTimeoutHeader" | i18n }} +

+
- - {{ "vaultTimeoutAction1" | i18n }} - - - - + + + - - {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
+ + {{ "vaultTimeoutAction1" | i18n }} + + + + + + + {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
+
+
+ + + {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} -
- - - {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} - -
+ + }
diff --git a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts index 28639cd1ed5..d0ab4793301 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts @@ -20,6 +20,7 @@ import { VaultTimeoutStringType, VaultTimeoutAction, } from "@bitwarden/common/key-management/vault-timeout"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -64,6 +65,7 @@ describe("AccountSecurityComponent", () => { const dialogService = mock(); const platformUtilsService = mock(); const lockService = mock(); + const configService = mock(); beforeEach(async () => { await TestBed.configureTestingModule({ @@ -93,6 +95,7 @@ describe("AccountSecurityComponent", () => { { provide: CollectionService, useValue: mock() }, { provide: ValidationService, useValue: validationService }, { provide: LockService, useValue: lockService }, + { provide: ConfigService, useValue: configService }, ], }) .overrideComponent(AccountSecurityComponent, { diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 4a5388ef266..c5423a5f1d1 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -32,6 +32,7 @@ import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/ import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeout, @@ -40,6 +41,7 @@ import { VaultTimeoutSettingsService, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -67,6 +69,7 @@ import { BiometricStateService, BiometricsStatus, } from "@bitwarden/key-management"; +import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui"; import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors"; import { BrowserApi } from "../../../platform/browser/browser-api"; @@ -100,6 +103,7 @@ import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component"; SectionComponent, SectionHeaderComponent, SelectModule, + SessionTimeoutSettingsComponent, SpotlightComponent, TypographyModule, VaultTimeoutInputComponent, @@ -133,11 +137,14 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { ), ); - private refreshTimeoutSettings$ = new BehaviorSubject(undefined); + protected readonly consolidatedSessionTimeoutComponent$: Observable; + + protected refreshTimeoutSettings$ = new BehaviorSubject(undefined); private destroy$ = new Subject(); constructor( private accountService: AccountService, + private configService: ConfigService, private pinService: PinServiceAbstraction, private policyService: PolicyService, private formBuilder: FormBuilder, @@ -157,7 +164,11 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { private vaultNudgesService: NudgesService, private validationService: ValidationService, private logService: LogService, - ) {} + ) { + this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$( + FeatureFlag.ConsolidatedSessionTimeoutComponent, + ); + } async ngOnInit() { const hasMasterPassword = await this.userVerificationService.hasMasterPassword(); @@ -173,6 +184,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { this.hasVaultTimeoutPolicy = true; } + // Determine platform-specific timeout options const showOnLocked = !this.platformUtilsService.isFirefox() && !this.platformUtilsService.isSafari() && diff --git a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts new file mode 100644 index 00000000000..297718687eb --- /dev/null +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts @@ -0,0 +1,58 @@ +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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; + +export class BrowserSessionTimeoutSettingsComponentService + implements SessionTimeoutSettingsComponentService +{ + availableTimeoutOptions$: Observable = defer(() => { + const options: VaultTimeoutOption[] = [ + { name: this.i18nService.t("immediately"), value: 0 }, + { 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 }, + ]; + + const showOnLocked = + !this.platformUtilsService.isFirefox() && + !this.platformUtilsService.isSafari() && + !(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel"); + + if (showOnLocked) { + 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 of(options); + }); + + constructor( + private readonly i18nService: I18nService, + private readonly platformUtilsService: PlatformUtilsService, + private readonly messagingService: MessagingService, + ) {} + + onTimeoutSave(timeout: VaultTimeout): void { + if (timeout === VaultTimeoutStringType.Never) { + this.messagingService.send("bgReseedStorage"); + } + } +} diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index a44ba81c40b..eebf0a08a22 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -141,7 +141,10 @@ import { KdfConfigService, KeyService, } from "@bitwarden/key-management"; -import { LockComponentService } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + SessionTimeoutSettingsComponentService, +} from "@bitwarden/key-management-ui"; import { DerivedStateProvider, GlobalStateProvider, StateProvider } from "@bitwarden/state"; import { InlineDerivedStateProvider } from "@bitwarden/state-internal"; import { @@ -165,6 +168,7 @@ import AutofillService from "../../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../../autofill/services/inline-menu-field-qualification.service"; import { ForegroundBrowserBiometricsService } from "../../key-management/biometrics/foreground-browser-biometrics"; import { ExtensionLockComponentService } from "../../key-management/lock/services/extension-lock-component.service"; +import { BrowserSessionTimeoutSettingsComponentService } from "../../key-management/session-timeout/services/browser-session-timeout-settings-component.service"; import { ForegroundVaultTimeoutService } from "../../key-management/vault-timeout/foreground-vault-timeout.service"; import { BrowserActionsService } from "../../platform/actions/browser-actions.service"; import { BrowserApi } from "../../platform/browser/browser-api"; @@ -713,6 +717,11 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionNewDeviceVerificationComponentService, deps: [], }), + safeProvider({ + provide: SessionTimeoutSettingsComponentService, + useClass: BrowserSessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction, PlatformUtilsService, MessagingServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index e120db339d8..79e21480a76 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -31,36 +31,50 @@ - -

{{ "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 05/12] 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 06/12] [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 07/12] [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 08/12] 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 09/12] [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 10/12] [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 11/12] [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 12/12] [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.