From 15610906d26f5c56e25d9f6e2fc1451edb011742 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 16 Sep 2024 17:40:08 +0200 Subject: [PATCH] [PM-7608] Account Security Settings V2 (#10441) * Implement account security settings v2 * Increase await dialog delay to 500 msec * Update messages * Replace platformservice with biometricsservice * Cleanup * Cleanup * Fix account security component according to feedback * Re-add old message * Re-add old error message * Fix minimum timeout message * Fix screen-reader on custom timeout * Remove debugging configurations * Fix incorrectly changed message * Remove custom vault timeout text * Restore vaultTimeoutPolicyInEffect i18n message in web * Change text to use vaultTimeoutPolicyInEffect1 * Fix tests --- apps/browser/src/_locales/en/messages.json | 77 ++- .../popup/components/set-pin.component.html | 8 +- .../account-security-v1.component.html | 140 +++++ .../settings/account-security-v1.component.ts | 501 ++++++++++++++++++ .../settings/account-security.component.html | 250 +++++---- .../settings/account-security.component.ts | 282 ++++++---- apps/browser/src/popup/app-routing.module.ts | 6 +- apps/browser/src/popup/app.module.ts | 7 +- .../auth/components/set-pin.component.html | 2 +- apps/desktop/src/locales/en/messages.json | 9 +- apps/web/src/locales/en/messages.json | 53 +- .../fingerprint-dialog.component.html | 6 +- .../vault-timeout-input.component.html | 48 +- .../vault-timeout-input.component.ts | 30 +- 14 files changed, 1122 insertions(+), 297 deletions(-) create mode 100644 apps/browser/src/auth/popup/settings/account-security-v1.component.html create mode 100644 apps/browser/src/auth/popup/settings/account-security-v1.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index b8d3a2d47c..764b8b5611 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -328,7 +328,7 @@ "createFoldersToOrganize": { "message": "Create folders to organize your vault items" }, - "deleteFolderPermanently":{ + "deleteFolderPermanently": { "message": "Are you sure you want to permanently delete this folder?" }, "deleteFolder": { @@ -561,6 +561,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "vaultTimeoutHeader": { + "message": "Vault timeout" + }, "otherOptions": { "message": "Other options" }, @@ -601,6 +604,9 @@ "vaultTimeout": { "message": "Vault timeout" }, + "vaultTimeout1": { + "message": "Timeout" + }, "lockNow": { "message": "Lock now" }, @@ -801,6 +807,12 @@ "twoStepLoginConfirmation": { "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, + "twoStepLoginConfirmationContent": { + "message": "Make your account more secure by setting up two-step login in the Bitwarden web app." + }, + "twoStepLoginConfirmationTitle": { + "message": "Continue to web app?" + }, "editedFolder": { "message": "Folder saved" }, @@ -1875,9 +1887,18 @@ "unlockWithPin": { "message": "Unlock with PIN" }, + "setYourPinTitle": { + "message": "Set PIN" + }, + "setYourPinButton": { + "message": "Set PIN" + }, "setYourPinCode": { "message": "Set your PIN code for unlocking Bitwarden. Your PIN settings will be reset if you ever fully log out of the application." }, + "setYourPinCode1": { + "message": "Your PIN will be used to unlock Bitwarden instead of your master password. Your PIN will reset if you ever fully log out of Bitwarden." + }, "pinRequired": { "message": "PIN code is required." }, @@ -1899,6 +1920,9 @@ "lockWithMasterPassOnRestart": { "message": "Lock with master password on browser restart" }, + "lockWithMasterPassOnRestart1": { + "message": "Require master password on browser restart" + }, "selectOneCollection": { "message": "You must select at least one collection." }, @@ -1921,7 +1945,7 @@ "message": "Use this password" }, "useThisUsername": { - "message": "Use this username" + "message": "Use this username" }, "securePasswordGenerated": { "message": "Secure password generated! Don't forget to also update your password on the website." @@ -1937,6 +1961,9 @@ "vaultTimeoutAction": { "message": "Vault timeout action" }, + "vaultTimeoutAction1": { + "message": "Timeout action" + }, "lock": { "message": "Lock", "description": "Verb form: to make secure or inaccessible by" @@ -2131,12 +2158,12 @@ "nativeMessagingWrongUserTitle": { "message": "Account missmatch" }, - "nativeMessagingWrongUserKeyDesc": { - "message": "Biometric unlock failed. The biometric secret key failed to unlock the vault. Please try to set up biometrics again." - }, "nativeMessagingWrongUserKeyTitle": { "message": "Biometric key missmatch" }, + "nativeMessagingWrongUserKeyDesc": { + "message": "Biometric unlock failed. The biometric secret key failed to unlock the vault. Please try to set up biometrics again." + }, "biometricsNotEnabledTitle": { "message": "Biometrics not set up" }, @@ -2522,6 +2549,9 @@ "minutes": { "message": "Minutes" }, + "vaultTimeoutPolicyAffectingOptions": { + "message": "Enterprise policy requirements have been applied to your timeout options" + }, "vaultTimeoutPolicyInEffect": { "message": "Your organization policies have set your maximum allowed vault timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", "placeholders": { @@ -2535,6 +2565,32 @@ } } }, + "vaultTimeoutPolicyInEffect1": { + "message": "$HOURS$ hour(s) and $MINUTES$ minute(s) maximum.", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + }, + "minutes": { + "content": "$2", + "example": "5" + } + } + }, + "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" + } + } + }, "vaultTimeoutPolicyWithActionInEffect": { "message": "Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is $HOURS$ hour(s) and $MINUTES$ minute(s). Your vault timeout action is set to $ACTION$.", "placeholders": { @@ -3264,7 +3320,7 @@ "message": "Unlock your account, opens in a new window", "description": "Screen reader text (aria-label) for unlock account button in overlay" }, - "fillCredentialsFor": { + "fillCredentialsFor": { "message": "Fill credentials for", "description": "Screen reader text for when overlay item is in focused" }, @@ -4321,6 +4377,15 @@ "enterprisePolicyRequirementsApplied": { "message": "Enterprise policy requirements have been applied to this setting" }, + "retry": { + "message": "Retry" + }, + "vaultCustomTimeoutMinimum": { + "message": "Minimum custom timeout is 1 minute." + }, + "additionalContentAvailable": { + "message": "Additional content is available" + }, "fileSavedToDevice": { "message": "File saved to device. Manage from your device downloads." }, diff --git a/apps/browser/src/auth/popup/components/set-pin.component.html b/apps/browser/src/auth/popup/components/set-pin.component.html index 50e7aca75f..80e1b63c7d 100644 --- a/apps/browser/src/auth/popup/components/set-pin.component.html +++ b/apps/browser/src/auth/popup/components/set-pin.component.html @@ -1,11 +1,11 @@
- {{ "unlockWithPin" | i18n }} + {{ "setYourPinTitle" | i18n }}

- {{ "setYourPinCode" | i18n }} + {{ "setYourPinCode1" | i18n }}

{{ "pin" | i18n }} @@ -22,12 +22,12 @@ bitCheckbox formControlName="requireMasterPasswordOnClientRestart" /> - {{ "lockWithMasterPassOnRestart" | i18n }} + {{ "lockWithMasterPassOnRestart1" | i18n }}
+
+

+ {{ "accountSecurity" | i18n }} +

+
+ +
+ +
+
+

{{ "unlockMethods" | i18n }}

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+

{{ "sessionTimeoutHeader" | i18n }}

+
+ + + {{ + "vaultTimeoutPolicyWithActionInEffect" + | i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n) + }} + + + {{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }} + + + {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }} + + + + +
+ + +
+ +
+
+
+

