mirror of
https://github.com/bitwarden/browser
synced 2026-02-01 09:13:54 +00:00
Merge branch 'main' into dev/kreynolds/tunnel_proto
This commit is contained in:
25
.github/workflows/build-desktop.yml
vendored
25
.github/workflows/build-desktop.yml
vendored
@@ -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
|
||||
|
||||
15
.github/workflows/deploy-web.yml
vendored
15
.github/workflows/deploy-web.yml
vendored
@@ -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
|
||||
|
||||
81
.github/workflows/sdk-breaking-change-check.yml
vendored
81
.github/workflows/sdk-breaking-change-check.yml
vendored
@@ -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
|
||||
fi
|
||||
|
||||
@@ -796,6 +796,12 @@
|
||||
"onLocked": {
|
||||
"message": "On system lock"
|
||||
},
|
||||
"onIdle": {
|
||||
"message": "On system idle"
|
||||
},
|
||||
"onSleep": {
|
||||
"message": "On system sleep"
|
||||
},
|
||||
"onRestart": {
|
||||
"message": "On browser restart"
|
||||
},
|
||||
@@ -5815,5 +5821,8 @@
|
||||
},
|
||||
"cardNumberLabel": {
|
||||
"message": "Card number"
|
||||
},
|
||||
"sessionTimeoutSettingsAction": {
|
||||
"message": "Timeout action"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
<bit-card>
|
||||
<bit-form-control [disableMargin]="!((pinEnabled$ | async) || this.form.value.pin)">
|
||||
<input bitCheckbox id="biometric" type="checkbox" formControlName="biometric" />
|
||||
<bit-label for="biometric" class="tw-whitespace-normal">{{
|
||||
"unlockWithBiometrics" | i18n
|
||||
}}</bit-label>
|
||||
<bit-label for="biometric" class="tw-whitespace-normal">
|
||||
{{ "unlockWithBiometrics" | i18n }}
|
||||
</bit-label>
|
||||
<bit-hint *ngIf="biometricUnavailabilityReason">
|
||||
{{ biometricUnavailabilityReason }}
|
||||
</bit-hint>
|
||||
@@ -38,9 +38,9 @@
|
||||
type="checkbox"
|
||||
formControlName="enableAutoBiometricsPrompt"
|
||||
/>
|
||||
<bit-label for="autoBiometricsPrompt" class="tw-whitespace-normal">{{
|
||||
"enableAutoBiometricsPrompt" | i18n
|
||||
}}</bit-label>
|
||||
<bit-label for="autoBiometricsPrompt" class="tw-whitespace-normal">
|
||||
{{ "enableAutoBiometricsPrompt" | i18n }}
|
||||
</bit-label>
|
||||
</bit-form-control>
|
||||
<bit-form-control
|
||||
[disableMargin]="!(this.form.value.pin && showMasterPasswordOnClientRestartOption)"
|
||||
@@ -60,46 +60,60 @@
|
||||
type="checkbox"
|
||||
formControlName="pinLockWithMasterPassword"
|
||||
/>
|
||||
<bit-label for="pinEphemeral" class="tw-whitespace-normal">{{
|
||||
"lockWithMasterPassOnRestart1" | i18n
|
||||
}}</bit-label>
|
||||
<bit-label for="pinEphemeral" class="tw-whitespace-normal">
|
||||
{{ "lockWithMasterPassOnRestart1" | i18n }}
|
||||
</bit-label>
|
||||
</bit-form-control>
|
||||
</bit-card>
|
||||
</bit-section>
|
||||
|
||||
<bit-section>
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "vaultTimeoutHeader" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
@if (consolidatedSessionTimeoutComponent$ | async) {
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">
|
||||
{{ "sessionTimeoutHeader" | i18n }}
|
||||
</h2>
|
||||
</bit-section-header>
|
||||
|
||||
<bit-card>
|
||||
<auth-vault-timeout-input
|
||||
[vaultTimeoutOptions]="vaultTimeoutOptions"
|
||||
[formControl]="form.controls.vaultTimeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</auth-vault-timeout-input>
|
||||
<bit-card>
|
||||
<bit-session-timeout-settings [refreshTimeoutActionSettings]="refreshTimeoutSettings$" />
|
||||
</bit-card>
|
||||
} @else {
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">
|
||||
{{ "vaultTimeoutHeader" | i18n }}
|
||||
</h2>
|
||||
</bit-section-header>
|
||||
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label for="vaultTimeoutAction">{{ "vaultTimeoutAction1" | i18n }}</bit-label>
|
||||
<bit-select id="vaultTimeoutAction" formControlName="vaultTimeoutAction">
|
||||
<bit-option
|
||||
*ngFor="let action of availableVaultTimeoutActions"
|
||||
[value]="action"
|
||||
[label]="action | i18n"
|
||||
>
|
||||
</bit-option>
|
||||
</bit-select>
|
||||
<bit-card>
|
||||
<auth-vault-timeout-input
|
||||
[vaultTimeoutOptions]="vaultTimeoutOptions"
|
||||
[formControl]="form.controls.vaultTimeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</auth-vault-timeout-input>
|
||||
|
||||
<bit-hint *ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)">
|
||||
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br />
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label for="vaultTimeoutAction">{{ "vaultTimeoutAction1" | i18n }}</bit-label>
|
||||
<bit-select id="vaultTimeoutAction" formControlName="vaultTimeoutAction">
|
||||
<bit-option
|
||||
*ngFor="let action of availableVaultTimeoutActions"
|
||||
[value]="action"
|
||||
[label]="action | i18n"
|
||||
>
|
||||
</bit-option>
|
||||
</bit-select>
|
||||
|
||||
<bit-hint *ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)">
|
||||
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br />
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-hint *ngIf="hasVaultTimeoutPolicy" class="tw-mt-4">
|
||||
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-hint *ngIf="hasVaultTimeoutPolicy" class="tw-mt-4">
|
||||
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
|
||||
</bit-hint>
|
||||
</bit-card>
|
||||
</bit-card>
|
||||
}
|
||||
</bit-section>
|
||||
|
||||
<bit-section>
|
||||
|
||||
@@ -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<DialogService>();
|
||||
const platformUtilsService = mock<PlatformUtilsService>();
|
||||
const lockService = mock<LockService>();
|
||||
const configService = mock<ConfigService>();
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -93,6 +95,7 @@ describe("AccountSecurityComponent", () => {
|
||||
{ provide: CollectionService, useValue: mock<CollectionService>() },
|
||||
{ provide: ValidationService, useValue: validationService },
|
||||
{ provide: LockService, useValue: lockService },
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(AccountSecurityComponent, {
|
||||
|
||||
@@ -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<void>(undefined);
|
||||
protected readonly consolidatedSessionTimeoutComponent$: Observable<boolean>;
|
||||
|
||||
protected refreshTimeoutSettings$ = new BehaviorSubject<void>(undefined);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
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() &&
|
||||
|
||||
@@ -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<VaultTimeoutOption[]> = 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -31,36 +31,50 @@
|
||||
</h2>
|
||||
<ng-container *ngIf="showSecurity">
|
||||
<bit-section disableMargin>
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "vaultTimeoutHeader" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
@if (consolidatedSessionTimeoutComponent$ | async) {
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "sessionTimeoutHeader" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
|
||||
<auth-vault-timeout-input
|
||||
[vaultTimeoutOptions]="vaultTimeoutOptions"
|
||||
[formControl]="form.controls.vaultTimeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</auth-vault-timeout-input>
|
||||
<bit-session-timeout-settings
|
||||
[refreshTimeoutActionSettings]="refreshTimeoutSettings$"
|
||||
/>
|
||||
} @else {
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "vaultTimeoutHeader" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label for="vaultTimeoutAction">{{ "vaultTimeoutAction1" | i18n }}</bit-label>
|
||||
<bit-select id="vaultTimeoutAction" formControlName="vaultTimeoutAction">
|
||||
<bit-option
|
||||
*ngFor="let action of availableVaultTimeoutActions"
|
||||
[value]="action"
|
||||
[label]="action | i18n"
|
||||
<auth-vault-timeout-input
|
||||
[vaultTimeoutOptions]="vaultTimeoutOptions"
|
||||
[formControl]="form.controls.vaultTimeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</auth-vault-timeout-input>
|
||||
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label for="vaultTimeoutAction">{{
|
||||
"vaultTimeoutAction1" | i18n
|
||||
}}</bit-label>
|
||||
<bit-select id="vaultTimeoutAction" formControlName="vaultTimeoutAction">
|
||||
<bit-option
|
||||
*ngFor="let action of availableVaultTimeoutActions"
|
||||
[value]="action"
|
||||
[label]="action | i18n"
|
||||
>
|
||||
</bit-option>
|
||||
</bit-select>
|
||||
|
||||
<bit-hint
|
||||
*ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)"
|
||||
>
|
||||
</bit-option>
|
||||
</bit-select>
|
||||
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br />
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-hint *ngIf="!availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)">
|
||||
{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br />
|
||||
<bit-hint *ngIf="hasVaultTimeoutPolicy" class="tw-mt-4">
|
||||
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-hint *ngIf="hasVaultTimeoutPolicy" class="tw-mt-4">
|
||||
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
|
||||
</bit-hint>
|
||||
}
|
||||
</bit-section>
|
||||
<div class="form-group tw-mt-4" *ngIf="(pinEnabled$ | async) || this.form.value.pin">
|
||||
<div class="checkbox">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<boolean> = of(true);
|
||||
isWindowsV2BiometricsEnabled: boolean = false;
|
||||
|
||||
consolidatedSessionTimeoutComponent$: Observable<boolean>;
|
||||
|
||||
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<void>(undefined);
|
||||
protected refreshTimeoutSettings$ = new BehaviorSubject<void>(undefined);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<VaultTimeoutOption[]> = 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 {}
|
||||
}
|
||||
@@ -4220,5 +4220,11 @@
|
||||
},
|
||||
"upgradeToPremium": {
|
||||
"message": "Upgrade to Premium"
|
||||
},
|
||||
"sessionTimeoutSettingsAction": {
|
||||
"message": "Timeout action"
|
||||
},
|
||||
"sessionTimeoutHeader": {
|
||||
"message": "Session timeout"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<app-header>
|
||||
<bit-tab-nav-bar slot="tabs">
|
||||
<ng-container *ngIf="showChangePassword">
|
||||
@if (consolidatedSessionTimeoutComponent$ | async) {
|
||||
<bit-tab-link route="session-timeout">{{ "sessionTimeoutHeader" | i18n }}</bit-tab-link>
|
||||
}
|
||||
@if (showChangePassword) {
|
||||
<bit-tab-link [route]="changePasswordRoute">{{ "masterPassword" | i18n }}</bit-tab-link>
|
||||
</ng-container>
|
||||
}
|
||||
<bit-tab-link route="two-factor">{{ "twoStepLogin" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link route="device-management">{{ "devices" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link route="security-keys">{{ "keys" | i18n }}</bit-tab-link>
|
||||
|
||||
@@ -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<boolean>;
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<UnifiedUpgradeDialogComponent>;
|
||||
const mockDialogRef = mock<DialogRef>();
|
||||
const mockRouter = mock<Router>();
|
||||
const mockPremiumInterestStateService = mock<PremiumInterestStateService>();
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<UnifiedUpgradeDialogResult>,
|
||||
@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<void> {
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<VaultTimeoutOption[]> = 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 {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<h2 class="tw-mt-6 tw-mb-2 tw-pb-2.5">{{ "sessionTimeoutHeader" | i18n }}</h2>
|
||||
|
||||
<div class="tw-max-w-lg">
|
||||
<bit-session-timeout-settings />
|
||||
</div>
|
||||
@@ -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 {}
|
||||
@@ -13,7 +13,11 @@
|
||||
<bit-nav-group icon="bwi-cog" [text]="'settings' | i18n" route="settings">
|
||||
<bit-nav-item [text]="'myAccount' | i18n" route="settings/account"></bit-nav-item>
|
||||
<bit-nav-item [text]="'security' | i18n" route="settings/security"></bit-nav-item>
|
||||
<bit-nav-item [text]="'preferences' | i18n" route="settings/preferences"></bit-nav-item>
|
||||
@if (consolidatedSessionTimeoutComponent$ | async) {
|
||||
<bit-nav-item [text]="'appearance' | i18n" route="settings/appearance"></bit-nav-item>
|
||||
} @else {
|
||||
<bit-nav-item [text]="'preferences' | i18n" route="settings/preferences"></bit-nav-item>
|
||||
}
|
||||
<bit-nav-item
|
||||
[text]="'subscription' | i18n"
|
||||
route="settings/subscription"
|
||||
|
||||
@@ -42,6 +42,7 @@ export class UserLayoutComponent implements OnInit {
|
||||
protected hasFamilySponsorshipAvailable$: Observable<boolean>;
|
||||
protected showSponsoredFamilies$: Observable<boolean>;
|
||||
protected showSubscription$: Observable<boolean>;
|
||||
protected consolidatedSessionTimeoutComponent$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
private syncService: SyncService,
|
||||
@@ -74,6 +75,10 @@ export class UserLayoutComponent implements OnInit {
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
this.consolidatedSessionTimeoutComponent$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.ConsolidatedSessionTimeoutComponent,
|
||||
);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
48
apps/web/src/app/settings/appearance.component.html
Normal file
48
apps/web/src/app/settings/appearance.component.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<app-header></app-header>
|
||||
|
||||
<bit-container>
|
||||
<form [formGroup]="form" class="tw-w-full tw-max-w-md">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "theme" | i18n }}</bit-label>
|
||||
<bit-select formControlName="theme" id="theme">
|
||||
@for (option of themeOptions; track option.value) {
|
||||
<bit-option [value]="option.value" [label]="option.name"></bit-option>
|
||||
}
|
||||
</bit-select>
|
||||
<bit-hint>{{ "themeDesc" | i18n }}</bit-hint>
|
||||
</bit-form-field>
|
||||
<bit-form-field>
|
||||
<bit-label>
|
||||
{{ "language" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
class="tw-float-right"
|
||||
href="https://bitwarden.com/help/localization/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
appA11yTitle="{{ 'learnMoreAboutLocalization' | i18n }}"
|
||||
slot="end"
|
||||
>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-label>
|
||||
<bit-select formControlName="locale" id="locale">
|
||||
@for (option of localeOptions; track option.value) {
|
||||
<bit-option [value]="option.value" [label]="option.name"></bit-option>
|
||||
}
|
||||
</bit-select>
|
||||
<bit-hint>{{ "languageDesc" | i18n }}</bit-hint>
|
||||
</bit-form-field>
|
||||
<div class="tw-flex tw-items-start tw-gap-1.5">
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="enableFavicons" />
|
||||
<bit-label>
|
||||
{{ "showIconsChangePasswordUrls" | i18n }}
|
||||
</bit-label>
|
||||
</bit-form-control>
|
||||
<div class="-tw-mt-0.5">
|
||||
<vault-permit-cipher-details-popover></vault-permit-cipher-details-popover>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</bit-container>
|
||||
215
apps/web/src/app/settings/appearance.component.spec.ts
Normal file
215
apps/web/src/app/settings/appearance.component.spec.ts
Normal file
@@ -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<AppearanceComponent>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockThemeStateService: MockProxy<ThemeStateService>;
|
||||
let mockDomainSettingsService: MockProxy<DomainSettingsService>;
|
||||
|
||||
const mockShowFavicons$ = new BehaviorSubject<boolean>(true);
|
||||
const mockSelectedTheme$ = new BehaviorSubject<Theme>(ThemeTypes.Light);
|
||||
const mockUserSetLocale$ = new BehaviorSubject<string | undefined>("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<I18nService>();
|
||||
mockThemeStateService = mock<ThemeStateService>();
|
||||
mockDomainSettingsService = mock<DomainSettingsService>();
|
||||
|
||||
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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
107
apps/web/src/app/settings/appearance.component.ts
Normal file
107
apps/web/src/app/settings/appearance.component.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,8 @@
|
||||
</bit-radio-group>
|
||||
</ng-container>
|
||||
<bit-form-field>
|
||||
<bit-label
|
||||
>{{ "language" | i18n }}
|
||||
<bit-label>
|
||||
{{ "language" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
class="tw-float-right"
|
||||
|
||||
@@ -39,6 +39,11 @@ import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault";
|
||||
import { HeaderModule } from "../layouts/header/header.module";
|
||||
import { SharedModule } from "../shared";
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link AppearanceComponent} and {@link SessionTimeoutComponent} instead.
|
||||
*
|
||||
* TODO Cleanup once feature flag enabled: https://bitwarden.atlassian.net/browse/PM-27297
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
@@ -211,6 +216,8 @@ export class PreferencesComponent implements OnInit, OnDestroy {
|
||||
values.vaultTimeout,
|
||||
values.vaultTimeoutAction,
|
||||
);
|
||||
|
||||
// Save other preferences (theme, locale, favicons)
|
||||
await this.domainSettingsService.setShowFavicons(values.enableFavicons);
|
||||
await this.themeStateService.setSelectedTheme(values.theme);
|
||||
await this.i18nService.setLocale(values.locale);
|
||||
|
||||
@@ -550,15 +550,7 @@ export class VaultComponent<C extends CipherViewLike> 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<C extends CipherViewLike> 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<C extends CipherViewLike> 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<C extends CipherViewLike> 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 (
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -12095,7 +12110,7 @@
|
||||
"encryptionKeySettingsAlgorithmPopoverArgon2Id": {
|
||||
"message": "Argon2id offers stronger protection against modern attacks. Best for advanced users with powerful devices."
|
||||
},
|
||||
"zipPostalCodeLabel": {
|
||||
"zipPostalCodeLabel": {
|
||||
"message": "ZIP / Postal code"
|
||||
},
|
||||
"cardNumberLabel": {
|
||||
@@ -12103,5 +12118,39 @@
|
||||
},
|
||||
"startFreeFamiliesTrial": {
|
||||
"message": "Start free Families trial"
|
||||
},
|
||||
"unlockMethodNeededToChangeTimeoutActionDesc": {
|
||||
"message": "Set up an unlock method to change your vault timeout action."
|
||||
},
|
||||
"vaultTimeoutPolicyAffectingOptions": {
|
||||
"message": "Enterprise policy requirements have been applied to your timeout options"
|
||||
},
|
||||
"vaultTimeoutTooLarge": {
|
||||
"message": "Your vault timeout exceeds the restrictions set by your organization."
|
||||
},
|
||||
"neverLockWarning": {
|
||||
"message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected."
|
||||
},
|
||||
"sessionTimeoutSettingsAction": {
|
||||
"message": "Timeout action"
|
||||
},
|
||||
"sessionTimeoutHeader": {
|
||||
"message": "Session timeout"
|
||||
},
|
||||
"appearance": {
|
||||
"message": "Appearance"
|
||||
},
|
||||
"vaultTimeoutPolicyMaximumError": {
|
||||
"message": "Timeout exceeds the restriction set by your organization: $HOURS$ hour(s) and $MINUTES$ minute(s) maximum",
|
||||
"placeholders": {
|
||||
"hours": {
|
||||
"content": "$1",
|
||||
"example": "5"
|
||||
},
|
||||
"minutes": {
|
||||
"content": "$2",
|
||||
"example": "5"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -44,26 +44,53 @@
|
||||
</dirt-activity-card>
|
||||
</li>
|
||||
|
||||
<li class="tw-col-span-1">
|
||||
<dirt-activity-card
|
||||
[title]="'newApplicationsCardTitle' | i18n"
|
||||
[cardMetrics]="
|
||||
isAllCaughtUp
|
||||
? ('allCaughtUp' | i18n)
|
||||
: ('newApplicationsWithCount' | i18n: newApplicationsCount)
|
||||
"
|
||||
[metricDescription]="
|
||||
isAllCaughtUp
|
||||
? ('noNewApplicationsToReviewAtThisTime' | i18n)
|
||||
: ('newApplicationsDescription' | i18n)
|
||||
"
|
||||
[iconClass]="isAllCaughtUp ? 'bwi-check-circle' : 'bwi-exclamation-triangle'"
|
||||
[iconColorClass]="isAllCaughtUp ? 'tw-text-success' : 'tw-text-warning'"
|
||||
[buttonText]="isAllCaughtUp ? '' : ('reviewNow' | i18n)"
|
||||
[buttonType]="'primary'"
|
||||
(buttonClick)="onReviewNewApplications()"
|
||||
>
|
||||
</dirt-activity-card>
|
||||
</li>
|
||||
<!-- All Caught Up State -->
|
||||
@if (isAllCaughtUp) {
|
||||
<li class="tw-col-span-1">
|
||||
<dirt-activity-card
|
||||
[title]="'applicationsNeedingReview' | i18n"
|
||||
[cardMetrics]="'allCaughtUp' | i18n"
|
||||
[metricDescription]="'noNewApplicationsToReviewAtThisTime' | i18n"
|
||||
[iconClass]="'bwi-check-circle'"
|
||||
[iconColorClass]="'tw-text-success'"
|
||||
[buttonText]="''"
|
||||
[buttonType]="'primary'"
|
||||
(buttonClick)="onReviewNewApplications()"
|
||||
>
|
||||
</dirt-activity-card>
|
||||
</li>
|
||||
}
|
||||
<!-- Needs Review State (No apps have been reviewed) -->
|
||||
@else if (showNeedsReviewState) {
|
||||
<li class="tw-col-span-2">
|
||||
<dirt-activity-card
|
||||
[title]="'applicationsNeedingReview' | i18n"
|
||||
[cardMetrics]="'organizationHasItemsSavedForApplications' | i18n: totalApplicationCount"
|
||||
[metricDescription]="'reviewApplicationsToSecureItems' | i18n"
|
||||
[iconClass]="'bwi-exclamation-triangle'"
|
||||
[iconColorClass]="'tw-text-warning'"
|
||||
[buttonText]="'reviewApplications' | i18n"
|
||||
[buttonType]="'primary'"
|
||||
(buttonClick)="onReviewNewApplications()"
|
||||
>
|
||||
</dirt-activity-card>
|
||||
</li>
|
||||
}
|
||||
<!-- Default State (New applications to review) -->
|
||||
@else {
|
||||
<li class="tw-col-span-1">
|
||||
<dirt-activity-card
|
||||
[title]="'applicationsNeedingReview' | i18n"
|
||||
[cardMetrics]="'newApplicationsWithCount' | i18n: newApplicationsCount"
|
||||
[metricDescription]="'newApplicationsDescription' | i18n"
|
||||
[iconClass]="'bwi-exclamation-triangle'"
|
||||
[iconColorClass]="'tw-text-muted'"
|
||||
[buttonText]="'reviewNow' | i18n"
|
||||
[buttonType]="'primary'"
|
||||
(buttonClick)="onReviewNewApplications()"
|
||||
>
|
||||
</dirt-activity-card>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -109,112 +109,4 @@
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (dataService.drawerDetails$ | async; as drawerDetails) {
|
||||
<bit-drawer style="width: 30%" [(open)]="isDrawerOpen" (openChange)="dataService.closeDrawer()">
|
||||
<ng-container *ngIf="dataService.isActiveDrawerType(drawerTypes.OrgAtRiskMembers)">
|
||||
<bit-drawer-header
|
||||
title="{{ 'atRiskMembersWithCount' | i18n: drawerDetails.atRiskMemberDetails.length }}"
|
||||
>
|
||||
</bit-drawer-header>
|
||||
<bit-drawer-body>
|
||||
<span bitTypography="body1" class="tw-text-muted tw-text-sm">{{
|
||||
(drawerDetails.atRiskMemberDetails.length > 0
|
||||
? "atRiskMembersDescription"
|
||||
: "atRiskMembersDescriptionNone"
|
||||
) | i18n
|
||||
}}</span>
|
||||
<ng-container *ngIf="drawerDetails.atRiskMemberDetails.length > 0">
|
||||
<button
|
||||
bitLink
|
||||
type="button"
|
||||
class="tw-my-4 tw-font-bold tw-block"
|
||||
(click)="downloadAtRiskMembers()"
|
||||
>
|
||||
<i class="bwi bwi-download tw-mr-1"></i>
|
||||
{{ "downloadCSV" | i18n }}
|
||||
</button>
|
||||
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
|
||||
<div bitTypography="body2" class="tw-text-sm tw-font-medium">
|
||||
{{ "email" | i18n }}
|
||||
</div>
|
||||
<div bitTypography="body2" class="tw-text-sm tw-font-medium">
|
||||
{{ "atRiskPasswords" | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngFor="let member of drawerDetails.atRiskMemberDetails">
|
||||
<div class="tw-flex tw-justify-between tw-mt-2">
|
||||
<div>{{ member.email }}</div>
|
||||
<div>{{ member.atRiskPasswordCount }}</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</bit-drawer-body>
|
||||
</ng-container>
|
||||
|
||||
@if (dataService.isActiveDrawerType(drawerTypes.AppAtRiskMembers)) {
|
||||
<bit-drawer-header title="{{ drawerDetails.appAtRiskMembers.applicationName }}">
|
||||
</bit-drawer-header>
|
||||
<bit-drawer-body>
|
||||
<div bitTypography="body1" class="tw-mb-2">
|
||||
{{ "atRiskMembersWithCount" | i18n: drawerDetails.appAtRiskMembers.members.length }}
|
||||
</div>
|
||||
<div bitTypography="body1" class="tw-text-muted tw-text-sm tw-mb-2">
|
||||
{{
|
||||
(drawerDetails.appAtRiskMembers.members.length > 0
|
||||
? "atRiskMembersDescriptionWithApp"
|
||||
: "atRiskMembersDescriptionWithAppNone"
|
||||
) | i18n: drawerDetails.appAtRiskMembers.applicationName
|
||||
}}
|
||||
</div>
|
||||
<div class="tw-mt-1">
|
||||
<ng-container *ngFor="let member of drawerDetails.appAtRiskMembers.members">
|
||||
<div>{{ member.email }}</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</bit-drawer-body>
|
||||
}
|
||||
|
||||
@if (dataService.isActiveDrawerType(drawerTypes.OrgAtRiskApps)) {
|
||||
<bit-drawer-header
|
||||
title="{{ 'atRiskApplicationsWithCount' | i18n: drawerDetails.atRiskAppDetails.length }}"
|
||||
>
|
||||
</bit-drawer-header>
|
||||
|
||||
<bit-drawer-body>
|
||||
<span bitTypography="body2" class="tw-text-muted tw-text-sm">{{
|
||||
(drawerDetails.atRiskAppDetails.length > 0
|
||||
? "atRiskApplicationsDescription"
|
||||
: "atRiskApplicationsDescriptionNone"
|
||||
) | i18n
|
||||
}}</span>
|
||||
<ng-container *ngIf="drawerDetails.atRiskAppDetails.length > 0">
|
||||
<button
|
||||
bitLink
|
||||
type="button"
|
||||
class="tw-my-4 tw-font-bold tw-block"
|
||||
(click)="downloadAtRiskApplications()"
|
||||
>
|
||||
<i class="bwi bwi-download tw-mr-1"></i>
|
||||
{{ "downloadCSV" | i18n }}
|
||||
</button>
|
||||
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
|
||||
<div bitTypography="body2" class="tw-text-sm tw-font-medium">
|
||||
{{ "application" | i18n }}
|
||||
</div>
|
||||
<div bitTypography="body2" class="tw-text-sm tw-font-medium">
|
||||
{{ "atRiskPasswords" | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngFor="let app of drawerDetails.atRiskAppDetails">
|
||||
<div class="tw-flex tw-justify-between tw-mt-2">
|
||||
<div>{{ app.applicationName }}</div>
|
||||
<div>{{ app.atRiskPasswordCount }}</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</bit-drawer-body>
|
||||
}
|
||||
</bit-drawer>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
@@ -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<unknown, RiskInsightsDrawerDialogComponent> | 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
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
@if (isActiveDrawerType(drawerTypes.OrgAtRiskMembers)) {
|
||||
<bit-dialog dialogSize="large" disablePadding="false">
|
||||
<ng-container bitDialogTitle>
|
||||
<span>{{
|
||||
"atRiskMembersWithCount" | i18n: drawerDetails.atRiskMemberDetails?.length ?? 0
|
||||
}}</span>
|
||||
</ng-container>
|
||||
<ng-container bitDialogContent>
|
||||
<span bitTypography="body1" class="tw-text-muted tw-text-sm">{{
|
||||
(drawerDetails.atRiskMemberDetails?.length > 0
|
||||
? "atRiskMembersDescription"
|
||||
: "atRiskMembersDescriptionNone"
|
||||
) | i18n
|
||||
}}</span>
|
||||
|
||||
@if (drawerDetails.atRiskMemberDetails?.length > 0) {
|
||||
<ng-container>
|
||||
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
|
||||
<div bitTypography="body2" class="tw-text-sm tw-font-bold">
|
||||
{{ "email" | i18n }}
|
||||
</div>
|
||||
<div bitTypography="body2" class="tw-text-sm tw-font-bold">
|
||||
{{ "atRiskPasswords" | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
@for (member of drawerDetails.atRiskMemberDetails; track member.email) {
|
||||
<div class="tw-flex tw-justify-between tw-mt-2">
|
||||
<div>{{ member.email }}</div>
|
||||
<div>{{ member.atRiskPasswordCount }}</div>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
}
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
}
|
||||
|
||||
@if (isActiveDrawerType(drawerTypes.AppAtRiskMembers)) {
|
||||
<bit-dialog dialogSize="large" disablePadding="false">
|
||||
<ng-container bitDialogTitle>
|
||||
<span>{{ drawerDetails.appAtRiskMembers?.applicationName }}</span>
|
||||
</ng-container>
|
||||
<ng-container bitDialogContent>
|
||||
<div bitTypography="body1" class="tw-mb-2">
|
||||
{{ "atRiskMembersWithCount" | i18n: drawerDetails.appAtRiskMembers?.members.length }}
|
||||
</div>
|
||||
<div bitTypography="body1" class="tw-text-muted tw-text-sm tw-mb-2">
|
||||
{{
|
||||
(drawerDetails.appAtRiskMembers?.members.length > 0
|
||||
? "atRiskMembersDescriptionWithApp"
|
||||
: "atRiskMembersDescriptionWithAppNone"
|
||||
) | i18n: drawerDetails.appAtRiskMembers?.applicationName
|
||||
}}
|
||||
</div>
|
||||
<div class="tw-mt-1">
|
||||
@for (member of drawerDetails.appAtRiskMembers?.members; track $index) {
|
||||
<div>{{ member.email }}</div>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
}
|
||||
|
||||
@if (isActiveDrawerType(drawerTypes.OrgAtRiskApps)) {
|
||||
<bit-dialog dialogSize="large" disablePadding="false">
|
||||
<ng-container bitDialogTitle>
|
||||
<span>{{
|
||||
"atRiskApplicationsWithCount" | i18n: drawerDetails.atRiskAppDetails?.length ?? 0
|
||||
}}</span>
|
||||
</ng-container>
|
||||
<ng-container bitDialogContent>
|
||||
<span bitTypography="body1" class="tw-text-muted tw-text-sm">{{
|
||||
(drawerDetails.atRiskAppDetails?.length > 0
|
||||
? "atRiskApplicationsDescription"
|
||||
: "atRiskApplicationsDescriptionNone"
|
||||
) | i18n
|
||||
}}</span>
|
||||
@if (drawerDetails.atRiskAppDetails?.length > 0) {
|
||||
<ng-container>
|
||||
<div class="tw-flex tw-justify-between tw-mt-2 tw-text-muted">
|
||||
<div bitTypography="body2" class="tw-text-sm tw-font-bold">
|
||||
{{ "application" | i18n }}
|
||||
</div>
|
||||
<div bitTypography="body2" class="tw-text-sm tw-font-bold">
|
||||
{{ "atRiskPasswords" | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
@for (app of drawerDetails.atRiskAppDetails; track app.applicationName) {
|
||||
<div class="tw-flex tw-justify-between tw-mt-2">
|
||||
<div>{{ app.applicationName }}</div>
|
||||
<div>{{ app.atRiskPasswordCount }}</div>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
}
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
}
|
||||
@@ -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<RiskInsightsDrawerDialogComponent>;
|
||||
const mockI18nService = mock<I18nService>();
|
||||
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<I18nPipe>() },
|
||||
{ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export enum FeatureFlag {
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
|
||||
|
||||
/* Tools */
|
||||
DesktopSendUIRefresh = "desktop-send-ui-refresh",
|
||||
@@ -136,6 +137,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
|
||||
|
||||
/* Platform */
|
||||
[FeatureFlag.IpcChannelFramework]: FALSE,
|
||||
|
||||
@@ -8,3 +8,4 @@ export {
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutStringType,
|
||||
} from "./types/vault-timeout.type";
|
||||
export { MaximumVaultTimeoutPolicyData } from "./types/maximum-vault-timeout-policy.type";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
|
||||
export interface MaximumVaultTimeoutPolicyData {
|
||||
minutes: number;
|
||||
action?: VaultTimeoutAction;
|
||||
}
|
||||
@@ -5,6 +5,6 @@ import { TranslationService } from "./translation.service";
|
||||
export abstract class I18nService extends TranslationService {
|
||||
abstract userSetLocale$: Observable<string | undefined>;
|
||||
abstract locale$: Observable<string>;
|
||||
abstract setLocale(locale: string): Promise<void>;
|
||||
abstract setLocale(locale: string | null): Promise<void>;
|
||||
abstract init(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ export interface IpcMessage {
|
||||
message: SerializedOutgoingMessage;
|
||||
}
|
||||
|
||||
export interface SerializedOutgoingMessage extends Omit<OutgoingMessage, "free" | "payload"> {
|
||||
export interface SerializedOutgoingMessage
|
||||
extends Omit<OutgoingMessage, typeof Symbol.dispose | "free" | "payload"> {
|
||||
payload: number[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
async setLocale(locale: string | null): Promise<void> {
|
||||
await this.translationLocaleState.update(() => locale);
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@ function createMockClient(): MockProxy<BitwardenClient> {
|
||||
state: jest.fn().mockReturnValue(mock()),
|
||||
load_flags: jest.fn().mockReturnValue(mock()),
|
||||
free: mock(),
|
||||
[Symbol.dispose]: jest.fn(),
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<div [formGroup]="formGroup">
|
||||
<auth-vault-timeout-input
|
||||
[vaultTimeoutOptions]="availableTimeoutOptions$ | async"
|
||||
[formControl]="formGroup.controls.timeout"
|
||||
ngDefaultControl
|
||||
>
|
||||
</auth-vault-timeout-input>
|
||||
|
||||
<bit-form-field [disableMargin]="true">
|
||||
<bit-label>{{ "sessionTimeoutSettingsAction" | i18n }}</bit-label>
|
||||
<bit-select
|
||||
id="timeoutAction"
|
||||
[formControl]="formGroup.controls.timeoutAction"
|
||||
[required]="false"
|
||||
>
|
||||
@for (action of availableTimeoutActions(); track action) {
|
||||
<bit-option [value]="action" [label]="action | i18n"></bit-option>
|
||||
}
|
||||
</bit-select>
|
||||
|
||||
@if (!canLock) {
|
||||
<bit-hint>{{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}<br /></bit-hint>
|
||||
}
|
||||
</bit-form-field>
|
||||
|
||||
@if (hasVaultTimeoutPolicy$ | async) {
|
||||
<bit-hint class="tw-mt-4">
|
||||
{{ "vaultTimeoutPolicyAffectingOptions" | i18n }}
|
||||
</bit-hint>
|
||||
}
|
||||
</div>
|
||||
@@ -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<SessionTimeoutSettingsComponent>;
|
||||
|
||||
// Mock services
|
||||
let mockVaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let mockSessionTimeoutSettingsComponentService: MockProxy<SessionTimeoutSettingsComponentService>;
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockToastService: MockProxy<ToastService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let accountService: FakeAccountService;
|
||||
let mockDialogService: MockProxy<DialogService>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
|
||||
const mockUserId = "user-id" as UserId;
|
||||
const mockEmail = "test@example.com";
|
||||
const mockInitialTimeout = 5;
|
||||
const mockInitialTimeoutAction = VaultTimeoutAction.Lock;
|
||||
let refreshTimeoutActionSettings$: BehaviorSubject<void>;
|
||||
let availableTimeoutOptions$: BehaviorSubject<VaultTimeoutOption[]>;
|
||||
|
||||
beforeEach(async () => {
|
||||
refreshTimeoutActionSettings$ = new BehaviorSubject<void>(undefined);
|
||||
availableTimeoutOptions$ = new BehaviorSubject<VaultTimeoutOption[]>([
|
||||
{ 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<VaultTimeoutSettingsService>();
|
||||
mockSessionTimeoutSettingsComponentService = mock<SessionTimeoutSettingsComponentService>();
|
||||
mockI18nService = mock<I18nService>();
|
||||
mockToastService = mock<ToastService>();
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
accountService = mockAccountServiceWith(mockUserId, { email: mockEmail });
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockLogService = mock<LogService>();
|
||||
|
||||
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<Policy[]>([]);
|
||||
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[]>([
|
||||
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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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<Observable<void>>(
|
||||
new BehaviorSubject<void>(undefined),
|
||||
);
|
||||
|
||||
formGroup = new FormGroup({
|
||||
timeout: new FormControl<VaultTimeout | null>(null, [Validators.required]),
|
||||
timeoutAction: new FormControl<VaultTimeoutAction>(VaultTimeoutAction.Lock, [
|
||||
Validators.required,
|
||||
]),
|
||||
});
|
||||
protected readonly availableTimeoutActions = signal<VaultTimeoutAction[]>([]);
|
||||
protected readonly availableTimeoutOptions$ =
|
||||
this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$.pipe(
|
||||
startWith([] as VaultTimeoutOption[]),
|
||||
);
|
||||
protected hasVaultTimeoutPolicy$: Observable<boolean> = 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<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<VaultTimeoutOption[]>;
|
||||
|
||||
abstract onTimeoutSave(timeout: VaultTimeout): void;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ module.exports = {
|
||||
// Also anecdotally improves performance when run locally
|
||||
maxWorkers: 3,
|
||||
|
||||
setupFiles: ["<rootDir>/../../libs/shared/polyfill-node-globals.ts"],
|
||||
|
||||
transform: {
|
||||
"^.+\\.tsx?$": [
|
||||
"ts-jest",
|
||||
|
||||
11
libs/shared/polyfill-node-globals.ts
Normal file
11
libs/shared/polyfill-node-globals.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ module.exports = {
|
||||
testMatch: ["**/+(*.)+(spec).+(ts)"],
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "jsdom",
|
||||
setupFiles: ["<rootDir>/../../../../../libs/shared/polyfill-node-globals.ts"],
|
||||
moduleNameMapper: pathsToModuleNameMapper(
|
||||
{ "@bitwarden/common/spec": ["libs/common/spec"], ...(compilerOptions?.paths ?? {}) },
|
||||
{
|
||||
|
||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -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",
|
||||
@@ -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",
|
||||
@@ -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"
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user