{{ "otherOptions" | i18n }}

+
+ + + + + +
+
+
diff --git a/apps/browser/src/auth/popup/settings/account-security-v1.component.ts b/apps/browser/src/auth/popup/settings/account-security-v1.component.ts new file mode 100644 index 0000000000..4975ba5f7a --- /dev/null +++ b/apps/browser/src/auth/popup/settings/account-security-v1.component.ts @@ -0,0 +1,501 @@ +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { + BehaviorSubject, + combineLatest, + concatMap, + distinctUntilChanged, + filter, + firstValueFrom, + map, + Observable, + pairwise, + startWith, + Subject, + switchMap, + takeUntil, +} from "rxjs"; + +import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; +import { PinServiceAbstraction } from "@bitwarden/auth/common"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +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 { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { BiometricsService } from "@bitwarden/common/platform/biometrics/biometric.service"; +import { + VaultTimeout, + VaultTimeoutOption, + VaultTimeoutStringType, +} from "@bitwarden/common/types/vault-timeout.type"; +import { DialogService } from "@bitwarden/components"; + +import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors"; +import { BrowserApi } from "../../../platform/browser/browser-api"; +import { enableAccountSwitching } from "../../../platform/flags"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; +import { SetPinComponent } from "../components/set-pin.component"; + +import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component"; + +@Component({ + selector: "auth-account-security", + templateUrl: "account-security-v1.component.html", +}) +// eslint-disable-next-line rxjs-angular/prefer-takeuntil +export class AccountSecurityComponent implements OnInit, OnDestroy { + protected readonly VaultTimeoutAction = VaultTimeoutAction; + + availableVaultTimeoutActions: VaultTimeoutAction[] = []; + vaultTimeoutOptions: VaultTimeoutOption[]; + vaultTimeoutPolicyCallout: Observable<{ + timeout: { hours: number; minutes: number }; + action: VaultTimeoutAction; + }>; + supportsBiometric: boolean; + showChangeMasterPass = true; + accountSwitcherEnabled = false; + + form = this.formBuilder.group({ + vaultTimeout: [null as VaultTimeout | null], + vaultTimeoutAction: [VaultTimeoutAction.Lock], + pin: [null as boolean | null], + biometric: false, + enableAutoBiometricsPrompt: true, + }); + + private refreshTimeoutSettings$ = new BehaviorSubject(undefined); + private destroy$ = new Subject(); + + constructor( + private accountService: AccountService, + private pinService: PinServiceAbstraction, + private policyService: PolicyService, + private formBuilder: FormBuilder, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private vaultTimeoutService: VaultTimeoutService, + private vaultTimeoutSettingsService: VaultTimeoutSettingsService, + public messagingService: MessagingService, + private environmentService: EnvironmentService, + private cryptoService: CryptoService, + private stateService: StateService, + private userVerificationService: UserVerificationService, + private dialogService: DialogService, + private changeDetectorRef: ChangeDetectorRef, + private biometricStateService: BiometricStateService, + private biometricsService: BiometricsService, + ) { + this.accountSwitcherEnabled = enableAccountSwitching(); + } + + async ngOnInit() { + const maximumVaultTimeoutPolicy = this.policyService.get$(PolicyType.MaximumVaultTimeout); + this.vaultTimeoutPolicyCallout = maximumVaultTimeoutPolicy.pipe( + filter((policy) => policy != null), + map((policy) => { + let timeout; + if (policy.data?.minutes) { + timeout = { + hours: Math.floor(policy.data?.minutes / 60), + minutes: policy.data?.minutes % 60, + }; + } + return { timeout: timeout, action: policy.data?.action }; + }), + ); + + const showOnLocked = + !this.platformUtilsService.isFirefox() && !this.platformUtilsService.isSafari(); + + this.vaultTimeoutOptions = [ + { 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 }, + ]; + + if (showOnLocked) { + this.vaultTimeoutOptions.push({ + name: this.i18nService.t("onLocked"), + value: VaultTimeoutStringType.OnLocked, + }); + } + + this.vaultTimeoutOptions.push({ + name: this.i18nService.t("onRestart"), + value: VaultTimeoutStringType.OnRestart, + }); + this.vaultTimeoutOptions.push({ + name: this.i18nService.t("never"), + value: VaultTimeoutStringType.Never, + }); + + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + let timeout = await firstValueFrom( + this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(activeAccount.id), + ); + if (timeout === VaultTimeoutStringType.OnLocked && !showOnLocked) { + timeout = VaultTimeoutStringType.OnRestart; + } + + const initialValues = { + vaultTimeout: timeout, + vaultTimeoutAction: await firstValueFrom( + this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id), + ), + pin: await this.pinService.isPinSet(activeAccount.id), + biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(), + enableAutoBiometricsPrompt: await firstValueFrom( + this.biometricStateService.promptAutomatically$, + ), + }; + this.form.patchValue(initialValues, { emitEvent: false }); + + this.supportsBiometric = await this.biometricsService.supportsBiometric(); + this.showChangeMasterPass = await this.userVerificationService.hasMasterPassword(); + + this.form.controls.vaultTimeout.valueChanges + .pipe( + startWith(initialValues.vaultTimeout), // emit to init pairwise + pairwise(), + concatMap(async ([previousValue, newValue]) => { + await this.saveVaultTimeout(previousValue, newValue); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + + this.form.controls.vaultTimeoutAction.valueChanges + .pipe( + startWith(initialValues.vaultTimeoutAction), // emit to init pairwise + pairwise(), + concatMap(async ([previousValue, newValue]) => { + await this.saveVaultTimeoutAction(previousValue, newValue); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + + this.form.controls.pin.valueChanges + .pipe( + concatMap(async (value) => { + await this.updatePin(value); + this.refreshTimeoutSettings$.next(); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + + this.form.controls.biometric.valueChanges + .pipe( + distinctUntilChanged(), + concatMap(async (enabled) => { + await this.updateBiometric(enabled); + if (enabled) { + this.form.controls.enableAutoBiometricsPrompt.enable(); + } else { + this.form.controls.enableAutoBiometricsPrompt.disable(); + } + this.refreshTimeoutSettings$.next(); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + + this.refreshTimeoutSettings$ + .pipe( + switchMap(() => + combineLatest([ + this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id), + ]), + ), + takeUntil(this.destroy$), + ) + .subscribe(([availableActions, action]) => { + this.availableVaultTimeoutActions = availableActions; + this.form.controls.vaultTimeoutAction.setValue(action, { emitEvent: false }); + // NOTE: The UI doesn't properly update without detect changes. + // I've even tried using an async pipe, but it still doesn't work. I'm not sure why. + // Using an async pipe means that we can't call `detectChanges` AFTER the data has change + // meaning that we are forced to use regular class variables instead of observables. + this.changeDetectorRef.detectChanges(); + }); + + this.refreshTimeoutSettings$ + .pipe( + switchMap(() => + combineLatest([ + this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + maximumVaultTimeoutPolicy, + ]), + ), + takeUntil(this.destroy$), + ) + .subscribe(([availableActions, policy]) => { + if (policy?.data?.action || availableActions.length <= 1) { + this.form.controls.vaultTimeoutAction.disable({ emitEvent: false }); + } else { + this.form.controls.vaultTimeoutAction.enable({ emitEvent: false }); + } + }); + } + + async saveVaultTimeout(previousValue: VaultTimeout, newValue: VaultTimeout) { + if (newValue === VaultTimeoutStringType.Never) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "warning" }, + content: { key: "neverLockWarning" }, + type: "warning", + }); + + if (!confirmed) { + this.form.controls.vaultTimeout.setValue(previousValue, { emitEvent: false }); + return; + } + } + + // The minTimeoutError does not apply to browser because it supports Immediately + // So only check for the policyError + if (this.form.controls.vaultTimeout.hasError("policyError")) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("vaultTimeoutTooLarge"), + ); + return; + } + + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + const vaultTimeoutAction = await firstValueFrom( + this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id), + ); + + await this.vaultTimeoutSettingsService.setVaultTimeoutOptions( + activeAccount.id, + newValue, + vaultTimeoutAction, + ); + if (newValue === VaultTimeoutStringType.Never) { + this.messagingService.send("bgReseedStorage"); + } + } + + async saveVaultTimeoutAction(previousValue: VaultTimeoutAction, newValue: VaultTimeoutAction) { + if (newValue === VaultTimeoutAction.LogOut) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "vaultTimeoutLogOutConfirmationTitle" }, + content: { key: "vaultTimeoutLogOutConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + this.form.controls.vaultTimeoutAction.setValue(previousValue, { + emitEvent: false, + }); + return; + } + } + + if (this.form.controls.vaultTimeout.hasError("policyError")) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("vaultTimeoutTooLarge"), + ); + return; + } + + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + await this.vaultTimeoutSettingsService.setVaultTimeoutOptions( + activeAccount.id, + this.form.value.vaultTimeout, + newValue, + ); + this.refreshTimeoutSettings$.next(); + } + + async updatePin(value: boolean) { + if (value) { + const dialogRef = SetPinComponent.open(this.dialogService); + + if (dialogRef == null) { + this.form.controls.pin.setValue(false, { emitEvent: false }); + return; + } + + const userHasPinSet = await firstValueFrom(dialogRef.closed); + this.form.controls.pin.setValue(userHasPinSet, { emitEvent: false }); + } else { + await this.vaultTimeoutSettingsService.clear(); + } + } + + async updateBiometric(enabled: boolean) { + if (enabled && this.supportsBiometric) { + let granted; + try { + granted = await BrowserApi.requestPermission({ permissions: ["nativeMessaging"] }); + } catch (e) { + // eslint-disable-next-line + console.error(e); + + if (this.platformUtilsService.isFirefox() && BrowserPopupUtils.inSidebar(window)) { + await this.dialogService.openSimpleDialog({ + title: { key: "nativeMessaginPermissionSidebarTitle" }, + content: { key: "nativeMessaginPermissionSidebarDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + type: "info", + }); + + this.form.controls.biometric.setValue(false); + return; + } + } + + if (!granted) { + await this.dialogService.openSimpleDialog({ + title: { key: "nativeMessaginPermissionErrorTitle" }, + content: { key: "nativeMessaginPermissionErrorDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + type: "danger", + }); + + this.form.controls.biometric.setValue(false); + return; + } + + const awaitDesktopDialogRef = AwaitDesktopDialogComponent.open(this.dialogService); + const awaitDesktopDialogClosed = firstValueFrom(awaitDesktopDialogRef.closed); + + await this.cryptoService.refreshAdditionalKeys(); + + await Promise.race([ + awaitDesktopDialogClosed.then(async (result) => { + if (result !== true) { + this.form.controls.biometric.setValue(false); + } + }), + this.biometricsService + .authenticateBiometric() + .then((result) => { + this.form.controls.biometric.setValue(result); + if (!result) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorEnableBiometricTitle"), + this.i18nService.t("errorEnableBiometricDesc"), + ); + } + }) + .catch((e) => { + // Handle connection errors + this.form.controls.biometric.setValue(false); + + const error = BiometricErrors[e.message as BiometricErrorTypes]; + + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.dialogService.openSimpleDialog({ + title: { key: error.title }, + content: { key: error.description }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + type: "danger", + }); + }) + .finally(() => { + awaitDesktopDialogRef.close(true); + }), + ]); + } else { + await this.biometricStateService.setBiometricUnlockEnabled(false); + await this.biometricStateService.setFingerprintValidated(false); + } + } + + async updateAutoBiometricsPrompt() { + await this.biometricStateService.setPromptAutomatically( + this.form.value.enableAutoBiometricsPrompt, + ); + } + + async changePassword() { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "continueToWebApp" }, + content: { key: "changeMasterPasswordOnWebConfirmation" }, + type: "info", + acceptButtonText: { key: "continue" }, + }); + if (confirmed) { + const env = await firstValueFrom(this.environmentService.environment$); + await BrowserApi.createNewTab(env.getWebVaultUrl()); + } + } + + async twoStep() { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "twoStepLogin" }, + content: { key: "twoStepLoginConfirmation" }, + type: "info", + }); + if (confirmed) { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + BrowserApi.createNewTab("https://bitwarden.com/help/setup-two-step-login/"); + } + } + + async fingerprint() { + const fingerprint = await this.cryptoService.getFingerprint( + await this.stateService.getUserId(), + ); + + const dialogRef = FingerprintDialogComponent.open(this.dialogService, { + fingerprint, + }); + + return firstValueFrom(dialogRef.closed); + } + + async lock() { + await this.vaultTimeoutService.lock(); + } + + async logOut() { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "logOut" }, + content: { key: "logOutConfirmation" }, + type: "info", + }); + + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + if (confirmed) { + this.messagingService.send("logout", { userId: userId }); + } + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index dff9675743..af6525daa8 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -1,140 +1,126 @@ - -
- -
-

- {{ "accountSecurity" | i18n }} -

-
- -
-
-
-
-

{{ "unlockMethods" | i18n }}

-
-
- - -
-
- - -
-
- - -
-
-
-
-

{{ "sessionTimeoutHeader" | i18n }}

-
- - - {{ - "vaultTimeoutPolicyWithActionInEffect" - | i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n) - }} - - - {{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }} - - - {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }} - - - - -
- - + {{ + "unlockWithBiometrics" | i18n + }} + + + + {{ + "enableAutoBiometricsPrompt" | i18n + }} + + - - -
- -
-
-
-

{{ "otherOptions" | i18n }}

-
- - - - + + + + + + + + +
{{ "lockNow" | i18n }}
- - -
{{ "logOut" | i18n }}
- - -
+ +
-
+ diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 25401f06f3..8e0acc7d64 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -1,15 +1,15 @@ import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; +import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { RouterModule } from "@angular/router"; import { BehaviorSubject, combineLatest, concatMap, distinctUntilChanged, - filter, firstValueFrom, map, - Observable, pairwise, startWith, Subject, @@ -17,7 +17,8 @@ import { takeUntil, } from "rxjs"; -import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { FingerprintDialogComponent, VaultTimeoutInputComponent } from "@bitwarden/auth/angular"; import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; @@ -39,30 +40,67 @@ import { VaultTimeoutOption, VaultTimeoutStringType, } from "@bitwarden/common/types/vault-timeout.type"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { + CardComponent, + CheckboxModule, + DialogService, + FormFieldModule, + IconButtonModule, + ItemModule, + LinkModule, + SectionComponent, + SectionHeaderComponent, + SelectModule, + TypographyModule, + ToastService, +} from "@bitwarden/components"; import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors"; import { BrowserApi } from "../../../platform/browser/browser-api"; import { enableAccountSwitching } from "../../../platform/flags"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; +import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; +import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; import { SetPinComponent } from "../components/set-pin.component"; import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component"; @Component({ - selector: "auth-account-security", templateUrl: "account-security.component.html", + standalone: true, + imports: [ + CardComponent, + CheckboxModule, + CommonModule, + FormFieldModule, + FormsModule, + ReactiveFormsModule, + IconButtonModule, + ItemModule, + JslibModule, + LinkModule, + PopOutComponent, + PopupFooterComponent, + PopupHeaderComponent, + PopupPageComponent, + RouterModule, + SectionComponent, + SectionHeaderComponent, + SelectModule, + TypographyModule, + VaultTimeoutInputComponent, + ], }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class AccountSecurityComponent implements OnInit, OnDestroy { protected readonly VaultTimeoutAction = VaultTimeoutAction; + showMasterPasswordOnClientRestartOption = true; availableVaultTimeoutActions: VaultTimeoutAction[] = []; - vaultTimeoutOptions: VaultTimeoutOption[]; - vaultTimeoutPolicyCallout: Observable<{ - timeout: { hours: number; minutes: number }; - action: VaultTimeoutAction; - }>; + vaultTimeoutOptions: VaultTimeoutOption[] = []; + hasVaultTimeoutPolicy = false; supportsBiometric: boolean; showChangeMasterPass = true; accountSwitcherEnabled = false; @@ -71,6 +109,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { vaultTimeout: [null as VaultTimeout | null], vaultTimeoutAction: [VaultTimeoutAction.Lock], pin: [null as boolean | null], + pinLockWithMasterPassword: false, biometric: false, enableAutoBiometricsPrompt: true, }); @@ -102,20 +141,12 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { } async ngOnInit() { + const hasMasterPassword = await this.userVerificationService.hasMasterPassword(); + this.showMasterPasswordOnClientRestartOption = hasMasterPassword; const maximumVaultTimeoutPolicy = this.policyService.get$(PolicyType.MaximumVaultTimeout); - this.vaultTimeoutPolicyCallout = maximumVaultTimeoutPolicy.pipe( - filter((policy) => policy != null), - map((policy) => { - let timeout; - if (policy.data?.minutes) { - timeout = { - hours: Math.floor(policy.data?.minutes / 60), - minutes: policy.data?.minutes % 60, - }; - } - return { timeout: timeout, action: policy.data?.action }; - }), - ); + if ((await firstValueFrom(this.policyService.get$(PolicyType.MaximumVaultTimeout))) != null) { + this.hasVaultTimeoutPolicy = true; + } const showOnLocked = !this.platformUtilsService.isFirefox() && !this.platformUtilsService.isSafari(); @@ -161,6 +192,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id), ), pin: await this.pinService.isPinSet(activeAccount.id), + pinLockWithMasterPassword: + (await this.pinService.getPinLockType(activeAccount.id)) == "EPHEMERAL", biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(), enableAutoBiometricsPrompt: await firstValueFrom( this.biometricStateService.promptAutomatically$, @@ -185,9 +218,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { this.form.controls.vaultTimeoutAction.valueChanges .pipe( startWith(initialValues.vaultTimeoutAction), // emit to init pairwise - pairwise(), - concatMap(async ([previousValue, newValue]) => { - await this.saveVaultTimeoutAction(previousValue, newValue); + map(async (value) => { + await this.saveVaultTimeoutAction(value); }), takeUntil(this.destroy$), ) @@ -203,6 +235,22 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { ) .subscribe(); + this.form.controls.pinLockWithMasterPassword.valueChanges + .pipe( + concatMap(async (value) => { + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; + const pinKeyEncryptedUserKey = + (await this.pinService.getPinKeyEncryptedUserKeyPersistent(userId)) || + (await this.pinService.getPinKeyEncryptedUserKeyEphemeral(userId)); + await this.pinService.clearPinKeyEncryptedUserKeyPersistent(userId); + await this.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId); + await this.pinService.storePinKeyEncryptedUserKey(pinKeyEncryptedUserKey, value, userId); + this.refreshTimeoutSettings$.next(); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + this.form.controls.biometric.valueChanges .pipe( distinctUntilChanged(), @@ -219,6 +267,15 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { ) .subscribe(); + this.form.controls.enableAutoBiometricsPrompt.valueChanges + .pipe( + concatMap(async (enabled) => { + await this.biometricStateService.setPromptAutomatically(enabled); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + this.refreshTimeoutSettings$ .pipe( switchMap(() => @@ -272,17 +329,6 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { } } - // The minTimeoutError does not apply to browser because it supports Immediately - // So only check for the policyError - if (this.form.controls.vaultTimeout.hasError("policyError")) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("vaultTimeoutTooLarge"), - }); - return; - } - const activeAccount = await firstValueFrom(this.accountService.activeAccount$); const vaultTimeoutAction = await firstValueFrom( @@ -299,8 +345,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { } } - async saveVaultTimeoutAction(previousValue: VaultTimeoutAction, newValue: VaultTimeoutAction) { - if (newValue === VaultTimeoutAction.LogOut) { + async saveVaultTimeoutAction(value: VaultTimeoutAction) { + if (value === VaultTimeoutAction.LogOut) { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "vaultTimeoutLogOutConfirmationTitle" }, content: { key: "vaultTimeoutLogOutConfirmation" }, @@ -308,7 +354,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { }); if (!confirmed) { - this.form.controls.vaultTimeoutAction.setValue(previousValue, { + this.form.controls.vaultTimeoutAction.setValue(VaultTimeoutAction.Lock, { emitEvent: false, }); return; @@ -329,7 +375,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { await this.vaultTimeoutSettingsService.setVaultTimeoutOptions( activeAccount.id, this.form.value.vaultTimeout, - newValue, + value, ); this.refreshTimeoutSettings$.next(); } @@ -343,8 +389,13 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { return; } + const userId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((account) => account.id)), + ); const userHasPinSet = await firstValueFrom(dialogRef.closed); this.form.controls.pin.setValue(userHasPinSet, { emitEvent: false }); + const requireReprompt = (await this.pinService.getPinLockType(userId)) == "EPHEMERAL"; + this.form.controls.pinLockWithMasterPassword.setValue(requireReprompt, { emitEvent: false }); } else { await this.vaultTimeoutSettingsService.clear(); } @@ -386,77 +437,91 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { return; } - let awaitDesktopDialogRef: DialogRef | undefined; - let biometricsResponseReceived = false; - await this.cryptoService.refreshAdditionalKeys(); - const waitForUserDialogPromise = async () => { - // only show waiting dialog if we have waited for 200 msec to prevent double dialog - // the os will respond instantly if the dialog shows successfully, and the desktop app will respond instantly if something is wrong - await new Promise((resolve) => setTimeout(resolve, 200)); - if (biometricsResponseReceived) { + const successful = await this.trySetupBiometrics(); + this.form.controls.biometric.setValue(successful); + if (!successful) { + await this.biometricStateService.setBiometricUnlockEnabled(false); + await this.biometricStateService.setFingerprintValidated(false); + } + } + } + + async trySetupBiometrics(): Promise { + let awaitDesktopDialogRef: DialogRef | undefined; + let biometricsResponseReceived = false; + let setupResult = false; + + const waitForUserDialogPromise = async () => { + // only show waiting dialog if we have waited for 500 msec to prevent double dialog + // the os will respond instantly if the dialog shows successfully, and the desktop app will respond instantly if something is wrong + await new Promise((resolve) => setTimeout(resolve, 500)); + if (biometricsResponseReceived) { + return; + } + + awaitDesktopDialogRef = AwaitDesktopDialogComponent.open(this.dialogService); + await firstValueFrom(awaitDesktopDialogRef.closed); + if (!biometricsResponseReceived) { + setupResult = false; + } + return; + }; + + const biometricsPromise = async () => { + try { + const result = await this.biometricsService.authenticateBiometric(); + + // prevent duplicate dialog + biometricsResponseReceived = true; + if (awaitDesktopDialogRef) { + awaitDesktopDialogRef.close(result); + } + + if (!result) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorEnableBiometricTitle"), + this.i18nService.t("errorEnableBiometricDesc"), + ); + } + setupResult = true; + } catch (e) { + // prevent duplicate dialog + biometricsResponseReceived = true; + if (awaitDesktopDialogRef) { + awaitDesktopDialogRef.close(true); + } + + if (e.message == "canceled") { + setupResult = false; return; } - awaitDesktopDialogRef = AwaitDesktopDialogComponent.open(this.dialogService); - const result = await firstValueFrom(awaitDesktopDialogRef.closed); - if (result !== true) { - this.form.controls.biometric.setValue(false); + const error = BiometricErrors[e.message as BiometricErrorTypes]; + const shouldRetry = await this.dialogService.openSimpleDialog({ + title: { key: error.title }, + content: { key: error.description }, + acceptButtonText: { key: "retry" }, + cancelButtonText: null, + type: "danger", + }); + if (shouldRetry) { + setupResult = await this.trySetupBiometrics(); + } else { + setupResult = false; + return; } - }; - - const biometricsPromise = async () => { - try { - const result = await this.biometricsService.authenticateBiometric(); - - // prevent duplicate dialog - biometricsResponseReceived = true; - if (awaitDesktopDialogRef) { - awaitDesktopDialogRef.close(true); - } - - this.form.controls.biometric.setValue(result); - if (!result) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorEnableBiometricTitle"), - message: this.i18nService.t("errorEnableBiometricDesc"), - }); - } - } catch (e) { - // prevent duplicate dialog - biometricsResponseReceived = true; - if (awaitDesktopDialogRef) { - awaitDesktopDialogRef.close(true); - } - - this.form.controls.biometric.setValue(false); - - if (e.message == "canceled") { - return; - } - - const error = BiometricErrors[e.message as BiometricErrorTypes]; - await this.dialogService.openSimpleDialog({ - title: { key: error.title }, - content: { key: error.description }, - acceptButtonText: { key: "ok" }, - cancelButtonText: null, - type: "danger", - }); - } finally { - if (awaitDesktopDialogRef) { - awaitDesktopDialogRef.close(true); - } + } finally { + if (awaitDesktopDialogRef) { + awaitDesktopDialogRef.close(true); } - }; + } + }; - await Promise.race([waitForUserDialogPromise(), biometricsPromise()]); - } else { - await this.biometricStateService.setBiometricUnlockEnabled(false); - await this.biometricStateService.setFingerprintValidated(false); - } + await Promise.all([waitForUserDialogPromise(), biometricsPromise()]); + return setupResult; } async updateAutoBiometricsPrompt() { @@ -471,6 +536,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { content: { key: "changeMasterPasswordOnWebConfirmation" }, type: "info", acceptButtonText: { key: "continue" }, + cancelButtonText: { key: "cancel" }, }); if (confirmed) { const env = await firstValueFrom(this.environmentService.environment$); @@ -480,9 +546,11 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { async twoStep() { const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "twoStepLogin" }, - content: { key: "twoStepLoginConfirmation" }, + title: { key: "twoStepLoginConfirmationTitle" }, + content: { key: "twoStepLoginConfirmationContent" }, type: "info", + acceptButtonText: { key: "continue" }, + cancelButtonText: { key: "cancel" }, }); if (confirmed) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 0f6a9d9248..14f35a78fb 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -41,6 +41,7 @@ import { LoginComponent } from "../auth/popup/login.component"; import { RegisterComponent } from "../auth/popup/register.component"; import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component"; +import { AccountSecurityComponent as AccountSecurityV1Component } from "../auth/popup/settings/account-security-v1.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { SsoComponent } from "../auth/popup/sso.component"; import { TwoFactorAuthComponent } from "../auth/popup/two-factor-auth.component"; @@ -296,12 +297,11 @@ const routes: Routes = [ canActivate: [authGuard], data: { state: "autofill" }, }), - { + ...extensionRefreshSwap(AccountSecurityV1Component, AccountSecurityComponent, { path: "account-security", - component: AccountSecurityComponent, canActivate: [authGuard], data: { state: "account-security" }, - }, + }), ...extensionRefreshSwap(NotificationsSettingsV1Component, NotificationsSettingsComponent, { path: "notifications", canActivate: [authGuard], diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index f14dafacb7..d5777215b1 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -15,7 +15,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe"; import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe"; import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; -import { AvatarModule, ButtonModule, ToastModule } from "@bitwarden/components"; +import { AvatarModule, ButtonModule, FormFieldModule, ToastModule } from "@bitwarden/components"; import { AccountComponent } from "../auth/popup/account-switching/account.component"; import { CurrentAccountComponent } from "../auth/popup/account-switching/current-account.component"; @@ -30,6 +30,7 @@ import { LoginComponent } from "../auth/popup/login.component"; import { RegisterComponent } from "../auth/popup/register.component"; import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component"; +import { AccountSecurityComponent as AccountSecurityComponentV1 } from "../auth/popup/settings/account-security-v1.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { VaultTimeoutInputComponent } from "../auth/popup/settings/vault-timeout-input.component"; import { SsoComponent } from "../auth/popup/sso.component"; @@ -98,6 +99,7 @@ import "../platform/popup/locales"; A11yModule, AppRoutingModule, AutofillComponent, + AccountSecurityComponent, ToastModule.forRoot({ maxOpened: 2, autoDismiss: true, @@ -132,6 +134,7 @@ import "../platform/popup/locales"; HeaderComponent, UserVerificationDialogComponent, CurrentAccountComponent, + FormFieldModule, ExtensionAnonLayoutWrapperComponent, ], declarations: [ @@ -171,7 +174,6 @@ import "../platform/popup/locales"; SendListComponent, SendTypeComponent, SetPasswordComponent, - AccountSecurityComponent, SettingsComponent, VaultSettingsComponent, ShareComponent, @@ -183,6 +185,7 @@ import "../platform/popup/locales"; TwoFactorOptionsComponent, UpdateTempPasswordComponent, UserVerificationComponent, + AccountSecurityComponentV1, VaultTimeoutInputComponent, ViewComponent, ViewCustomFieldsComponent, diff --git a/apps/desktop/src/auth/components/set-pin.component.html b/apps/desktop/src/auth/components/set-pin.component.html index 50e7aca75f..cadd5340bb 100644 --- a/apps/desktop/src/auth/components/set-pin.component.html +++ b/apps/desktop/src/auth/components/set-pin.component.html @@ -22,7 +22,7 @@ bitCheckbox formControlName="requireMasterPasswordOnClientRestart" /> - {{ "lockWithMasterPassOnRestart" | i18n }} + {{ "lockWithMasterPassOnRestart1" | i18n }}
diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 721faa2567..bd9ad5075b 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -942,6 +942,9 @@ "vaultTimeout": { "message": "Vault timeout" }, + "vaultTimeout1": { + "message": "Timeout" + }, "vaultTimeoutDesc": { "message": "Choose when your vault will take the vault timeout action." }, @@ -1567,7 +1570,7 @@ "recommendedForSecurity": { "message": "Recommended for security." }, - "lockWithMasterPassOnRestart": { + "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, "deleteAccount": { @@ -2099,8 +2102,8 @@ "minutes": { "message": "Minutes" }, - "vaultTimeoutPolicyInEffect": { - "message": "Your organization policies have set your maximum allowed vault timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", + "vaultTimeoutPolicyInEffect1": { + "message": "$HOURS$ hour(s) and $MINUTES$ minute(s) maximum.", "placeholders": { "hours": { "content": "$1", diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 340acc8efb..7b518e4899 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1024,8 +1024,8 @@ "unexpectedError": { "message": "An unexpected error has occurred." }, - "expirationDateError" : { - "message":"Please select an expiration date that is in the future." + "expirationDateError": { + "message": "Please select an expiration date that is in the future." }, "emailAddress": { "message": "Email address" @@ -1033,8 +1033,8 @@ "yourVaultIsLockedV2": { "message": "Your vault is locked" }, - "uuid":{ - "message" : "UUID" + "uuid": { + "message": "UUID" }, "unlock": { "message": "Unlock" @@ -1270,10 +1270,10 @@ "copyUuid": { "message": "Copy UUID" }, - "errorRefreshingAccessToken":{ + "errorRefreshingAccessToken": { "message": "Access Token Refresh Error" }, - "errorRefreshingAccessTokenDesc":{ + "errorRefreshingAccessTokenDesc": { "message": "No refresh token or API keys found. Please try logging out and logging back in." }, "warning": { @@ -3993,6 +3993,9 @@ "vaultTimeout": { "message": "Vault timeout" }, + "vaultTimeout1": { + "message": "Timeout" + }, "vaultTimeoutDesc": { "message": "Choose when your vault will take the vault timeout action." }, @@ -4997,7 +5000,7 @@ "youNeedApprovalFromYourAdminToTrySecretsManager": { "message": "You need approval from your administrator to try Secrets Manager." }, - "smAccessRequestEmailSent" : { + "smAccessRequestEmailSent": { "message": "Access request for secrets manager email sent to admins." }, "requestAccessSMDefaultEmailContent": { @@ -5006,8 +5009,8 @@ "giveMembersAccess": { "message": "Give members access:" }, - "viewAndSelectTheMembers" : { - "message" :"view and select the members you want to give access to Secrets Manager." + "viewAndSelectTheMembers": { + "message": "view and select the members you want to give access to Secrets Manager." }, "openYourOrganizations": { "message": "Open your organization's" @@ -5471,6 +5474,19 @@ } } }, + "vaultTimeoutPolicyInEffect1": { + "message": "$HOURS$ hour(s) and $MINUTES$ minute(s) maximum.", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + }, + "minutes": { + "content": "$2", + "example": "5" + } + } + }, "vaultTimeoutPolicyWithActionInEffect": { "message": "Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is $HOURS$ hour(s) and $MINUTES$ minute(s). Your vault timeout action is set to $ACTION$.", "placeholders": { @@ -5497,9 +5513,6 @@ } } }, - "customVaultTimeout": { - "message": "Custom vault timeout" - }, "vaultTimeoutToLarge": { "message": "Your vault timeout exceeds the restriction set by your organization." }, @@ -5944,10 +5957,10 @@ "selfHostedBaseUrlHint": { "message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com" }, - "selfHostedCustomEnvHeader" :{ + "selfHostedCustomEnvHeader": { "message": "For advanced configuration, you can specify the base URL of each service independently." }, - "selfHostedEnvFormInvalid" :{ + "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, "apiUrl": { @@ -7709,7 +7722,7 @@ } } }, - "verificationRequired" : { + "verificationRequired": { "message": "Verification required", "description": "Default title for the user verification dialog." }, @@ -8501,7 +8514,7 @@ "deleteProviderRecoverConfirmDesc": { "message": "You have requested to delete this Provider. Use the button below to confirm." }, - "deleteProviderWarning": { + "deleteProviderWarning": { "message": "Deleting your provider is permanent. It cannot be undone." }, "errorAssigningTargetCollection": { @@ -8514,7 +8527,7 @@ "message": "Integrations & SDKs", "description": "The title for the section that deals with integrations and SDKs." }, - "integrations":{ + "integrations": { "message": "Integrations" }, "integrationsDesc": { @@ -8585,7 +8598,7 @@ }, "createdNewClient": { "message": "Successfully created new client" - }, + }, "noAccess": { "message": "No access" }, @@ -8821,11 +8834,11 @@ "placeholders": { "value": { "content": "$1", - "example":"increments of 100,000" + "example": "increments of 100,000" } } }, - "providerReinstate":{ + "providerReinstate": { "message": " Contact Customer Support to reinstate your subscription." }, "secretPeopleDescription": { diff --git a/libs/auth/src/angular/fingerprint-dialog/fingerprint-dialog.component.html b/libs/auth/src/angular/fingerprint-dialog/fingerprint-dialog.component.html index 9a534d461c..d6005c970f 100644 --- a/libs/auth/src/angular/fingerprint-dialog/fingerprint-dialog.component.html +++ b/libs/auth/src/angular/fingerprint-dialog/fingerprint-dialog.component.html @@ -1,8 +1,10 @@ - {{ "yourAccountsFingerprint" | i18n }}: + {{ "yourAccountsFingerprint" | i18n }}: - {{ data.fingerprint.join("-") }} + {{ data.fingerprint.join("-") }} - - {{ "vaultTimeout" | i18n }} +
+ + {{ "vaultTimeout1" | i18n }} - {{ - ((canLockVault$ | async) ? "vaultTimeoutDesc" : "vaultTimeoutLogoutDesc") | i18n - }}
- - {{ "customVaultTimeout" | i18n }} - - {{ "hours" | i18n }} + + + {{ "hours" | i18n }} - - - {{ "minutes" | i18n }} + + + {{ "minutes" | i18n }}
- + + {{ "vaultTimeoutPolicyInEffect1" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes }} + + {{ "vaultCustomTimeoutMinimum" | i18n }} + + + {{ + "vaultTimeoutPolicyMaximumError" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes + }} +
diff --git a/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts b/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts index 7c76083560..d42e7d7d15 100644 --- a/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts +++ b/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts @@ -55,16 +55,41 @@ type VaultTimeoutFormValue = VaultTimeoutForm["value"]; export class VaultTimeoutInputComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges { + protected readonly VaultTimeoutAction = VaultTimeoutAction; + get showCustom() { return this.form.get("vaultTimeout").value === VaultTimeoutInputComponent.CUSTOM_VALUE; } - get exceedsMinimumTimout(): boolean { + get exceedsMinimumTimeout(): boolean { return ( !this.showCustom || this.customTimeInMinutes() > VaultTimeoutInputComponent.MIN_CUSTOM_MINUTES ); } + get exceedsMaximumTimeout(): boolean { + return ( + this.showCustom && + this.customTimeInMinutes() > + this.vaultTimeoutPolicyMinutes + 60 * this.vaultTimeoutPolicyHours + ); + } + + get filteredVaultTimeoutOptions(): VaultTimeoutOption[] { + // by policy max value + if (this.vaultTimeoutPolicy == null || this.vaultTimeoutPolicy.data == null) { + return this.vaultTimeoutOptions; + } + + return this.vaultTimeoutOptions.filter((option) => { + if (typeof option.value === "number") { + return option.value <= this.vaultTimeoutPolicy.data.minutes; + } + + return false; + }); + } + static CUSTOM_VALUE = -100; static MIN_CUSTOM_MINUTES = 0; @@ -77,6 +102,7 @@ export class VaultTimeoutInputComponent }); @Input() vaultTimeoutOptions: VaultTimeoutOption[]; + vaultTimeoutPolicy: Policy; vaultTimeoutPolicyHours: number; vaultTimeoutPolicyMinutes: number; @@ -207,7 +233,7 @@ export class VaultTimeoutInputComponent return { policyError: true }; } - if (!this.exceedsMinimumTimout) { + if (!this.exceedsMinimumTimeout) { return { minTimeoutError: true }; }