diff --git a/apps/browser/src/autofill/notification/bar.html b/apps/browser/src/autofill/notification/bar.html index 796601057d8..90df670d29c 100644 --- a/apps/browser/src/autofill/notification/bar.html +++ b/apps/browser/src/autofill/notification/bar.html @@ -1,4 +1,5 @@ - + + Bitwarden diff --git a/apps/browser/src/autofill/services/autofill-constants.ts b/apps/browser/src/autofill/services/autofill-constants.ts index 92c9a508333..51d0513a7f8 100644 --- a/apps/browser/src/autofill/services/autofill-constants.ts +++ b/apps/browser/src/autofill/services/autofill-constants.ts @@ -59,6 +59,14 @@ export class AutoFillConstants { "neue e-mail", ]; + static readonly RegistrationKeywords: string[] = [ + "register", + "signup", + "sign-up", + "join", + "create", + ]; + static readonly NewsletterFormNames: string[] = ["newsletter"]; static readonly FieldIgnoreList: string[] = ["captcha", "findanything", "forgot"]; diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 2b37e0654ca..f5df17083ce 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -856,13 +856,28 @@ export default class AutofillService implements AutofillServiceInterface { options.fillNewPassword, ); + const loginPasswordFields: AutofillField[] = []; + const registrationPasswordFields: AutofillField[] = []; + + passwordFields.forEach((passField) => { + if (this.isRegistrationPasswordField(pageDetails, passField)) { + registrationPasswordFields.push(passField); + } else { + loginPasswordFields.push(passField); + } + }); + + // Prefer login fields over registration fields + const prioritizedPasswordFields = + loginPasswordFields.length > 0 ? loginPasswordFields : registrationPasswordFields; + for (const formKey in pageDetails.forms) { // eslint-disable-next-line if (!pageDetails.forms.hasOwnProperty(formKey)) { continue; } - passwordFields.forEach((passField) => { + prioritizedPasswordFields.forEach((passField) => { pf = passField; passwords.push(pf); @@ -887,8 +902,7 @@ export default class AutofillService implements AutofillServiceInterface { if (passwordFields.length && !passwords.length) { // The page does not have any forms with password fields. Use the first password field on the page and the // input field just before it as the username. - - pf = passwordFields[0]; + pf = prioritizedPasswordFields[0]; passwords.push(pf); if (login.username && pf.elementNumber > 0) { @@ -2251,6 +2265,38 @@ export default class AutofillService implements AutofillServiceInterface { return arr; } + /** + * Determines if a password field is part of a registration/signup form. + * @param {AutofillPageDetails} pageDetails + * @param {AutofillField} passwordField + * @returns {boolean} + * @private + */ + private isRegistrationPasswordField( + pageDetails: AutofillPageDetails, + passwordField: AutofillField, + ): boolean { + if (!passwordField.form || !pageDetails.forms) { + return false; + } + + const form = pageDetails.forms[passwordField.form]; + if (!form) { + return false; + } + + const formIdentifierValues = [ + form.htmlID?.toLowerCase?.(), + form.htmlName?.toLowerCase?.(), + passwordField?.htmlID?.toLowerCase?.(), + passwordField?.htmlName?.toLowerCase?.(), + ].filter(Boolean); + + return formIdentifierValues.some((value) => + AutoFillConstants.RegistrationKeywords.some((keyword) => value.includes(keyword)), + ); + } + /** * Accepts a pageDetails object with a list of fields and returns a list of * fields that are likely to be username fields. diff --git a/apps/browser/src/background/idle.background.ts b/apps/browser/src/background/idle.background.ts index 2de4b48a9c0..0f89aa4792a 100644 --- a/apps/browser/src/background/idle.background.ts +++ b/apps/browser/src/background/idle.background.ts @@ -1,5 +1,6 @@ import { firstValueFrom } from "rxjs"; +import { LogoutService } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { VaultTimeoutAction, @@ -8,6 +9,7 @@ import { VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; +import { UserId } from "@bitwarden/user-core"; const IdleInterval = 60 * 5; // 5 minutes @@ -21,6 +23,7 @@ export default class IdleBackground { private serverNotificationsService: ServerNotificationsService, private accountService: AccountService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, + private logoutService: LogoutService, ) { this.idle = chrome.idle || (browser != null ? browser.idle : null); } @@ -61,7 +64,7 @@ export default class IdleBackground { this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(userId), ); if (action === VaultTimeoutAction.LogOut) { - await this.vaultTimeoutService.logOut(userId); + await this.logoutService.logout(userId as UserId, "vaultTimeout"); } else { await this.vaultTimeoutService.lock(userId); } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 7ba55a45892..c4c412732c9 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -21,6 +21,7 @@ import { AuthRequestServiceAbstraction, DefaultAuthRequestApiService, DefaultLockService, + DefaultLogoutService, InternalUserDecryptionOptionsServiceAbstraction, LoginEmailServiceAbstraction, LogoutReason, @@ -976,6 +977,7 @@ export default class MainBackground { this.restrictedItemTypesService, ); + const logoutService = new DefaultLogoutService(this.messagingService); this.vaultTimeoutService = new VaultTimeoutService( this.accountService, this.masterPasswordService, @@ -994,7 +996,7 @@ export default class MainBackground { this.logService, this.biometricsService, lockedCallback, - logoutCallback, + logoutService, ); this.containerService = new ContainerService(this.keyService, this.encryptService); @@ -1386,6 +1388,7 @@ export default class MainBackground { this.serverNotificationsService, this.accountService, this.vaultTimeoutSettingsService, + logoutService, ); this.usernameGenerationService = legacyUsernameGenerationServiceFactory( diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts index 30d3b7faeee..b29d97451b8 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts +++ b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts @@ -87,7 +87,7 @@ export default { props: args, template: /*html*/ ` diff --git a/apps/browser/src/key-management/key-connector/remove-password.component.ts b/apps/browser/src/key-management/key-connector/remove-password.component.ts index 915effc8c33..c4077a1eca9 100644 --- a/apps/browser/src/key-management/key-connector/remove-password.component.ts +++ b/apps/browser/src/key-management/key-connector/remove-password.component.ts @@ -4,6 +4,8 @@ import { Component } from "@angular/core"; import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/key-management-ui"; +// 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: "app-remove-password", templateUrl: "remove-password.component.html", diff --git a/apps/browser/src/key-management/vault-timeout/foreground-vault-timeout.service.ts b/apps/browser/src/key-management/vault-timeout/foreground-vault-timeout.service.ts index 5003dfd5b29..4081ab03359 100644 --- a/apps/browser/src/key-management/vault-timeout/foreground-vault-timeout.service.ts +++ b/apps/browser/src/key-management/vault-timeout/foreground-vault-timeout.service.ts @@ -13,8 +13,4 @@ export class ForegroundVaultTimeoutService implements BaseVaultTimeoutService { async lock(userId?: UserId): Promise { this.messagingService.send("lockVault", { userId }); } - - async logOut(userId?: string): Promise { - this.messagingService.send("logout", { userId }); - } } diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index cb5e597e78c..e3d63d20c17 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -23,6 +23,7 @@ import { UserLockIcon, VaultIcon, LockIcon, + DomainIcon, TwoFactorAuthSecurityKeyIcon, } from "@bitwarden/assets/svg"; import { @@ -565,6 +566,8 @@ const routes: Routes = [ key: "verifyYourIdentity", }, showBackButton: true, + // `TwoFactorAuthComponent` manually sets its icon based on the 2fa type + pageIcon: null, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, }, { @@ -572,6 +575,7 @@ const routes: Routes = [ data: { elevation: 1, hideFooter: true, + pageIcon: LockIcon, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, children: [ { @@ -617,9 +621,9 @@ const routes: Routes = [ path: "", component: IntroCarouselComponent, data: { - hideIcon: true, + pageIcon: null, hideFooter: true, - }, + } satisfies ExtensionAnonLayoutWrapperData, }, ], }, @@ -637,6 +641,7 @@ const routes: Routes = [ key: "confirmKeyConnectorDomain", }, showBackButton: true, + pageIcon: DomainIcon, } satisfies ExtensionAnonLayoutWrapperData, }, ], @@ -722,7 +727,7 @@ const routes: Routes = [ }, ], data: { - hideIcon: true, + pageIcon: null, hideBackgroundIllustration: true, showReadonlyHostname: true, } satisfies AnonLayoutWrapperData, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index d34bef4674c..ec35fc7c554 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -39,7 +39,6 @@ import { FilePopoutCalloutComponent } from "../tools/popup/components/file-popou import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; import { ExtensionAnonLayoutWrapperComponent } from "./components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component"; -import { UserVerificationComponent } from "./components/user-verification.component"; import { ServicesModule } from "./services/services.module"; import { TabsV2Component } from "./tabs-v2.component"; @@ -91,7 +90,6 @@ import "../platform/popup/locales"; ColorPasswordPipe, ColorPasswordCountPipe, TabsV2Component, - UserVerificationComponent, RemovePasswordComponent, ], exports: [], diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts index 952c42b8367..659ab70110a 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service.ts @@ -11,13 +11,15 @@ export class ExtensionAnonLayoutWrapperDataService extends DefaultAnonLayoutWrapperDataService implements AnonLayoutWrapperDataService { - protected override anonLayoutWrapperDataSubject = new Subject(); + protected override anonLayoutWrapperDataSubject = new Subject< + Partial + >(); - override setAnonLayoutWrapperData(data: ExtensionAnonLayoutWrapperData): void { + override setAnonLayoutWrapperData(data: Partial): void { this.anonLayoutWrapperDataSubject.next(data); } - override anonLayoutWrapperData$(): Observable { + override anonLayoutWrapperData$(): Observable> { return this.anonLayoutWrapperDataSubject.asObservable(); } } diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html index d389fd8d783..dcd0496ed30 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -23,7 +23,6 @@ [hideLogo]="true" [maxWidth]="maxWidth" [hideFooter]="hideFooter" - [hideIcon]="hideIcon" [hideCardWrapper]="hideCardWrapper" > diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts index c1694d80668..8fca1b057ff 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts @@ -27,7 +27,6 @@ export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData { showBackButton?: boolean; showLogo?: boolean; hideFooter?: boolean; - hideIcon?: boolean; } @Component({ @@ -50,7 +49,6 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { protected showAcctSwitcher: boolean; protected showBackButton: boolean; protected showLogo: boolean = true; - protected hideIcon: boolean = false; protected pageTitle: string; protected pageSubtitle: string; @@ -134,10 +132,6 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { this.showLogo = Boolean(firstChildRouteData["showLogo"]); } - if (firstChildRouteData["hideIcon"] !== undefined) { - this.hideIcon = Boolean(firstChildRouteData["hideIcon"]); - } - if (firstChildRouteData["hideCardWrapper"] !== undefined) { this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]); } @@ -196,10 +190,6 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { if (data.showLogo !== undefined) { this.showLogo = data.showLogo; } - - if (data.hideIcon !== undefined) { - this.hideIcon = data.hideIcon; - } } private handleStringOrTranslation(value: string | Translation): string { @@ -222,7 +212,6 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { this.showLogo = null; this.maxWidth = null; this.hideFooter = null; - this.hideIcon = null; this.hideCardWrapper = null; } diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts index 4e6f2fb452d..7c97bc764f2 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -208,7 +208,9 @@ export const DefaultContentExample: Story = { children: [ { path: "default-example", - data: {}, + data: { + pageIcon: LockIcon, + } satisfies ExtensionAnonLayoutWrapperData, children: [ { path: "", @@ -244,7 +246,6 @@ const initialData: ExtensionAnonLayoutWrapperData = { showAcctSwitcher: true, showBackButton: true, showLogo: true, - hideIcon: false, }; const changedData: ExtensionAnonLayoutWrapperData = { @@ -258,7 +259,6 @@ const changedData: ExtensionAnonLayoutWrapperData = { showAcctSwitcher: false, showBackButton: false, showLogo: false, - hideIcon: false, }; @Component({ @@ -337,9 +337,9 @@ export const HasLoggedInAccountExample: Story = { { path: "has-logged-in-account", data: { - hasLoggedInAccount: true, showAcctSwitcher: true, - }, + pageIcon: LockIcon, + } satisfies ExtensionAnonLayoutWrapperData, children: [ { path: "", diff --git a/apps/browser/src/popup/components/user-verification.component.html b/apps/browser/src/popup/components/user-verification.component.html deleted file mode 100644 index 25bd81cf394..00000000000 --- a/apps/browser/src/popup/components/user-verification.component.html +++ /dev/null @@ -1,47 +0,0 @@ - -
- - -
-
- -
- - - - - {{ "codeSent" | i18n }} - -
- -
- - -
-
diff --git a/apps/browser/src/popup/components/user-verification.component.ts b/apps/browser/src/popup/components/user-verification.component.ts deleted file mode 100644 index f6cb6cdff12..00000000000 --- a/apps/browser/src/popup/components/user-verification.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { animate, style, transition, trigger } from "@angular/animations"; -import { Component } from "@angular/core"; -import { NG_VALUE_ACCESSOR } from "@angular/forms"; - -import { UserVerificationComponent as BaseComponent } from "@bitwarden/angular/auth/components/user-verification.component"; -/** - * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead. - * Each client specific component should eventually be converted over to use one of these new components. - */ -@Component({ - selector: "app-user-verification", - templateUrl: "user-verification.component.html", - providers: [ - { - provide: NG_VALUE_ACCESSOR, - multi: true, - useExisting: UserVerificationComponent, - }, - ], - animations: [ - trigger("sent", [ - transition(":enter", [style({ opacity: 0 }), animate("100ms", style({ opacity: 1 }))]), - ]), - ], - standalone: false, -}) -export class UserVerificationComponent extends BaseComponent {} diff --git a/apps/browser/src/tools/popup/components/file-popout-callout.component.ts b/apps/browser/src/tools/popup/components/file-popout-callout.component.ts index f597998fa56..33044b79351 100644 --- a/apps/browser/src/tools/popup/components/file-popout-callout.component.ts +++ b/apps/browser/src/tools/popup/components/file-popout-callout.component.ts @@ -7,6 +7,8 @@ import { CalloutModule } from "@bitwarden/components"; import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; import { FilePopoutUtilsService } from "../services/file-popout-utils.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: "tools-file-popout-callout", templateUrl: "file-popout-callout.component.html", diff --git a/apps/browser/src/tools/popup/generator/credential-generator-history.component.ts b/apps/browser/src/tools/popup/generator/credential-generator-history.component.ts index 441e5d6e4c6..90a23c82330 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator-history.component.ts +++ b/apps/browser/src/tools/popup/generator/credential-generator-history.component.ts @@ -25,6 +25,8 @@ import { PopupFooterComponent } from "../../../platform/popup/layout/popup-foote import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.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({ selector: "app-credential-generator-history", templateUrl: "credential-generator-history.component.html", @@ -52,6 +54,8 @@ export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, O private logService: LogService, ) {} + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() account: Account | null; @@ -60,6 +64,8 @@ export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, O * * @warning this may reveal sensitive information in plaintext. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() debug: boolean = false; diff --git a/apps/browser/src/tools/popup/generator/credential-generator.component.ts b/apps/browser/src/tools/popup/generator/credential-generator.component.ts index b34c829b006..a69a7f52a6f 100644 --- a/apps/browser/src/tools/popup/generator/credential-generator.component.ts +++ b/apps/browser/src/tools/popup/generator/credential-generator.component.ts @@ -10,6 +10,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.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({ selector: "credential-generator", templateUrl: "credential-generator.component.html", diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts index 5911b3b6d89..3481ced35dc 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts @@ -60,6 +60,8 @@ export type AddEditQueryParams = Partial>; /** * Component for adding or editing a send item. */ +// 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: "tools-send-add-edit", templateUrl: "send-add-edit.component.html", diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts index 30359e98fa0..e9109ec6c21 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts @@ -20,6 +20,8 @@ import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-fo import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.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({ selector: "app-send-created", templateUrl: "./send-created.component.html", diff --git a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts index 26b3a2abbc7..56b8bcbb9f5 100644 --- a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts @@ -10,12 +10,16 @@ import { FilePopoutUtilsService } from "../../services/file-popout-utils.service import { SendFilePopoutDialogComponent } from "./send-file-popout-dialog.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({ selector: "send-file-popout-dialog-container", templateUrl: "./send-file-popout-dialog-container.component.html", imports: [JslibModule, CommonModule], }) export class SendFilePopoutDialogContainerComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals config = input.required(); constructor( diff --git a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog.component.ts b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog.component.ts index 64c95a2e2f7..23fa744995a 100644 --- a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog.component.ts @@ -6,6 +6,8 @@ import { ButtonModule, DialogModule, DialogService, TypographyModule } from "@bi import BrowserPopupUtils from "../../../../platform/browser/browser-popup-utils"; +// 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: "send-file-popout-dialog", templateUrl: "./send-file-popout-dialog.component.html", diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index a37c038d822..1272a86be17 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -39,6 +39,8 @@ export enum SendState { NoResults, } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "send-v2.component.html", providers: [ diff --git a/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts b/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts index 39bff089668..2fa401f0010 100644 --- a/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts +++ b/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts @@ -16,11 +16,15 @@ import { TypographyModule, } from "@bitwarden/components"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "about-dialog.component.html", imports: [CommonModule, JslibModule, DialogModule, ButtonModule, TypographyModule], }) export class AboutDialogComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("version") protected version!: ElementRef; protected year = new Date().getFullYear(); diff --git a/apps/browser/src/tools/popup/settings/about-page/about-page-v2.component.ts b/apps/browser/src/tools/popup/settings/about-page/about-page-v2.component.ts index 67212dc5c4a..2ef830d9d94 100644 --- a/apps/browser/src/tools/popup/settings/about-page/about-page-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/about-page/about-page-v2.component.ts @@ -29,6 +29,8 @@ const RateUrls = { [DeviceType.SafariExtension]: "https://apps.apple.com/app/bitwarden/id1352778147", }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "about-page-v2.component.html", imports: [ diff --git a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.ts b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.ts index 5aebee3b781..584c7cd3f7c 100644 --- a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.ts @@ -12,6 +12,8 @@ import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-fo import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.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({ templateUrl: "export-browser-v2.component.html", imports: [ diff --git a/apps/browser/src/tools/popup/settings/import/import-browser-v2.component.ts b/apps/browser/src/tools/popup/settings/import/import-browser-v2.component.ts index 6397ddc1850..a88cc3f81dc 100644 --- a/apps/browser/src/tools/popup/settings/import/import-browser-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/import/import-browser-v2.component.ts @@ -20,6 +20,8 @@ import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-fo import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.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({ templateUrl: "import-browser-v2.component.html", imports: [ diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.ts b/apps/browser/src/tools/popup/settings/settings-v2.component.ts index 205d4063b59..1c370381f54 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.ts @@ -24,6 +24,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.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({ templateUrl: "settings-v2.component.html", imports: [ diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 6d156dbbcec..0aa040bbcf1 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -591,6 +591,19 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "cipher" version = "0.4.4" @@ -904,6 +917,7 @@ dependencies = [ "byteorder", "bytes", "cbc", + "chacha20poly1305", "core-foundation", "desktop_objc", "dirs", @@ -923,6 +937,8 @@ dependencies = [ "secmem-proc", "security-framework", "security-framework-sys", + "serde", + "serde_json", "sha2", "ssh-encoding", "ssh-key", @@ -1817,13 +1833,14 @@ version = "0.0.0" dependencies = [ "desktop_core", "futures", - "log", "oslog", "serde", "serde_json", "tokio", "tokio-util", "tracing", + "tracing-oslog", + "tracing-subscriber", "uniffi", ] @@ -2569,6 +2586,7 @@ dependencies = [ "ctor 0.5.0", "desktop_core", "libc", + "tracing", ] [[package]] @@ -3413,6 +3431,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-oslog" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76902d2a8d5f9f55a81155c08971734071968c90f2d9bfe645fe700579b2950" +dependencies = [ + "cc", + "cfg-if", + "tracing-core", + "tracing-subscriber", +] + [[package]] name = "tracing-subscriber" version = "0.3.20" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 39c77f53254..855b0b3aa43 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -27,6 +27,7 @@ bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", re byteorder = "=1.5.0" bytes = "=1.10.1" cbc = "=0.1.2" +chacha20poly1305 = "=0.10.1" core-foundation = "=0.10.1" ctor = "=0.5.0" dirs = "=6.0.0" @@ -78,6 +79,10 @@ zbus_polkit = "=5.0.0" zeroizing-alloc = "=0.1.0" [workspace.lints.clippy] +# Dis-allow println and eprintln, which are typically used in debugging. +# Use `tracing` and `tracing-subscriber` crates for observability needs. +print_stderr = "deny" +print_stdout = "deny" +string_slice = "warn" unused_async = "deny" unwrap_used = "deny" -string_slice = "warn" diff --git a/apps/desktop/desktop_native/autotype/src/windows.rs b/apps/desktop/desktop_native/autotype/src/windows.rs index 1e125ef8e21..01270e7971d 100644 --- a/apps/desktop/desktop_native/autotype/src/windows.rs +++ b/apps/desktop/desktop_native/autotype/src/windows.rs @@ -331,7 +331,6 @@ mod tests { fn get_alphabetic_hot_key_happy() { for c in ('a'..='z').chain('A'..='Z') { let letter = c.to_string(); - println!("{}", letter); let converted = get_alphabetic_hotkey(letter).unwrap(); assert_eq!(converted, c as u16); } diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/metadata.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/metadata.rs index a95a86ef0e8..28f13cd9863 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/metadata.rs +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/metadata.rs @@ -173,6 +173,8 @@ mod tests { let map = get_supported_importers::(); let expected: HashSet = HashSet::from([ + "bravecsv".to_string(), + "chromecsv".to_string(), "chromiumcsv".to_string(), "edgecsv".to_string(), "operacsv".to_string(), @@ -192,7 +194,14 @@ mod tests { #[test] fn windows_specific_loaders_match_const_array() { let map = get_supported_importers::(); - let ids = ["chromiumcsv", "edgecsv", "operacsv", "vivaldicsv"]; + let ids = [ + "bravecsv", + "chromecsv", + "chromiumcsv", + "edgecsv", + "operacsv", + "vivaldicsv", + ]; for id in ids { let loaders = get_loaders(&map, id); diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs index b9c1c9a4cc2..096808aafb6 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs @@ -16,7 +16,15 @@ use crate::util; // // IMPORTANT adjust array size when enabling / disabling chromium importers here -pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [ +pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [ + BrowserConfig { + name: "Brave", + data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data", + }, + BrowserConfig { + name: "Chrome", + data_dir: "AppData/Local/Google/Chrome/User Data", + }, BrowserConfig { name: "Chromium", data_dir: "AppData/Local/Chromium/User Data", diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index 36e1a85abc0..b7e4c9b7a83 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -26,6 +26,7 @@ bitwarden-russh = { workspace = true } byteorder = { workspace = true } bytes = { workspace = true } cbc = { workspace = true, features = ["alloc"] } +chacha20poly1305 = { workspace = true } dirs = { workspace = true } ed25519 = { workspace = true, features = ["pkcs8"] } futures = { workspace = true } @@ -38,6 +39,8 @@ rsa = { workspace = true } russh-cryptovec = { workspace = true } scopeguard = { workspace = true } secmem-proc = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } sha2 = { workspace = true } ssh-encoding = { workspace = true } ssh-key = { workspace = true, features = [ @@ -64,6 +67,7 @@ windows = { workspace = true, features = [ "Storage_Streams", "Win32_Foundation", "Win32_Security_Credentials", + "Win32_Security_Cryptography", "Win32_System_WinRT", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs new file mode 100644 index 00000000000..e37a101e2ae --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs @@ -0,0 +1,33 @@ +use anyhow::Result; + +#[allow(clippy::module_inception)] +#[cfg_attr(target_os = "linux", path = "unimplemented.rs")] +#[cfg_attr(target_os = "macos", path = "unimplemented.rs")] +#[cfg_attr(target_os = "windows", path = "windows.rs")] +mod biometric_v2; + +#[cfg(target_os = "windows")] +pub mod windows_focus; + +pub use biometric_v2::BiometricLockSystem; + +#[allow(async_fn_in_trait)] +pub trait BiometricTrait: Send + Sync { + /// Authenticate the user + async fn authenticate(&self, hwnd: Vec, message: String) -> Result; + /// Check if biometric authentication is available + async fn authenticate_available(&self) -> Result; + /// Enroll a key for persistent unlock. If the implementation does not support persistent enrollment, + /// this function should do nothing. + async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()>; + /// Clear the persistent and ephemeral keys + async fn unenroll(&self, user_id: &str) -> Result<()>; + /// Check if a persistent (survives app restarts and reboots) key is set for a user + async fn has_persistent(&self, user_id: &str) -> Result; + /// Provide a key to be ephemerally held. This should be called on every unlock. + async fn provide_key(&self, user_id: &str, key: &[u8]); + /// Perform biometric unlock and return the key + async fn unlock(&self, user_id: &str, hwnd: Vec) -> Result>; + /// Check if biometric unlock is available based on whether a key is present and whether authentication is possible + async fn unlock_available(&self, user_id: &str) -> Result; +} diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs b/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs new file mode 100644 index 00000000000..1503cfea89c --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/unimplemented.rs @@ -0,0 +1,47 @@ +pub struct BiometricLockSystem {} + +impl BiometricLockSystem { + pub fn new() -> Self { + Self {} + } +} + +impl Default for BiometricLockSystem { + fn default() -> Self { + Self::new() + } +} + +impl super::BiometricTrait for BiometricLockSystem { + async fn authenticate(&self, _hwnd: Vec, _message: String) -> Result { + unimplemented!() + } + + async fn authenticate_available(&self) -> Result { + unimplemented!() + } + + async fn enroll_persistent(&self, _user_id: &str, _key: &[u8]) -> Result<(), anyhow::Error> { + unimplemented!() + } + + async fn provide_key(&self, _user_id: &str, _key: &[u8]) { + unimplemented!() + } + + async fn unlock(&self, _user_id: &str, _hwnd: Vec) -> Result, anyhow::Error> { + unimplemented!() + } + + async fn unlock_available(&self, _user_id: &str) -> Result { + unimplemented!() + } + + async fn has_persistent(&self, _user_id: &str) -> Result { + unimplemented!() + } + + async fn unenroll(&self, _user_id: &str) -> Result<(), anyhow::Error> { + unimplemented!() + } +} diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs new file mode 100644 index 00000000000..043c2453cd0 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs @@ -0,0 +1,506 @@ +//! This file implements Windows-Hello based biometric unlock. +//! +//! There are two paths implemented here. +//! The former via UV + ephemerally (but protected) keys. This only works after first unlock. +//! The latter via a signing API, that deterministically signs a challenge, from which a windows hello key is derived. This key +//! is used to encrypt the protected key. +//! +//! # Security +//! The security goal is that a locked vault - a running app - cannot be unlocked when the device (user-space) +//! is compromised in this state. +//! +//! ## UV path +//! When first unlocking the app, the app sends the user-key to this module, which holds it in secure memory, +//! protected by DPAPI. This makes it inaccessible to other processes, unless they compromise the system administrator, or kernel. +//! While the app is running this key is held in memory, even if locked. When unlocking, the app will prompt the user via +//! `windows_hello_authenticate` to get a yes/no decision on whether to release the key to the app. +//! Note: Further process isolation is needed here so that code cannot be injected into the running process, which may +//! circumvent DPAPI. +//! +//! ## Sign path +//! In this scenario, when enrolling, the app sends the user-key to this module, which derives the windows hello key +//! with the Windows Hello prompt. This is done by signing a per-user challenge, which produces a deterministic +//! signature which is hashed to obtain a key. This key is used to encrypt and persist the vault unlock key (user key). +//! +//! Since the keychain can be accessed by all user-space processes, the challenge is known to all userspace processes. +//! Therefore, to circumvent the security measure, the attacker would need to create a fake Windows-Hello prompt, and +//! get the user to confirm it. + +use std::sync::{atomic::AtomicBool, Arc}; +use tracing::{debug, warn}; + +use aes::cipher::KeyInit; +use anyhow::{anyhow, Result}; +use chacha20poly1305::{aead::Aead, XChaCha20Poly1305, XNonce}; +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; +use windows::{ + core::{factory, h, Interface, HSTRING}, + Security::{ + Credentials::{ + KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus, + UI::{ + UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability, + }, + }, + Cryptography::CryptographicBuffer, + }, + Storage::Streams::IBuffer, + Win32::{ + System::WinRT::{IBufferByteAccess, IUserConsentVerifierInterop}, + UI::WindowsAndMessaging::GetForegroundWindow, + }, +}; +use windows_future::IAsyncOperation; + +use super::windows_focus::{focus_security_prompt, restore_focus}; +use crate::{ + password::{self, PASSWORD_NOT_FOUND}, + secure_memory::*, +}; + +const KEYCHAIN_SERVICE_NAME: &str = "BitwardenBiometricsV2"; +const CREDENTIAL_NAME: &HSTRING = h!("BitwardenBiometricsV2"); +const CHALLENGE_LENGTH: usize = 16; +const XCHACHA20POLY1305_NONCE_LENGTH: usize = 24; +const XCHACHA20POLY1305_KEY_LENGTH: usize = 32; + +#[derive(serde::Serialize, serde::Deserialize)] +struct WindowsHelloKeychainEntry { + nonce: [u8; XCHACHA20POLY1305_NONCE_LENGTH], + challenge: [u8; CHALLENGE_LENGTH], + wrapped_key: Vec, +} + +/// The Windows OS implementation of the biometric trait. +pub struct BiometricLockSystem { + // The userkeys that are held in memory MUST be protected from memory dumping attacks, to ensure + // locked vaults cannot be unlocked + secure_memory: Arc>, +} + +impl BiometricLockSystem { + pub fn new() -> Self { + Self { + secure_memory: Arc::new(Mutex::new( + crate::secure_memory::dpapi::DpapiSecretKVStore::new(), + )), + } + } +} + +impl Default for BiometricLockSystem { + fn default() -> Self { + Self::new() + } +} + +impl super::BiometricTrait for BiometricLockSystem { + async fn authenticate(&self, _hwnd: Vec, message: String) -> Result { + windows_hello_authenticate(message).await + } + + async fn authenticate_available(&self) -> Result { + match UserConsentVerifier::CheckAvailabilityAsync()?.await? { + UserConsentVerifierAvailability::Available + | UserConsentVerifierAvailability::DeviceBusy => Ok(true), + _ => Ok(false), + } + } + + async fn unenroll(&self, user_id: &str) -> Result<()> { + self.secure_memory.lock().await.remove(user_id); + delete_keychain_entry(user_id).await + } + + async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()> { + // Enrollment works by first generating a random challenge unique to the user / enrollment. Then, + // with the challenge and a Windows-Hello prompt, the "windows hello key" is derived. The windows + // hello key is used to encrypt the key to store with XChaCha20Poly1305. The bundle of nonce, + // challenge and wrapped-key are stored to the keychain + + // Each enrollment (per user) has a unique challenge, so that the windows-hello key is unique + let challenge: [u8; CHALLENGE_LENGTH] = rand::random(); + + // This key is unique to the challenge + let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge).await?; + let (wrapped_key, nonce) = encrypt_data(&windows_hello_key, key)?; + + set_keychain_entry( + user_id, + &WindowsHelloKeychainEntry { + nonce, + challenge, + wrapped_key, + }, + ) + .await + } + + async fn provide_key(&self, user_id: &str, key: &[u8]) { + self.secure_memory + .lock() + .await + .put(user_id.to_string(), key); + } + + async fn unlock(&self, user_id: &str, _hwnd: Vec) -> Result> { + // Allow restoring focus to the previous window (browser) + let previous_active_window = super::windows_focus::get_active_window(); + let _focus_scopeguard = scopeguard::guard((), |_| { + if let Some(hwnd) = previous_active_window { + debug!("Restoring focus to previous window"); + restore_focus(hwnd.0); + } + }); + + let mut secure_memory = self.secure_memory.lock().await; + // If the key is held ephemerally, always use UV API. Only use signing API if the key is not held + // ephemerally but the keychain holds it persistently. + if secure_memory.has(user_id) { + if windows_hello_authenticate("Unlock your vault".to_string()).await? { + secure_memory + .get(user_id) + .clone() + .ok_or_else(|| anyhow!("No key found for user")) + } else { + Err(anyhow!("Authentication failed")) + } + } else { + let keychain_entry = get_keychain_entry(user_id).await?; + let windows_hello_key = + windows_hello_authenticate_with_crypto(&keychain_entry.challenge).await?; + let decrypted_key = decrypt_data( + &windows_hello_key, + &keychain_entry.wrapped_key, + &keychain_entry.nonce, + )?; + // The first unlock already sets the key for subsequent unlocks. The key may again be set externally after unlock finishes. + secure_memory.put(user_id.to_string(), &decrypted_key.clone()); + Ok(decrypted_key) + } + } + + async fn unlock_available(&self, user_id: &str) -> Result { + let secure_memory = self.secure_memory.lock().await; + let has_key = + secure_memory.has(user_id) || has_keychain_entry(user_id).await.unwrap_or(false); + Ok(has_key && self.authenticate_available().await.unwrap_or(false)) + } + + async fn has_persistent(&self, user_id: &str) -> Result { + Ok(get_keychain_entry(user_id).await.is_ok()) + } +} + +/// Get a yes/no authorization without any cryptographic backing. +/// This API has better focusing behavior +async fn windows_hello_authenticate(message: String) -> Result { + debug!( + "[Windows Hello] Authenticating to perform UV with message: {}", + message + ); + + let userconsent_result: IAsyncOperation = unsafe { + // Windows Hello prompt must be in foreground, focused, otherwise the face or fingerprint + // unlock will not work. We get the current foreground window, which will either be the + // Bitwarden desktop app or the browser extension. + let foreground_window = GetForegroundWindow(); + factory::()? + .RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))? + }; + + match userconsent_result.await? { + UserConsentVerificationResult::Verified => Ok(true), + _ => Ok(false), + } +} + +/// Derive the symmetric encryption key from the Windows Hello signature. +/// +/// This works by signing a static challenge string with Windows Hello protected key store. The +/// signed challenge is then hashed using SHA-256 and used as the symmetric encryption key for the +/// Windows Hello protected keys. +/// +/// Windows will only sign the challenge if the user has successfully authenticated with Windows, +/// ensuring user presence. +/// +/// Note: This API has inconsistent focusing behavior when called from another window +async fn windows_hello_authenticate_with_crypto( + challenge: &[u8; CHALLENGE_LENGTH], +) -> Result<[u8; XCHACHA20POLY1305_KEY_LENGTH]> { + debug!("[Windows Hello] Authenticating to sign challenge"); + + // Ugly hack: We need to focus the window via window focusing APIs until Microsoft releases a new API. + // This is unreliable, and if it does not work, the operation may fail + let stop_focusing = Arc::new(AtomicBool::new(false)); + let stop_focusing_clone = stop_focusing.clone(); + let _ = std::thread::spawn(move || loop { + if !stop_focusing_clone.load(std::sync::atomic::Ordering::Relaxed) { + focus_security_prompt(); + std::thread::sleep(std::time::Duration::from_millis(500)); + } else { + break; + } + }); + // Only stop focusing once this function exits. The focus MUST run both during the initial creation + // with RequestCreateAsync, and also with the subsequent use with RequestSignAsync. + let _guard = scopeguard::guard((), |_| { + stop_focusing.store(true, std::sync::atomic::Ordering::Relaxed); + }); + + // First create or replace the Bitwarden Biometrics signing key + let credential = { + let key_credential_creation_result = KeyCredentialManager::RequestCreateAsync( + CREDENTIAL_NAME, + KeyCredentialCreationOption::FailIfExists, + )? + .await?; + match key_credential_creation_result.Status()? { + KeyCredentialStatus::CredentialAlreadyExists => { + KeyCredentialManager::OpenAsync(CREDENTIAL_NAME)?.await? + } + KeyCredentialStatus::Success => key_credential_creation_result, + _ => return Err(anyhow!("Failed to create key credential")), + } + } + .Credential()?; + + let signature = { + let sign_operation = credential.RequestSignAsync( + &CryptographicBuffer::CreateFromByteArray(challenge.as_slice())?, + )?; + + // We need to drop the credential here to avoid holding it across an await point. + drop(credential); + sign_operation.await? + }; + + if signature.Status()? != KeyCredentialStatus::Success { + return Err(anyhow!("Failed to sign data")); + } + + let signature_buffer = signature.Result()?; + let signature_value = unsafe { as_mut_bytes(&signature_buffer)? }; + + // The signature is deterministic based on the challenge and keychain key. Thus, it can be hashed to a key. + // It is unclear what entropy this key provides. + let windows_hello_key = Sha256::digest(signature_value).into(); + Ok(windows_hello_key) +} + +async fn set_keychain_entry(user_id: &str, entry: &WindowsHelloKeychainEntry) -> Result<()> { + password::set_password( + KEYCHAIN_SERVICE_NAME, + user_id, + &serde_json::to_string(entry)?, + ) + .await +} + +async fn get_keychain_entry(user_id: &str) -> Result { + serde_json::from_str(&password::get_password(KEYCHAIN_SERVICE_NAME, user_id).await?) + .map_err(|e| anyhow!(e)) +} + +async fn delete_keychain_entry(user_id: &str) -> Result<()> { + password::delete_password(KEYCHAIN_SERVICE_NAME, user_id) + .await + .or_else(|e| { + if e.to_string() == PASSWORD_NOT_FOUND { + debug!( + "[Windows Hello] No keychain entry found for user {}, nothing to delete", + user_id + ); + Ok(()) + } else { + Err(e) + } + }) +} + +async fn has_keychain_entry(user_id: &str) -> Result { + password::get_password(KEYCHAIN_SERVICE_NAME, user_id) + .await + .map(|entry| !entry.is_empty()) + .or_else(|e| { + if e.to_string() == PASSWORD_NOT_FOUND { + Ok(false) + } else { + warn!( + "[Windows Hello] Error checking keychain entry for user {}: {}", + user_id, e + ); + Err(e) + } + }) +} + +/// Encrypt data with XChaCha20Poly1305 +fn encrypt_data( + key: &[u8; XCHACHA20POLY1305_KEY_LENGTH], + plaintext: &[u8], +) -> Result<(Vec, [u8; XCHACHA20POLY1305_NONCE_LENGTH])> { + let cipher = XChaCha20Poly1305::new(key.into()); + let mut nonce = [0u8; XCHACHA20POLY1305_NONCE_LENGTH]; + rand::fill(&mut nonce); + let ciphertext = cipher + .encrypt(XNonce::from_slice(&nonce), plaintext) + .map_err(|e| anyhow!(e))?; + Ok((ciphertext, nonce)) +} + +/// Decrypt data with XChaCha20Poly1305 +fn decrypt_data( + key: &[u8; XCHACHA20POLY1305_KEY_LENGTH], + ciphertext: &[u8], + nonce: &[u8; XCHACHA20POLY1305_NONCE_LENGTH], +) -> Result> { + let cipher = XChaCha20Poly1305::new(key.into()); + let plaintext = cipher + .decrypt(XNonce::from_slice(nonce), ciphertext) + .map_err(|e| anyhow!(e))?; + Ok(plaintext) +} + +unsafe fn as_mut_bytes(buffer: &IBuffer) -> Result<&mut [u8]> { + let interop = buffer.cast::()?; + + unsafe { + let data = interop.Buffer()?; + Ok(std::slice::from_raw_parts_mut( + data, + buffer.Length()? as usize, + )) + } +} + +#[cfg(test)] +#[allow(clippy::print_stdout)] +mod tests { + use crate::biometric_v2::{ + biometric_v2::{ + decrypt_data, encrypt_data, has_keychain_entry, windows_hello_authenticate, + windows_hello_authenticate_with_crypto, CHALLENGE_LENGTH, XCHACHA20POLY1305_KEY_LENGTH, + }, + BiometricLockSystem, BiometricTrait, + }; + + #[test] + fn test_encrypt_decrypt() { + let key = [0u8; 32]; + let plaintext = b"Test data"; + let (ciphertext, nonce) = encrypt_data(&key, plaintext).unwrap(); + let decrypted = decrypt_data(&key, &ciphertext, &nonce).unwrap(); + assert_eq!(plaintext.to_vec(), decrypted); + } + + #[tokio::test] + async fn test_has_keychain_entry_no_entry() { + let user_id = "test_user"; + let has_entry = has_keychain_entry(user_id).await.unwrap(); + assert!(!has_entry); + } + + // Note: These tests are ignored because they require manual intervention to run + + #[tokio::test] + #[ignore] + async fn test_windows_hello_authenticate_with_crypto_manual() { + let challenge = [0u8; CHALLENGE_LENGTH]; + let windows_hello_key = windows_hello_authenticate_with_crypto(&challenge) + .await + .unwrap(); + println!( + "Windows hello key {:?} for challenge {:?}", + windows_hello_key, challenge + ); + } + + #[tokio::test] + #[ignore] + async fn test_windows_hello_authenticate() { + let authenticated = + windows_hello_authenticate("Test Windows Hello authentication".to_string()) + .await + .unwrap(); + println!("Windows Hello authentication result: {:?}", authenticated); + } + + #[tokio::test] + #[ignore] + async fn test_double_unenroll() { + let user_id = "test_user"; + let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH]; + rand::fill(&mut key); + + let windows_hello_lock_system = BiometricLockSystem::new(); + + println!("Enrolling user"); + windows_hello_lock_system + .enroll_persistent(user_id, &key) + .await + .unwrap(); + assert!(windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + + println!("Unlocking user"); + let key_after_unlock = windows_hello_lock_system + .unlock(user_id, Vec::new()) + .await + .unwrap(); + assert_eq!(key_after_unlock, key); + + println!("Unenrolling user"); + windows_hello_lock_system.unenroll(user_id).await.unwrap(); + assert!(!windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + + println!("Unenrolling user again"); + + // This throws PASSWORD_NOT_FOUND but our code should handle that and not throw. + windows_hello_lock_system.unenroll(user_id).await.unwrap(); + assert!(!windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + } + + #[tokio::test] + #[ignore] + async fn test_enroll_unlock_unenroll() { + let user_id = "test_user"; + let mut key = [0u8; XCHACHA20POLY1305_KEY_LENGTH]; + rand::fill(&mut key); + + let windows_hello_lock_system = BiometricLockSystem::new(); + + println!("Enrolling user"); + windows_hello_lock_system + .enroll_persistent(user_id, &key) + .await + .unwrap(); + assert!(windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + + println!("Unlocking user"); + let key_after_unlock = windows_hello_lock_system + .unlock(user_id, Vec::new()) + .await + .unwrap(); + assert_eq!(key_after_unlock, key); + + println!("Unenrolling user"); + windows_hello_lock_system.unenroll(user_id).await.unwrap(); + assert!(!windows_hello_lock_system + .has_persistent(user_id) + .await + .unwrap()); + } +} diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs b/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs new file mode 100644 index 00000000000..f3ffb6e4ebe --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs @@ -0,0 +1,100 @@ +use windows::{ + core::s, + Win32::{ + Foundation::HWND, + System::Threading::{AttachThreadInput, GetCurrentThreadId}, + UI::{ + Input::KeyboardAndMouse::{EnableWindow, SetActiveWindow, SetCapture, SetFocus}, + WindowsAndMessaging::{ + BringWindowToTop, FindWindowA, GetForegroundWindow, GetWindowThreadProcessId, + SetForegroundWindow, SwitchToThisWindow, SystemParametersInfoW, SPIF_SENDCHANGE, + SPIF_UPDATEINIFILE, SPI_GETFOREGROUNDLOCKTIMEOUT, SPI_SETFOREGROUNDLOCKTIMEOUT, + }, + }, + }, +}; + +pub(crate) struct HwndHolder(pub(crate) HWND); +unsafe impl Send for HwndHolder {} + +pub(crate) fn get_active_window() -> Option { + unsafe { Some(HwndHolder(GetForegroundWindow())) } +} + +/// Searches for a window that looks like a security prompt and set it as focused. +/// Only works when the process has permission to foreground, either by being in foreground +/// Or by being given foreground permission https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks +pub fn focus_security_prompt() { + let hwnd_result = unsafe { FindWindowA(s!("Credential Dialog Xaml Host"), None) }; + if let Ok(hwnd) = hwnd_result { + set_focus(hwnd); + } +} + +/// Sets focus to a window using a few unstable methods +fn set_focus(hwnd: HWND) { + unsafe { + // Windows REALLY does not like apps stealing focus, even if it is for fixing Windows-Hello bugs. + // The windows hello signing prompt NEEDS to be focused instantly, or it will error, but it does + // not focus itself. + + // This function implements forced focusing of windows using a few hacks. + // The conditions to successfully foreground a window are: + // All of the following conditions are true: + // The calling process belongs to a desktop application, not a UWP app or a Windows Store app designed for Windows 8 or 8.1. + // The foreground process has not disabled calls to SetForegroundWindow by a previous call to the LockSetForegroundWindow function. + // The foreground lock time-out has expired (see SPI_GETFOREGROUNDLOCKTIMEOUT in SystemParametersInfo). + // No menus are active. + // Additionally, at least one of the following conditions is true: + // The calling process is the foreground process. + // The calling process was started by the foreground process. + // There is currently no foreground window, and thus no foreground process. + // The calling process received the last input event. + // Either the foreground process or the calling process is being debugged. + + // Update the foreground lock timeout temporarily + let mut old_timeout = 0; + let _ = SystemParametersInfoW( + SPI_GETFOREGROUNDLOCKTIMEOUT, + 0, + Some(&mut old_timeout as *mut _ as *mut std::ffi::c_void), + windows::Win32::UI::WindowsAndMessaging::SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0), + ); + let _ = SystemParametersInfoW( + SPI_SETFOREGROUNDLOCKTIMEOUT, + 0, + None, + SPIF_UPDATEINIFILE | SPIF_SENDCHANGE, + ); + let _scopeguard = scopeguard::guard((), |_| { + let _ = SystemParametersInfoW( + SPI_SETFOREGROUNDLOCKTIMEOUT, + old_timeout, + None, + SPIF_UPDATEINIFILE | SPIF_SENDCHANGE, + ); + }); + + // Attach to the foreground thread once attached, we can foreground, even if in the background + let dw_current_thread = GetCurrentThreadId(); + let dw_fg_thread = GetWindowThreadProcessId(GetForegroundWindow(), None); + + let _ = AttachThreadInput(dw_current_thread, dw_fg_thread, true); + let _ = SetForegroundWindow(hwnd); + SetCapture(hwnd); + let _ = SetFocus(Some(hwnd)); + let _ = SetActiveWindow(hwnd); + let _ = EnableWindow(hwnd, true); + let _ = BringWindowToTop(hwnd); + SwitchToThisWindow(hwnd, true); + let _ = AttachThreadInput(dw_current_thread, dw_fg_thread, false); + } +} + +/// When restoring focus to the application window, we need a less aggressive method so the electron window doesn't get frozen. +pub(crate) fn restore_focus(hwnd: HWND) { + unsafe { + let _ = SetForegroundWindow(hwnd); + let _ = SetFocus(Some(hwnd)); + } +} diff --git a/apps/desktop/desktop_native/core/src/lib.rs b/apps/desktop/desktop_native/core/src/lib.rs index a72ec04e9c2..668badb95ed 100644 --- a/apps/desktop/desktop_native/core/src/lib.rs +++ b/apps/desktop/desktop_native/core/src/lib.rs @@ -1,13 +1,15 @@ pub mod autofill; pub mod autostart; pub mod biometric; +pub mod biometric_v2; pub mod clipboard; -pub mod crypto; +pub(crate) mod crypto; pub mod error; pub mod ipc; pub mod password; pub mod powermonitor; pub mod process_isolation; +pub(crate) mod secure_memory; pub mod ssh_agent; use zeroizing_alloc::ZeroAlloc; diff --git a/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs new file mode 100644 index 00000000000..ca9b6081d69 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs @@ -0,0 +1,134 @@ +use std::collections::HashMap; + +use windows::Win32::Security::Cryptography::{ + CryptProtectMemory, CryptUnprotectMemory, CRYPTPROTECTMEMORY_BLOCK_SIZE, + CRYPTPROTECTMEMORY_SAME_PROCESS, +}; + +use crate::secure_memory::SecureMemoryStore; + +/// https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata +/// The DPAPI store encrypts data using the Windows Data Protection API (DPAPI). The key is bound +/// to the current process, and cannot be decrypted by other user-mode processes. +/// +/// Note: Admin processes can still decrypt this memory: +/// https://blog.slowerzs.net/posts/cryptdecryptmemory/ +pub(crate) struct DpapiSecretKVStore { + map: HashMap>, +} + +impl DpapiSecretKVStore { + pub(crate) fn new() -> Self { + DpapiSecretKVStore { + map: HashMap::new(), + } + } +} + +impl SecureMemoryStore for DpapiSecretKVStore { + fn put(&mut self, key: String, value: &[u8]) { + let length_header_len = std::mem::size_of::(); + + // The allocated data has to be a multiple of CRYPTPROTECTMEMORY_BLOCK_SIZE, so we pad it and write the length in front + // We are storing LENGTH|DATA|00..00, where LENGTH is the length of DATA, the total length is a multiple + // of CRYPTPROTECTMEMORY_BLOCK_SIZE, and the padding is filled with zeros. + + let data_len = value.len(); + let len_with_header = data_len + length_header_len; + let padded_length = len_with_header + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize + - (len_with_header % CRYPTPROTECTMEMORY_BLOCK_SIZE as usize); + let mut padded_data = vec![0u8; padded_length]; + padded_data[..length_header_len].copy_from_slice(&data_len.to_le_bytes()); + padded_data[length_header_len..][..data_len].copy_from_slice(value); + + // Protect the memory using DPAPI + unsafe { + CryptProtectMemory( + padded_data.as_mut_ptr() as *mut core::ffi::c_void, + padded_length as u32, + CRYPTPROTECTMEMORY_SAME_PROCESS, + ) + } + .expect("crypt_protect_memory should work"); + + self.map.insert(key, padded_data); + } + + fn get(&self, key: &str) -> Option> { + self.map.get(key).map(|data| { + // A copy is created, that is then mutated by the DPAPI unprotect function. + let mut data = data.clone(); + unsafe { + CryptUnprotectMemory( + data.as_mut_ptr() as *mut core::ffi::c_void, + data.len() as u32, + CRYPTPROTECTMEMORY_SAME_PROCESS, + ) + } + .expect("crypt_unprotect_memory should work"); + + // Unpad the data to retrieve the original value + let length_header_size = std::mem::size_of::(); + let length_bytes = &data[..length_header_size]; + let data_length = usize::from_le_bytes( + length_bytes + .try_into() + .expect("length header should be usize"), + ); + + data[length_header_size..length_header_size + data_length].to_vec() + }) + } + + fn has(&self, key: &str) -> bool { + self.map.contains_key(key) + } + + fn remove(&mut self, key: &str) { + self.map.remove(key); + } + + fn clear(&mut self) { + self.map.clear(); + } +} + +impl Drop for DpapiSecretKVStore { + fn drop(&mut self) { + self.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dpapi_secret_kv_store_various_sizes() { + let mut store = DpapiSecretKVStore::new(); + for size in 0..=2048 { + let key = format!("test_key_{}", size); + let value: Vec = (0..size).map(|i| (i % 256) as u8).collect(); + store.put(key.clone(), &value); + assert!(store.has(&key), "Store should have key for size {}", size); + assert_eq!( + store.get(&key), + Some(value), + "Value mismatch for size {}", + size + ); + } + } + + #[test] + fn test_dpapi_crud() { + let mut store = DpapiSecretKVStore::new(); + let key = "test_key".to_string(); + let value = vec![1, 2, 3, 4, 5]; + store.put(key.clone(), &value); + assert!(store.has(&key)); + assert_eq!(store.get(&key), Some(value)); + store.remove(&key); + assert!(!store.has(&key)); + } +} diff --git a/apps/desktop/desktop_native/core/src/secure_memory/mod.rs b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs new file mode 100644 index 00000000000..0cb604e03f2 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs @@ -0,0 +1,22 @@ +#[cfg(target_os = "windows")] +pub(crate) mod dpapi; + +/// The secure memory store provides an ephemeral key-value store for sensitive data. +/// Data stored in this store is prevented from being swapped to disk and zeroed out. Additionally, +/// platform-specific protections are applied to prevent memory dumps or debugger access from +/// reading the stored values. +#[allow(unused)] +pub(crate) trait SecureMemoryStore { + /// Stores a copy of the provided value in secure memory. + fn put(&mut self, key: String, value: &[u8]); + /// Retrieves a copy of the value associated with the given key from secure memory. + /// This copy does not have additional memory protections applied, and should be zeroed when no + /// longer needed. + fn get(&self, key: &str) -> Option>; + /// Checks if a value is stored under the given key. + fn has(&self, key: &str) -> bool; + /// Removes the value associated with the given key from secure memory. + fn remove(&mut self, key: &str); + /// Clears all values stored in secure memory. + fn clear(&mut self); +} diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs index 3440a0114ae..61cb8fc187d 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -1,6 +1,9 @@ -use std::sync::{ - atomic::{AtomicBool, AtomicU32}, - Arc, +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, AtomicU32}, + Arc, RwLock, + }, }; use base64::{engine::general_purpose::STANDARD, Engine as _}; @@ -25,8 +28,8 @@ pub mod peerinfo; mod request_parser; #[derive(Clone)] -pub struct BitwardenDesktopAgent { - keystore: ssh_agent::KeyStore, +pub struct BitwardenDesktopAgent { + keystore: ssh_agent::KeyStore, cancellation_token: CancellationToken, show_ui_request_tx: tokio::sync::mpsc::Sender, get_ui_response_rx: Arc>>, @@ -77,9 +80,7 @@ impl SshKey for BitwardenSshKey { } } -impl ssh_agent::Agent - for BitwardenDesktopAgent -{ +impl ssh_agent::Agent for BitwardenDesktopAgent { async fn confirm( &self, ssh_key: BitwardenSshKey, @@ -179,7 +180,23 @@ impl ssh_agent::Agent } } -impl BitwardenDesktopAgent { +impl BitwardenDesktopAgent { + /// Create a new `BitwardenDesktopAgent` from the provided auth channel handles. + pub fn new( + auth_request_tx: tokio::sync::mpsc::Sender, + auth_response_rx: Arc>>, + ) -> Self { + Self { + keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))), + cancellation_token: CancellationToken::new(), + show_ui_request_tx: auth_request_tx, + get_ui_response_rx: auth_response_rx, + request_id: Arc::new(AtomicU32::new(0)), + needs_unlock: Arc::new(AtomicBool::new(true)), + is_running: Arc::new(AtomicBool::new(false)), + } + } + pub fn stop(&self) { if !self.is_running() { error!("Tried to stop agent while it is not running"); diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs index 813ebd61cc1..a45c2f6c0bf 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs @@ -1,94 +1,53 @@ -use std::{ - collections::HashMap, - fs, - os::unix::fs::PermissionsExt, - sync::{ - atomic::{AtomicBool, AtomicU32}, - Arc, RwLock, - }, -}; +use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc}; +use anyhow::anyhow; use bitwarden_russh::ssh_agent; use homedir::my_home; use tokio::{net::UnixListener, sync::Mutex}; -use tokio_util::sync::CancellationToken; use tracing::{error, info}; use crate::ssh_agent::peercred_unix_listener_stream::PeercredUnixListenerStream; -use super::{BitwardenDesktopAgent, BitwardenSshKey, SshAgentUIRequest}; +use super::{BitwardenDesktopAgent, SshAgentUIRequest}; -impl BitwardenDesktopAgent { +/// User can override the default socket path with this env var +const ENV_BITWARDEN_SSH_AUTH_SOCK: &str = "BITWARDEN_SSH_AUTH_SOCK"; + +const FLATPAK_DATA_DIR: &str = ".var/app/com.bitwarden.desktop/data"; + +const SOCKFILE_NAME: &str = ".bitwarden-ssh-agent.sock"; + +impl BitwardenDesktopAgent { + /// Starts the Bitwarden Desktop SSH Agent server. + /// # Errors + /// Will return `Err` if unable to create and set permissions for socket file path or + /// if unable to bind to the socket path. pub fn start_server( auth_request_tx: tokio::sync::mpsc::Sender, auth_response_rx: Arc>>, ) -> Result { - let agent = BitwardenDesktopAgent { - keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))), - cancellation_token: CancellationToken::new(), - show_ui_request_tx: auth_request_tx, - get_ui_response_rx: auth_response_rx, - request_id: Arc::new(AtomicU32::new(0)), - needs_unlock: Arc::new(AtomicBool::new(true)), - is_running: Arc::new(AtomicBool::new(false)), - }; - let cloned_agent_state = agent.clone(); - tokio::spawn(async move { - let ssh_path = match std::env::var("BITWARDEN_SSH_AUTH_SOCK") { - Ok(path) => path, - Err(_) => { - info!("BITWARDEN_SSH_AUTH_SOCK not set, using default path"); + let agent_state = BitwardenDesktopAgent::new(auth_request_tx, auth_response_rx); - let ssh_agent_directory = match my_home() { - Ok(Some(home)) => home, - _ => { - info!("Could not determine home directory"); - return; - } - }; + let socket_path = get_socket_path()?; - let is_flatpak = std::env::var("container") == Ok("flatpak".to_string()); - if !is_flatpak { - ssh_agent_directory - .join(".bitwarden-ssh-agent.sock") - .to_str() - .expect("Path should be valid") - .to_owned() - } else { - ssh_agent_directory - .join(".var/app/com.bitwarden.desktop/data/.bitwarden-ssh-agent.sock") - .to_str() - .expect("Path should be valid") - .to_owned() - } - } - }; + // if the socket is already present and wasn't cleanly removed during a previous + // runtime, remove it before beginning anew. + remove_path(&socket_path)?; - info!(socket = %ssh_path, "Starting SSH Agent server"); - let sockname = std::path::Path::new(&ssh_path); - if let Err(e) = std::fs::remove_file(sockname) { - error!(error = %e, socket = %ssh_path, "Could not remove existing socket file"); - if e.kind() != std::io::ErrorKind::NotFound { - return; - } - } + info!(?socket_path, "Starting SSH Agent server"); - match UnixListener::bind(sockname) { - Ok(listener) => { - // Only the current user should be able to access the socket - if let Err(e) = fs::set_permissions(sockname, fs::Permissions::from_mode(0o600)) - { - error!(error = %e, socket = ?sockname, "Could not set socket permissions"); - return; - } + match UnixListener::bind(socket_path.clone()) { + Ok(listener) => { + // Only the current user should be able to access the socket + set_user_permissions(&socket_path)?; - let stream = PeercredUnixListenerStream::new(listener); + let stream = PeercredUnixListenerStream::new(listener); - let cloned_keystore = cloned_agent_state.keystore.clone(); - let cloned_cancellation_token = cloned_agent_state.cancellation_token.clone(); - cloned_agent_state - .is_running - .store(true, std::sync::atomic::Ordering::Relaxed); + let cloned_agent_state = agent_state.clone(); + let cloned_keystore = cloned_agent_state.keystore.clone(); + let cloned_cancellation_token = cloned_agent_state.cancellation_token.clone(); + + tokio::spawn(async move { let _ = ssh_agent::serve( stream, cloned_agent_state.clone(), @@ -96,17 +55,132 @@ impl BitwardenDesktopAgent { cloned_cancellation_token, ) .await; + cloned_agent_state .is_running .store(false, std::sync::atomic::Ordering::Relaxed); - info!("SSH Agent server exited"); - } - Err(e) => { - error!(error = %e, socket = %ssh_path, "Unable to start start agent server"); - } - } - }); - Ok(agent) + info!("SSH Agent server exited"); + }); + + agent_state + .is_running + .store(true, std::sync::atomic::Ordering::Relaxed); + + info!(?socket_path, "SSH Agent is running."); + } + Err(error) => { + error!(%error, ?socket_path, "Unable to start start agent server"); + return Err(error.into()); + } + } + + Ok(agent_state) + } +} + +// one of the following: +// - only the env var socket path if it is defined +// - the $HOME path and our well known extension +fn get_socket_path() -> Result { + if let Ok(path) = std::env::var(ENV_BITWARDEN_SSH_AUTH_SOCK) { + Ok(PathBuf::from(path)) + } else { + info!("BITWARDEN_SSH_AUTH_SOCK not set, using default path"); + get_default_socket_path() + } +} + +fn is_flatpak() -> bool { + std::env::var("container") == Ok("flatpak".to_string()) +} + +// use the $HOME directory +fn get_default_socket_path() -> Result { + let Ok(Some(mut ssh_agent_directory)) = my_home() else { + error!("Could not determine home directory"); + return Err(anyhow!("Could not determine home directory.")); + }; + + if is_flatpak() { + ssh_agent_directory = ssh_agent_directory.join(FLATPAK_DATA_DIR); + } + + ssh_agent_directory = ssh_agent_directory.join(SOCKFILE_NAME); + + Ok(ssh_agent_directory) +} + +fn set_user_permissions(path: &PathBuf) -> Result<(), anyhow::Error> { + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .map_err(|e| anyhow!("Could not set socket permissions for {path:?}: {e}")) +} + +// try to remove the given path if it exists +fn remove_path(path: &PathBuf) -> Result<(), anyhow::Error> { + if let Ok(true) = std::fs::exists(path) { + std::fs::remove_file(path).map_err(|e| anyhow!("Error removing socket {path:?}: {e}"))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use rand::{distr::Alphanumeric, Rng}; + + use super::*; + + #[test] + fn test_default_socket_path_success() { + let path = get_default_socket_path().unwrap(); + let expected = PathBuf::from_iter([ + std::env::var("HOME").unwrap(), + ".bitwarden-ssh-agent.sock".to_string(), + ]); + assert_eq!(path, expected); + } + + fn rand_file_in_temp() -> PathBuf { + let mut path = std::env::temp_dir(); + let s: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect(); + path.push(s); + path + } + + #[test] + fn test_remove_path_exists_success() { + let path = rand_file_in_temp(); + fs::write(&path, "").unwrap(); + remove_path(&path).unwrap(); + + assert!(!fs::exists(&path).unwrap()); + } + + // the remove_path should not fail if the path does not exist + #[test] + fn test_remove_path_not_found_success() { + let path = rand_file_in_temp(); + remove_path(&path).unwrap(); + + assert!(!fs::exists(&path).unwrap()); + } + + #[test] + fn test_sock_path_file_permissions() { + let path = rand_file_in_temp(); + fs::write(&path, "").unwrap(); + + set_user_permissions(&path).unwrap(); + + let metadata = fs::metadata(&path).unwrap(); + let permissions = metadata.permissions().mode(); + + assert_eq!(permissions, 0o100_600); + + remove_path(&path).unwrap(); } } diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs b/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs index 75c47165960..662a4658ede 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs @@ -1,32 +1,18 @@ use bitwarden_russh::ssh_agent; pub mod named_pipe_listener_stream; -use std::{ - collections::HashMap, - sync::{ - atomic::{AtomicBool, AtomicU32}, - Arc, RwLock, - }, -}; +use std::sync::Arc; use tokio::sync::Mutex; -use tokio_util::sync::CancellationToken; -use super::{BitwardenDesktopAgent, BitwardenSshKey, SshAgentUIRequest}; +use super::{BitwardenDesktopAgent, SshAgentUIRequest}; -impl BitwardenDesktopAgent { +impl BitwardenDesktopAgent { pub fn start_server( auth_request_tx: tokio::sync::mpsc::Sender, auth_response_rx: Arc>>, ) -> Result { - let agent_state = BitwardenDesktopAgent { - keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))), - show_ui_request_tx: auth_request_tx, - get_ui_response_rx: auth_response_rx, - cancellation_token: CancellationToken::new(), - request_id: Arc::new(AtomicU32::new(0)), - needs_unlock: Arc::new(AtomicBool::new(true)), - is_running: Arc::new(AtomicBool::new(true)), - }; + let agent_state = BitwardenDesktopAgent::new(auth_request_tx, auth_response_rx); + let stream = named_pipe_listener_stream::NamedPipeServerStream::new( agent_state.cancellation_token.clone(), agent_state.is_running.clone(), diff --git a/apps/desktop/desktop_native/deny.toml b/apps/desktop/desktop_native/deny.toml new file mode 100644 index 00000000000..7d7a126f694 --- /dev/null +++ b/apps/desktop/desktop_native/deny.toml @@ -0,0 +1,40 @@ +# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html +[advisories] +ignore = [ + # Vulnerability in `rsa` crate: https://rustsec.org/advisories/RUSTSEC-2023-0071.html + { id = "RUSTSEC-2023-0071", reason = "There is no fix available yet." }, + { id = "RUSTSEC-2024-0436", reason = "paste crate is unmaintained."} +] + +# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html +[licenses] +# See https://spdx.org/licenses/ for list of possible licenses +allow = [ + "0BSD", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Zlib", +] + + +[licenses.private] +# If true, ignores workspace crates that aren't published, or are only +# published to private registries. +# To see how to mark a crate as unpublished (to the official registry), +# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. +ignore = true + +# This section is considered when running `cargo deny check bans`. +# More documentation about the 'bans' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html +[bans] +deny = [ +# TODO: enable after https://github.com/bitwarden/clients/pull/16761 is merged +# { name = "log", wrappers = [], reason = "Use `tracing` and `tracing-subscriber` for observability needs." }, +] diff --git a/apps/desktop/desktop_native/macos_provider/Cargo.toml b/apps/desktop/desktop_native/macos_provider/Cargo.toml index 9f042209b06..97a8b7d545a 100644 --- a/apps/desktop/desktop_native/macos_provider/Cargo.toml +++ b/apps/desktop/desktop_native/macos_provider/Cargo.toml @@ -16,12 +16,13 @@ bench = false [dependencies] desktop_core = { path = "../core" } futures = { workspace = true } -log = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["sync"] } tokio-util = { workspace = true } tracing = { workspace = true } +tracing-oslog = "0.3.0" +tracing-subscriber = { workspace = true } uniffi = { workspace = true, features = ["cli"] } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/apps/desktop/desktop_native/macos_provider/src/lib.rs b/apps/desktop/desktop_native/macos_provider/src/lib.rs index ded133bcb54..789a56d3048 100644 --- a/apps/desktop/desktop_native/macos_provider/src/lib.rs +++ b/apps/desktop/desktop_native/macos_provider/src/lib.rs @@ -2,13 +2,18 @@ use std::{ collections::HashMap, - sync::{atomic::AtomicU32, Arc, Mutex}, + sync::{atomic::AtomicU32, Arc, Mutex, Once}, time::Instant, }; use futures::FutureExt; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tracing::{error, info}; +use tracing_subscriber::{ + filter::{EnvFilter, LevelFilter}, + layer::SubscriberExt, + util::SubscriberInitExt, +}; uniffi::setup_scaffolding!(); @@ -21,6 +26,8 @@ use assertion::{ }; use registration::{PasskeyRegistrationRequest, PreparePasskeyRegistrationCallback}; +static INIT: Once = Once::new(); + #[derive(uniffi::Enum, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum UserVerification { @@ -65,9 +72,20 @@ impl MacOSProviderClient { #[allow(clippy::unwrap_used)] #[uniffi::constructor] pub fn connect() -> Self { - let _ = oslog::OsLogger::new("com.bitwarden.desktop.autofill-extension") - .level_filter(log::LevelFilter::Trace) - .init(); + INIT.call_once(|| { + let filter = EnvFilter::builder() + // Everything logs at `INFO` + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(); + + tracing_subscriber::registry() + .with(filter) + .with(tracing_oslog::OsLogger::new( + "com.bitwarden.desktop.autofill-extension", + "default", + )) + .init(); + }); let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32); let (to_server_send, to_server_recv) = tokio::sync::mpsc::channel(32); diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index c822017c1a2..59751cd3246 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -58,6 +58,18 @@ export declare namespace biometrics { ivB64: string } } +export declare namespace biometrics_v2 { + export function initBiometricSystem(): BiometricLockSystem + export function authenticate(biometricLockSystem: BiometricLockSystem, hwnd: Buffer, message: string): Promise + export function authenticateAvailable(biometricLockSystem: BiometricLockSystem): Promise + export function enrollPersistent(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise + export function provideKey(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise + export function unlock(biometricLockSystem: BiometricLockSystem, userId: string, hwnd: Buffer): Promise + export function unlockAvailable(biometricLockSystem: BiometricLockSystem, userId: string): Promise + export function hasPersistent(biometricLockSystem: BiometricLockSystem, userId: string): Promise + export function unenroll(biometricLockSystem: BiometricLockSystem, userId: string): Promise + export class BiometricLockSystem { } +} export declare namespace clipboards { export function read(): Promise export function write(text: string, password: boolean): Promise diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 3e6a5f00ae2..a193e44d6df 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -149,6 +149,123 @@ pub mod biometrics { } } +#[napi] +pub mod biometrics_v2 { + use desktop_core::biometric_v2::BiometricTrait; + + #[napi] + pub struct BiometricLockSystem { + inner: desktop_core::biometric_v2::BiometricLockSystem, + } + + #[napi] + pub fn init_biometric_system() -> napi::Result { + Ok(BiometricLockSystem { + inner: desktop_core::biometric_v2::BiometricLockSystem::new(), + }) + } + + #[napi] + pub async fn authenticate( + biometric_lock_system: &BiometricLockSystem, + hwnd: napi::bindgen_prelude::Buffer, + message: String, + ) -> napi::Result { + biometric_lock_system + .inner + .authenticate(hwnd.into(), message) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn authenticate_available( + biometric_lock_system: &BiometricLockSystem, + ) -> napi::Result { + biometric_lock_system + .inner + .authenticate_available() + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn enroll_persistent( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + key: napi::bindgen_prelude::Buffer, + ) -> napi::Result<()> { + biometric_lock_system + .inner + .enroll_persistent(&user_id, &key) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn provide_key( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + key: napi::bindgen_prelude::Buffer, + ) -> napi::Result<()> { + biometric_lock_system + .inner + .provide_key(&user_id, &key) + .await; + Ok(()) + } + + #[napi] + pub async fn unlock( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + hwnd: napi::bindgen_prelude::Buffer, + ) -> napi::Result { + biometric_lock_system + .inner + .unlock(&user_id, hwnd.into()) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + .map(|v| v.into()) + } + + #[napi] + pub async fn unlock_available( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + ) -> napi::Result { + biometric_lock_system + .inner + .unlock_available(&user_id) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn has_persistent( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + ) -> napi::Result { + biometric_lock_system + .inner + .has_persistent(&user_id) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn unenroll( + biometric_lock_system: &BiometricLockSystem, + user_id: String, + ) -> napi::Result<()> { + biometric_lock_system + .inner + .unenroll(&user_id) + .await + .map_err(|e| napi::Error::from_reason(e.to_string())) + } +} + #[napi] pub mod clipboards { #[allow(clippy::unused_async)] // FIXME: Remove unused async! @@ -169,7 +286,6 @@ pub mod clipboards { pub mod sshagent { use std::sync::Arc; - use desktop_core::ssh_agent::BitwardenSshKey; use napi::{ bindgen_prelude::Promise, threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction}, @@ -179,7 +295,7 @@ pub mod sshagent { #[napi] pub struct SshAgentState { - state: desktop_core::ssh_agent::BitwardenDesktopAgent, + state: desktop_core::ssh_agent::BitwardenDesktopAgent, } #[napi(object)] diff --git a/apps/desktop/desktop_native/process_isolation/Cargo.toml b/apps/desktop/desktop_native/process_isolation/Cargo.toml index 8e4072f1a90..170832c2fde 100644 --- a/apps/desktop/desktop_native/process_isolation/Cargo.toml +++ b/apps/desktop/desktop_native/process_isolation/Cargo.toml @@ -12,3 +12,4 @@ crate-type = ["cdylib"] ctor = { workspace = true } desktop_core = { path = "../core" } libc = { workspace = true } +tracing = { workspace = true } diff --git a/apps/desktop/desktop_native/process_isolation/src/lib.rs b/apps/desktop/desktop_native/process_isolation/src/lib.rs index 57275817b9f..850ffac841e 100644 --- a/apps/desktop/desktop_native/process_isolation/src/lib.rs +++ b/apps/desktop/desktop_native/process_isolation/src/lib.rs @@ -7,6 +7,7 @@ use desktop_core::process_isolation; use std::{ffi::c_char, sync::LazyLock}; +use tracing::info; static ORIGINAL_UNSETENV: LazyLock i32> = LazyLock::new(|| unsafe { @@ -38,8 +39,8 @@ unsafe extern "C" fn unsetenv(name: *const c_char) -> i32 { #[ctor::ctor] fn preload_init() { let pid = unsafe { libc::getpid() }; + info!(pid, "Enabling memory security for process."); unsafe { - println!("[Process Isolation] Enabling memory security for process {pid}"); process_isolation::isolate_process(); process_isolation::disable_coredumps(); } diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index dfddff034e6..e120db339d8 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -81,6 +81,31 @@ "additionalTouchIdSettings" | i18n }} +
+
+ +
+
{ const desktopAutotypeService = mock(); const billingAccountProfileStateService = mock(); const configService = mock(); + const userVerificationService = mock(); + + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)); beforeEach(async () => { jest.clearAllMocks(); @@ -92,6 +96,7 @@ describe("SettingsComponent", () => { }; i18nService.supportedTranslationLocales = []; + i18nService.t.mockImplementation((key: string) => key); await TestBed.configureTestingModule({ imports: [], @@ -124,7 +129,7 @@ describe("SettingsComponent", () => { { provide: PolicyService, useValue: policyService }, { provide: StateService, useValue: mock() }, { provide: ThemeStateService, useValue: themeStateService }, - { provide: UserVerificationService, useValue: mock() }, + { provide: UserVerificationService, useValue: userVerificationService }, { provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService }, { provide: ValidationService, useValue: validationService }, { provide: MessagingService, useValue: messagingService }, @@ -153,6 +158,7 @@ describe("SettingsComponent", () => { component = fixture.componentInstance; fixture.detectChanges(); + desktopBiometricsService.hasPersistentKey.mockResolvedValue(false); vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue( of(VaultTimeoutStringType.OnLocked), ); @@ -296,43 +302,81 @@ describe("SettingsComponent", () => { describe("windows desktop", () => { beforeEach(() => { platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + desktopBiometricsService.isWindowsV2BiometricsEnabled.mockResolvedValue(true); // Recreate component to apply the correct device fixture = TestBed.createComponent(SettingsComponent); component = fixture.componentInstance; }); - it("require password or pin on app start not visible when RemoveUnlockWithPin policy is disabled and pin set and windows desktop", async () => { - const policy = new Policy(); - policy.type = PolicyType.RemoveUnlockWithPin; - policy.enabled = false; - policyService.policiesByType$.mockReturnValue(of([policy])); - pinServiceAbstraction.isPinSet.mockResolvedValue(true); + test.each([true, false])( + `correct message display for require MP/PIN on app restart when pin is set, windows desktop, and policy is %s`, + async (policyEnabled) => { + const policy = new Policy(); + policy.type = PolicyType.RemoveUnlockWithPin; + policy.enabled = policyEnabled; + policyService.policiesByType$.mockReturnValue(of([policy])); + platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + pinServiceAbstraction.isPinSet.mockResolvedValue(true); - await component.ngOnInit(); - fixture.detectChanges(); + await component.ngOnInit(); + fixture.detectChanges(); - const requirePasswordOnStartLabelElement = fixture.debugElement.query( - By.css("label[for='requirePasswordOnStart']"), - ); - expect(requirePasswordOnStartLabelElement).toBeNull(); + const textNodes = checkRequireMasterPasswordOnAppRestartElement(fixture); + + if (policyEnabled) { + expect(textNodes).toContain("requireMasterPasswordOnAppRestart"); + } else { + expect(textNodes).toContain("requireMasterPasswordOrPinOnAppRestart"); + } + }, + ); + + describe("users without a master password", () => { + beforeEach(() => { + userVerificationService.hasMasterPassword.mockResolvedValue(false); + }); + + it("displays require MP/PIN on app restart checkbox when pin is set", async () => { + pinServiceAbstraction.isPinSet.mockResolvedValue(true); + + await component.ngOnInit(); + fixture.detectChanges(); + + checkRequireMasterPasswordOnAppRestartElement(fixture); + }); + + it("does not display require MP/PIN on app restart checkbox when pin is not set", async () => { + pinServiceAbstraction.isPinSet.mockResolvedValue(false); + + await component.ngOnInit(); + fixture.detectChanges(); + + const requireMasterPasswordOnAppRestartLabelElement = fixture.debugElement.query( + By.css("label[for='requireMasterPasswordOnAppRestart']"), + ); + expect(requireMasterPasswordOnAppRestartLabelElement).toBeNull(); + }); }); - it("require password on app start not visible when RemoveUnlockWithPin policy is enabled and pin set and windows desktop", async () => { - const policy = new Policy(); - policy.type = PolicyType.RemoveUnlockWithPin; - policy.enabled = true; - policyService.policiesByType$.mockReturnValue(of([policy])); - pinServiceAbstraction.isPinSet.mockResolvedValue(true); - - await component.ngOnInit(); - fixture.detectChanges(); - - const requirePasswordOnStartLabelElement = fixture.debugElement.query( - By.css("label[for='requirePasswordOnStart']"), + function checkRequireMasterPasswordOnAppRestartElement( + fixture: ComponentFixture, + ) { + const requireMasterPasswordOnAppRestartLabelElement = fixture.debugElement.query( + By.css("label[for='requireMasterPasswordOnAppRestart']"), ); - expect(requirePasswordOnStartLabelElement).toBeNull(); - }); + expect(requireMasterPasswordOnAppRestartLabelElement).not.toBeNull(); + expect(requireMasterPasswordOnAppRestartLabelElement.children).toHaveLength(1); + expect(requireMasterPasswordOnAppRestartLabelElement.children[0].name).toBe("input"); + expect(requireMasterPasswordOnAppRestartLabelElement.children[0].attributes).toMatchObject({ + id: "requireMasterPasswordOnAppRestart", + type: "checkbox", + }); + const textNodes = requireMasterPasswordOnAppRestartLabelElement.childNodes + .filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE) + .map((node) => node.nativeNode.wholeText?.trim()); + return textNodes; + } }); }); @@ -362,7 +406,7 @@ describe("SettingsComponent", () => { await component.updatePinHandler(true); expect(component.form.controls.pin.value).toBe(false); - expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled(); + expect(pinServiceAbstraction.unsetPin).not.toHaveBeenCalled(); expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); }); @@ -378,7 +422,7 @@ describe("SettingsComponent", () => { await component.updatePinHandler(true); expect(component.form.controls.pin.value).toBe(dialogResult); - expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled(); + expect(pinServiceAbstraction.unsetPin).not.toHaveBeenCalled(); expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); }, ); @@ -390,9 +434,147 @@ describe("SettingsComponent", () => { await component.updatePinHandler(false); expect(component.form.controls.pin.value).toBe(false); - expect(vaultTimeoutSettingsService.clear).not.toHaveBeenCalled(); + expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled(); expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); }); + + describe("when windows biometric v2 feature flag is enabled", () => { + beforeEach(() => { + keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey)); + }); + + test.each([false, true])( + "enrolls persistent biometric if needed, enrolled is %s", + async (enrolled) => { + desktopBiometricsService.hasPersistentKey.mockResolvedValue(enrolled); + + await component.ngOnInit(); + component.isWindowsV2BiometricsEnabled = true; + component.isWindows = true; + component.form.value.requireMasterPasswordOnAppRestart = true; + component.userHasMasterPassword = false; + component.supportsBiometric = true; + component.form.value.biometric = true; + + await component.updatePinHandler(false); + + expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false); + expect(component.form.controls.pin.value).toBe(false); + expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled(); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + + if (enrolled) { + expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled(); + } else { + expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith( + mockUserId, + mockUserKey, + ); + } + }, + ); + + test.each([ + { + userHasMasterPassword: true, + supportsBiometric: false, + biometric: false, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: true, + supportsBiometric: false, + biometric: false, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: true, + supportsBiometric: false, + biometric: true, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: true, + supportsBiometric: false, + biometric: true, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: true, + supportsBiometric: true, + biometric: false, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: true, + supportsBiometric: true, + biometric: false, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: false, + supportsBiometric: false, + biometric: false, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: false, + supportsBiometric: false, + biometric: false, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: false, + supportsBiometric: false, + biometric: true, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: false, + supportsBiometric: false, + biometric: true, + requireMasterPasswordOnAppRestart: true, + }, + { + userHasMasterPassword: false, + supportsBiometric: true, + biometric: false, + requireMasterPasswordOnAppRestart: false, + }, + { + userHasMasterPassword: false, + supportsBiometric: true, + biometric: false, + requireMasterPasswordOnAppRestart: true, + }, + ])( + "does not enroll persistent biometric when conditions are not met: userHasMasterPassword=$userHasMasterPassword, supportsBiometric=$supportsBiometric, biometric=$biometric, requireMasterPasswordOnAppRestart=$requireMasterPasswordOnAppRestart", + async ({ + userHasMasterPassword, + supportsBiometric, + biometric, + requireMasterPasswordOnAppRestart, + }) => { + desktopBiometricsService.hasPersistentKey.mockResolvedValue(false); + + await component.ngOnInit(); + component.isWindowsV2BiometricsEnabled = true; + component.isWindows = true; + component.form.value.requireMasterPasswordOnAppRestart = + requireMasterPasswordOnAppRestart; + component.userHasMasterPassword = userHasMasterPassword; + component.supportsBiometric = supportsBiometric; + component.form.value.biometric = biometric; + + await component.updatePinHandler(false); + + expect(component.form.controls.pin.value).toBe(false); + expect(pinServiceAbstraction.unsetPin).toHaveBeenCalled(); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled(); + }, + ); + }); }); }); @@ -474,22 +656,92 @@ describe("SettingsComponent", () => { expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); }); - it("handles windows case", async () => { - desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available); - desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue( - BiometricsStatus.Available, - ); + describe("windows test cases", () => { + beforeEach(() => { + platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + component.isWindows = true; + component.isLinux = false; - component.isWindows = true; - component.isLinux = false; - await component.updateBiometricHandler(true); + desktopBiometricsService.getBiometricsStatus.mockResolvedValue( + BiometricsStatus.Available, + ); + desktopBiometricsService.getBiometricsStatusForUser.mockResolvedValue( + BiometricsStatus.Available, + ); + }); - expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); - expect(component.form.controls.autoPromptBiometrics.value).toBe(false); - expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false); - expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId); - expect(component.form.controls.biometric.value).toBe(true); - expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + it("handles windows case", async () => { + await component.updateBiometricHandler(true); + + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(component.form.controls.autoPromptBiometrics.value).toBe(false); + expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false); + expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId); + expect(component.form.controls.biometric.value).toBe(true); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + }); + + describe("when windows v2 biometrics is enabled", () => { + beforeEach(() => { + component.isWindowsV2BiometricsEnabled = true; + + keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey)); + }); + + it("when the user doesn't have a master password or a PIN set, allows biometric unlock on app restart", async () => { + component.userHasMasterPassword = false; + component.userHasPinSet = false; + desktopBiometricsService.hasPersistentKey.mockResolvedValue(false); + + await component.updateBiometricHandler(true); + + expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId); + expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith( + mockUserId, + mockUserKey, + ); + expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false); + + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(component.form.controls.autoPromptBiometrics.value).toBe(false); + expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false); + expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId); + expect(component.form.controls.biometric.value).toBe(true); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + }); + + test.each([ + [true, true], + [true, false], + [false, true], + ])( + "when the userHasMasterPassword is %s and userHasPinSet is %s, require master password/PIN on app restart is the default setting", + async (userHasMasterPassword, userHasPinSet) => { + component.userHasMasterPassword = userHasMasterPassword; + component.userHasPinSet = userHasPinSet; + + await component.updateBiometricHandler(true); + + expect(desktopBiometricsService.enrollPersistent).not.toHaveBeenCalled(); + expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(true); + expect(desktopBiometricsService.deleteBiometricUnlockKeyForUser).toHaveBeenCalledWith( + mockUserId, + ); + expect( + desktopBiometricsService.setBiometricProtectedUnlockKeyForUser, + ).toHaveBeenCalledWith(mockUserId, mockUserKey); + + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(biometricStateService.setBiometricUnlockEnabled).toHaveBeenCalledWith(true); + expect(component.form.controls.autoPromptBiometrics.value).toBe(false); + expect(biometricStateService.setPromptAutomatically).toHaveBeenCalledWith(false); + expect(keyService.refreshAdditionalKeys).toHaveBeenCalledWith(mockUserId); + expect(component.form.controls.biometric.value).toBe(true); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + }, + ); + }); }); it("handles linux case", async () => { @@ -553,6 +805,57 @@ describe("SettingsComponent", () => { }); }); + describe("updateRequireMasterPasswordOnAppRestartHandler", () => { + beforeEach(() => { + jest.clearAllMocks(); + + keyService.userKey$ = jest.fn().mockReturnValue(of(mockUserKey)); + }); + + test.each([true, false])(`handles thrown errors when updated to %s`, async (update) => { + const error = new Error("Test error"); + jest.spyOn(component, "updateRequireMasterPasswordOnAppRestart").mockRejectedValue(error); + + await component.ngOnInit(); + await component.updateRequireMasterPasswordOnAppRestartHandler(update, mockUserId); + + expect(logService.error).toHaveBeenCalled(); + expect(validationService.showError).toHaveBeenCalledWith(error); + }); + + describe("when updating to true", () => { + it("calls the biometrics service to clear and reset biometric key", async () => { + await component.ngOnInit(); + await component.updateRequireMasterPasswordOnAppRestartHandler(true, mockUserId); + + expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId); + expect(desktopBiometricsService.deleteBiometricUnlockKeyForUser).toHaveBeenCalledWith( + mockUserId, + ); + expect(desktopBiometricsService.setBiometricProtectedUnlockKeyForUser).toHaveBeenCalledWith( + mockUserId, + mockUserKey, + ); + }); + }); + + describe("when updating to false", () => { + it("doesn't enroll persistent biometric if already enrolled", async () => { + biometricStateService.hasPersistentKey.mockResolvedValue(false); + + await component.ngOnInit(); + await component.updateRequireMasterPasswordOnAppRestartHandler(false, mockUserId); + + expect(keyService.userKey$).toHaveBeenCalledWith(mockUserId); + expect(desktopBiometricsService.enrollPersistent).toHaveBeenCalledWith( + mockUserId, + mockUserKey, + ); + expect(component.form.controls.requireMasterPasswordOnAppRestart.value).toBe(false); + }); + }); + }); + describe("saveVaultTimeout", () => { const DEFAULT_VAULT_TIMEOUT: VaultTimeout = 123; const DEFAULT_VAULT_TIMEOUT_ACTION = VaultTimeoutAction.Lock; diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 53b2cad4376..7666e9bef1b 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -142,6 +142,7 @@ export class SettingsComponent implements OnInit, OnDestroy { userHasPinSet: boolean; pinEnabled$: Observable = of(true); + isWindowsV2BiometricsEnabled: boolean = false; form = this.formBuilder.group({ // Security @@ -149,6 +150,7 @@ export class SettingsComponent implements OnInit, OnDestroy { vaultTimeoutAction: [VaultTimeoutAction.Lock], pin: [null as boolean | null], biometric: false, + requireMasterPasswordOnAppRestart: true, autoPromptBiometrics: false, // Account Preferences clearClipboard: [null], @@ -281,6 +283,8 @@ export class SettingsComponent implements OnInit, OnDestroy { } async ngOnInit() { + this.isWindowsV2BiometricsEnabled = await this.biometricsService.isWindowsV2BiometricsEnabled(); + this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions(); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); @@ -372,6 +376,9 @@ export class SettingsComponent implements OnInit, OnDestroy { ), pin: this.userHasPinSet, biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(), + requireMasterPasswordOnAppRestart: !(await this.biometricsService.hasPersistentKey( + activeAccount.id, + )), autoPromptBiometrics: await firstValueFrom(this.biometricStateService.promptAutomatically$), clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$), minimizeOnCopyToClipboard: await firstValueFrom(this.desktopSettingsService.minimizeOnCopy$), @@ -479,6 +486,15 @@ export class SettingsComponent implements OnInit, OnDestroy { ) .subscribe(); + this.form.controls.requireMasterPasswordOnAppRestart.valueChanges + .pipe( + concatMap(async (value) => { + await this.updateRequireMasterPasswordOnAppRestartHandler(value, activeAccount.id); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + this.form.controls.enableBrowserIntegration.valueChanges .pipe(takeUntil(this.destroy$)) .subscribe((enabled) => { @@ -588,6 +604,19 @@ export class SettingsComponent implements OnInit, OnDestroy { this.form.controls.pin.setValue(this.userHasPinSet, { emitEvent: false }); } else { const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + // On Windows if a user turned off PIN without having a MP and has biometrics + require MP/PIN on restart enabled. + if ( + this.isWindows && + this.isWindowsV2BiometricsEnabled && + this.supportsBiometric && + this.form.value.requireMasterPasswordOnAppRestart && + this.form.value.biometric && + !this.userHasMasterPassword + ) { + // Allow biometric unlock on app restart so the user doesn't get into a bad state. + await this.enrollPersistentBiometricIfNeeded(userId); + } await this.pinService.unsetPin(userId); } } @@ -639,6 +668,16 @@ export class SettingsComponent implements OnInit, OnDestroy { // Recommended settings for Windows Hello this.form.controls.autoPromptBiometrics.setValue(false); await this.biometricStateService.setPromptAutomatically(false); + + if (this.isWindowsV2BiometricsEnabled) { + // If the user doesn't have a MP or PIN then they have to use biometrics on app restart. + if (!this.userHasMasterPassword && !this.userHasPinSet) { + // Allow biometric unlock on app restart so the user doesn't get into a bad state. + await this.enrollPersistentBiometricIfNeeded(activeUserId); + } else { + this.form.controls.requireMasterPasswordOnAppRestart.setValue(true); + } + } } else if (this.isLinux) { // Similar to Windows this.form.controls.autoPromptBiometrics.setValue(false); @@ -656,6 +695,37 @@ export class SettingsComponent implements OnInit, OnDestroy { } } + async updateRequireMasterPasswordOnAppRestartHandler(enabled: boolean, userId: UserId) { + try { + await this.updateRequireMasterPasswordOnAppRestart(enabled, userId); + } catch (error) { + this.logService.error("Error updating require master password on app restart: ", error); + this.validationService.showError(error); + } + } + + async updateRequireMasterPasswordOnAppRestart(enabled: boolean, userId: UserId) { + if (enabled) { + // Require master password or PIN on app restart + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + await this.biometricsService.deleteBiometricUnlockKeyForUser(userId); + await this.biometricsService.setBiometricProtectedUnlockKeyForUser(userId, userKey); + } else { + // Allow biometric unlock on app restart + await this.enrollPersistentBiometricIfNeeded(userId); + } + } + + private async enrollPersistentBiometricIfNeeded(userId: UserId): Promise { + if (!(await this.biometricsService.hasPersistentKey(userId))) { + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + await this.biometricsService.enrollPersistent(userId, userKey); + this.form.controls.requireMasterPasswordOnAppRestart.setValue(false, { + emitEvent: false, + }); + } + } + async updateAutoPromptBiometrics() { if (this.form.value.autoPromptBiometrics) { await this.biometricStateService.setPromptAutomatically(true); diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index b07c1c08718..a809a1b23a2 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -21,6 +21,7 @@ import { UserLockIcon, VaultIcon, LockIcon, + DomainIcon, } from "@bitwarden/assets/svg"; import { LoginComponent, @@ -289,6 +290,8 @@ const routes: Routes = [ pageTitle: { key: "verifyYourIdentity", }, + // `TwoFactorAuthComponent` manually sets its icon based on the 2fa type + pageIcon: null, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { @@ -297,12 +300,16 @@ const routes: Routes = [ component: SetInitialPasswordComponent, data: { maxWidth: "lg", + pageIcon: LockIcon, } satisfies AnonLayoutWrapperData, }, { path: "change-password", component: ChangePasswordComponent, canActivate: [authGuard], + data: { + pageIcon: LockIcon, + } satisfies AnonLayoutWrapperData, }, { path: "confirm-key-connector-domain", @@ -312,6 +319,7 @@ const routes: Routes = [ pageTitle: { key: "confirmKeyConnectorDomain", }, + pageIcon: DomainIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, ], diff --git a/apps/desktop/src/app/components/fido2placeholder.component.ts b/apps/desktop/src/app/components/fido2placeholder.component.ts index b95dcc6d890..f1f52dae439 100644 --- a/apps/desktop/src/app/components/fido2placeholder.component.ts +++ b/apps/desktop/src/app/components/fido2placeholder.component.ts @@ -9,6 +9,8 @@ import { } from "../../autofill/services/desktop-fido2-user-interface.service"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.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({ standalone: true, imports: [CommonModule], diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 79c93c1390e..ae633bd4a69 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -28,6 +28,7 @@ import { DesktopAutotypeService } from "../../autofill/services/desktop-autotype import { SshAgentService } from "../../autofill/services/ssh-agent.service"; import { I18nRendererService } from "../../platform/services/i18n.renderer.service"; import { VersionService } from "../../platform/services/version.service"; +import { BiometricMessageHandlerService } from "../../services/biometric-message-handler.service"; import { NativeMessagingService } from "../../services/native-messaging.service"; @Injectable() @@ -53,6 +54,7 @@ export class InitService { private autofillService: DesktopAutofillService, private autotypeService: DesktopAutotypeService, private sdkLoadService: SdkLoadService, + private biometricMessageHandlerService: BiometricMessageHandlerService, private configService: ConfigService, @Inject(DOCUMENT) private document: Document, private readonly migrationRunner: MigrationRunner, @@ -95,6 +97,7 @@ export class InitService { const containerService = new ContainerService(this.keyService, this.encryptService); containerService.attachToGlobal(this.win); + await this.biometricMessageHandlerService.init(); await this.autofillService.init(); await this.autotypeService.init(); }; diff --git a/apps/desktop/src/app/tools/export/export-desktop.component.ts b/apps/desktop/src/app/tools/export/export-desktop.component.ts index 03afb154200..0adc8e758c9 100644 --- a/apps/desktop/src/app/tools/export/export-desktop.component.ts +++ b/apps/desktop/src/app/tools/export/export-desktop.component.ts @@ -5,6 +5,8 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DialogRef, AsyncActionsModule, ButtonModule, DialogModule } from "@bitwarden/components"; import { ExportComponent } from "@bitwarden/vault-export-ui"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "export-desktop.component.html", imports: [ diff --git a/apps/desktop/src/app/tools/generator/credential-generator.component.ts b/apps/desktop/src/app/tools/generator/credential-generator.component.ts index 4124b2439da..42313c48f7f 100644 --- a/apps/desktop/src/app/tools/generator/credential-generator.component.ts +++ b/apps/desktop/src/app/tools/generator/credential-generator.component.ts @@ -13,6 +13,8 @@ import { GeneratorModule, } from "@bitwarden/generator-components"; +// 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: "credential-generator", templateUrl: "credential-generator.component.html", diff --git a/apps/desktop/src/app/tools/import/import-desktop.component.ts b/apps/desktop/src/app/tools/import/import-desktop.component.ts index fefeb439010..dd34855f416 100644 --- a/apps/desktop/src/app/tools/import/import-desktop.component.ts +++ b/apps/desktop/src/app/tools/import/import-desktop.component.ts @@ -13,6 +13,8 @@ import { safeProvider } from "@bitwarden/ui-common"; import { DesktopImportMetadataService } from "./desktop-import-metadata.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({ templateUrl: "import-desktop.component.html", imports: [ diff --git a/apps/desktop/src/app/tools/send/add-edit.component.ts b/apps/desktop/src/app/tools/send/add-edit.component.ts index bee4f920eda..b817adda848 100644 --- a/apps/desktop/src/app/tools/send/add-edit.component.ts +++ b/apps/desktop/src/app/tools/send/add-edit.component.ts @@ -21,6 +21,8 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { CalloutModule, DialogService, ToastService } from "@bitwarden/components"; +// 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: "app-send-add-edit", templateUrl: "add-edit.component.html", diff --git a/apps/desktop/src/app/tools/send/send.component.ts b/apps/desktop/src/app/tools/send/send.component.ts index 0146a5e62ea..3605ca3d2dc 100644 --- a/apps/desktop/src/app/tools/send/send.component.ts +++ b/apps/desktop/src/app/tools/send/send.component.ts @@ -35,12 +35,16 @@ enum Action { const BroadcasterSubscriptionId = "SendComponent"; +// 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: "app-send", templateUrl: "send.component.html", imports: [CommonModule, JslibModule, FormsModule, NavComponent, AddEditComponent], }) export class SendComponent extends BaseSendComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(AddEditComponent) addEditComponent: AddEditComponent; sendId: string; diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.ts b/apps/desktop/src/autofill/components/autotype-shortcut.component.ts index 5cf1d90cb79..3c82d8297a1 100644 --- a/apps/desktop/src/autofill/components/autotype-shortcut.component.ts +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.ts @@ -22,6 +22,8 @@ import { IconButtonModule, } from "@bitwarden/components"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "autotype-shortcut.component.html", imports: [ diff --git a/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts b/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts index 97e1d322a0e..0fe3d7e95e7 100644 --- a/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts @@ -13,4 +13,9 @@ export abstract class DesktopBiometricsService extends BiometricsService { ): Promise; abstract deleteBiometricUnlockKeyForUser(userId: UserId): Promise; abstract setupBiometrics(): Promise; + abstract enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise; + abstract hasPersistentKey(userId: UserId): Promise; + /* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */ + abstract enableWindowsV2Biometrics(): Promise; + abstract isWindowsV2BiometricsEnabled(): Promise; } diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts b/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts index d4ce01f53f4..24bb5495da0 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts @@ -51,6 +51,17 @@ export class MainBiometricsIPCListener { return await this.biometricService.setShouldAutopromptNow(message.data as boolean); case BiometricAction.GetShouldAutoprompt: return await this.biometricService.getShouldAutopromptNow(); + case BiometricAction.HasPersistentKey: + return await this.biometricService.hasPersistentKey(message.userId as UserId); + case BiometricAction.EnrollPersistent: + return await this.biometricService.enrollPersistent( + message.userId as UserId, + SymmetricCryptoKey.fromString(message.key as string), + ); + case BiometricAction.EnableWindowsV2: + return await this.biometricService.enableWindowsV2Biometrics(); + case BiometricAction.IsWindowsV2Enabled: + return await this.biometricService.isWindowsV2BiometricsEnabled(); default: return; } diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts index bc57a7e55fb..be9e1f841e1 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts @@ -7,6 +7,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { EncryptionType } from "@bitwarden/common/platform/enums"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; +import { newGuid } from "@bitwarden/guid"; import { BiometricsService, BiometricsStatus, @@ -16,6 +17,7 @@ import { import { WindowMain } from "../../main/window.main"; import { MainBiometricsService } from "./main-biometrics.service"; +import { WindowsBiometricsSystem } from "./native-v2"; import OsBiometricsServiceLinux from "./os-biometrics-linux.service"; import OsBiometricsServiceMac from "./os-biometrics-mac.service"; import OsBiometricsServiceWindows from "./os-biometrics-windows.service"; @@ -28,6 +30,13 @@ jest.mock("@bitwarden/desktop-napi", () => { }; }); +jest.mock("./native-v2", () => ({ + WindowsBiometricsSystem: jest.fn(), + biometrics_v2: { + initBiometricSystem: jest.fn(), + }, +})); + const unlockKey = new SymmetricCryptoKey(new Uint8Array(64)); describe("MainBiometricsService", function () { @@ -38,24 +47,6 @@ describe("MainBiometricsService", function () { const cryptoFunctionService = mock(); const encryptService = mock(); - it("Should call the platformspecific methods", async () => { - const sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - - const mockService = mock(); - (sut as any).osBiometricsService = mockService; - - await sut.authenticateBiometric(); - expect(mockService.authenticateBiometric).toBeCalled(); - }); - describe("Should create a platform specific service", function () { it("Should create a biometrics service specific for Windows", () => { const sut = new MainBiometricsService( @@ -207,46 +198,6 @@ describe("MainBiometricsService", function () { }); }); - describe("setupBiometrics", () => { - it("should call the platform specific setup method", async () => { - const sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - const osBiometricsService = mock(); - (sut as any).osBiometricsService = osBiometricsService; - - await sut.setupBiometrics(); - - expect(osBiometricsService.runSetup).toHaveBeenCalled(); - }); - }); - - describe("authenticateWithBiometrics", () => { - it("should call the platform specific authenticate method", async () => { - const sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - const osBiometricsService = mock(); - (sut as any).osBiometricsService = osBiometricsService; - - await sut.authenticateWithBiometrics(); - - expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled(); - }); - }); - describe("unlockWithBiometricsForUser", () => { let sut: MainBiometricsService; let osBiometricsService: MockProxy; @@ -288,55 +239,6 @@ describe("MainBiometricsService", function () { }); }); - describe("setBiometricProtectedUnlockKeyForUser", () => { - let sut: MainBiometricsService; - let osBiometricsService: MockProxy; - - beforeEach(() => { - sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - osBiometricsService = mock(); - (sut as any).osBiometricsService = osBiometricsService; - }); - - it("should call the platform specific setBiometricKey method", async () => { - const userId = "test" as UserId; - - await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey); - - expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey); - }); - }); - - describe("deleteBiometricUnlockKeyForUser", () => { - it("should call the platform specific deleteBiometricKey method", async () => { - const sut = new MainBiometricsService( - i18nService, - windowMain, - logService, - process.platform, - biometricStateService, - encryptService, - cryptoFunctionService, - ); - const osBiometricsService = mock(); - (sut as any).osBiometricsService = osBiometricsService; - - const userId = "test" as UserId; - - await sut.deleteBiometricUnlockKeyForUser(userId); - - expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId); - }); - }); - describe("setShouldAutopromptNow", () => { let sut: MainBiometricsService; @@ -386,4 +288,138 @@ describe("MainBiometricsService", function () { expect(shouldAutoPrompt).toBe(true); }); }); + + describe("enableWindowsV2Biometrics", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("enables Windows V2 biometrics when platform is win32 and not already enabled", async () => { + const sut = new MainBiometricsService( + i18nService, + windowMain, + logService, + "win32", + biometricStateService, + encryptService, + cryptoFunctionService, + ); + + await sut.enableWindowsV2Biometrics(); + + expect(logService.info).toHaveBeenCalledWith( + "[BiometricsMain] Loading native biometrics module v2 for windows", + ); + expect(await sut.isWindowsV2BiometricsEnabled()).toBe(true); + const internalService = (sut as any).osBiometricsService; + expect(internalService).not.toBeNull(); + expect(internalService).toBeInstanceOf(WindowsBiometricsSystem); + }); + + it("should not enable Windows V2 biometrics when platform is not win32", async () => { + const sut = new MainBiometricsService( + i18nService, + windowMain, + logService, + "darwin", + biometricStateService, + encryptService, + cryptoFunctionService, + ); + + await sut.enableWindowsV2Biometrics(); + + expect(logService.info).not.toHaveBeenCalled(); + expect(await sut.isWindowsV2BiometricsEnabled()).toBe(false); + }); + + it("should not enable Windows V2 biometrics when already enabled", async () => { + const sut = new MainBiometricsService( + i18nService, + windowMain, + logService, + "win32", + biometricStateService, + encryptService, + cryptoFunctionService, + ); + + // Enable it first + await sut.enableWindowsV2Biometrics(); + + // Enable it again + await sut.enableWindowsV2Biometrics(); + + expect(logService.info).toHaveBeenCalledWith( + "[BiometricsMain] Loading native biometrics module v2 for windows", + ); + expect(logService.info).toHaveBeenCalledTimes(1); + expect(await sut.isWindowsV2BiometricsEnabled()).toBe(true); + const internalService = (sut as any).osBiometricsService; + expect(internalService).not.toBeNull(); + expect(internalService).toBeInstanceOf(WindowsBiometricsSystem); + }); + }); + + describe("pass through methods that call platform specific osBiometricsService methods", () => { + const userId = newGuid() as UserId; + let sut: MainBiometricsService; + let osBiometricsService: MockProxy; + + beforeEach(() => { + sut = new MainBiometricsService( + i18nService, + windowMain, + logService, + process.platform, + biometricStateService, + encryptService, + cryptoFunctionService, + ); + osBiometricsService = mock(); + (sut as any).osBiometricsService = osBiometricsService; + }); + + it("calls the platform specific setBiometricKey method", async () => { + await sut.setBiometricProtectedUnlockKeyForUser(userId, unlockKey); + + expect(osBiometricsService.setBiometricKey).toHaveBeenCalledWith(userId, unlockKey); + }); + + it("calls the platform specific enrollPersistent method", async () => { + await sut.enrollPersistent(userId, unlockKey); + + expect(osBiometricsService.enrollPersistent).toHaveBeenCalledWith(userId, unlockKey); + }); + + it("calls the platform specific hasPersistentKey method", async () => { + await sut.hasPersistentKey(userId); + + expect(osBiometricsService.hasPersistentKey).toHaveBeenCalledWith(userId); + }); + + it("calls the platform specific deleteBiometricUnlockKeyForUser method", async () => { + await sut.deleteBiometricUnlockKeyForUser(userId); + + expect(osBiometricsService.deleteBiometricKey).toHaveBeenCalledWith(userId); + }); + + it("calls the platform specific authenticateWithBiometrics method", async () => { + await sut.authenticateWithBiometrics(); + + expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled(); + }); + + it("calls the platform specific authenticateBiometric method", async () => { + await sut.authenticateBiometric(); + + expect(osBiometricsService.authenticateBiometric).toHaveBeenCalled(); + }); + + it("calls the platform specific setupBiometrics method", async () => { + await sut.setupBiometrics(); + + expect(osBiometricsService.runSetup).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts index 1de8e3cd12d..d1aff17646a 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts @@ -10,17 +10,19 @@ import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-manageme import { WindowMain } from "../../main/window.main"; import { DesktopBiometricsService } from "./desktop.biometrics.service"; +import { WindowsBiometricsSystem } from "./native-v2"; import { OsBiometricService } from "./os-biometrics.service"; export class MainBiometricsService extends DesktopBiometricsService { private osBiometricsService: OsBiometricService; private shouldAutoPrompt = true; + private windowsV2BiometricsEnabled = false; constructor( private i18nService: I18nService, private windowMain: WindowMain, private logService: LogService, - platform: NodeJS.Platform, + private platform: NodeJS.Platform, private biometricStateService: BiometricStateService, private encryptService: EncryptService, private cryptoFunctionService: CryptoFunctionService, @@ -144,4 +146,28 @@ export class MainBiometricsService extends DesktopBiometricsService { async canEnableBiometricUnlock(): Promise { return true; } + + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { + return await this.osBiometricsService.enrollPersistent(userId, key); + } + + async hasPersistentKey(userId: UserId): Promise { + return await this.osBiometricsService.hasPersistentKey(userId); + } + + async enableWindowsV2Biometrics(): Promise { + if (this.platform === "win32" && !this.windowsV2BiometricsEnabled) { + this.logService.info("[BiometricsMain] Loading native biometrics module v2 for windows"); + this.osBiometricsService = new WindowsBiometricsSystem( + this.i18nService, + this.windowMain, + this.logService, + ); + this.windowsV2BiometricsEnabled = true; + } + } + + async isWindowsV2BiometricsEnabled(): Promise { + return this.windowsV2BiometricsEnabled; + } } diff --git a/apps/desktop/src/key-management/biometrics/native-v2/index.ts b/apps/desktop/src/key-management/biometrics/native-v2/index.ts new file mode 100644 index 00000000000..030224bbd74 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/index.ts @@ -0,0 +1 @@ +export { default as WindowsBiometricsSystem } from "./os-biometrics-windows.service"; diff --git a/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.spec.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.spec.ts new file mode 100644 index 00000000000..28b05c490b0 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.spec.ts @@ -0,0 +1,126 @@ +import { mock } from "jest-mock-extended"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +import { biometrics_v2 } from "@bitwarden/desktop-napi"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { WindowMain } from "../../main/window.main"; + +import OsBiometricsServiceWindows from "./os-biometrics-windows.service"; + +jest.mock("@bitwarden/desktop-napi", () => ({ + biometrics_v2: { + initBiometricSystem: jest.fn(() => "mockSystem"), + provideKey: jest.fn(), + enrollPersistent: jest.fn(), + unenroll: jest.fn(), + unlock: jest.fn(), + authenticate: jest.fn(), + authenticateAvailable: jest.fn(), + unlockAvailable: jest.fn(), + hasPersistent: jest.fn(), + }, + passwords: { + isAvailable: jest.fn(), + }, +})); + +const mockKey = new Uint8Array(64); + +jest.mock("../../../utils", () => ({ + isFlatpak: jest.fn(() => false), + isLinux: jest.fn(() => true), + isSnapStore: jest.fn(() => false), +})); + +describe("OsBiometricsServiceWindows", () => { + const userId = "user-id" as UserId; + + let service: OsBiometricsServiceWindows; + let i18nService: I18nService; + let windowMain: WindowMain; + let logService: LogService; + + beforeEach(() => { + i18nService = mock(); + windowMain = mock(); + logService = mock(); + + windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(Buffer.from([1, 2, 3, 4])); + service = new OsBiometricsServiceWindows(i18nService, windowMain, logService); + }); + + it("should enroll persistent biometric key", async () => { + await service.enrollPersistent("user-id" as UserId, new SymmetricCryptoKey(mockKey)); + expect(biometrics_v2.enrollPersistent).toHaveBeenCalled(); + }); + + it("should set biometric key", async () => { + await service.setBiometricKey(userId, new SymmetricCryptoKey(mockKey)); + expect(biometrics_v2.provideKey).toHaveBeenCalled(); + }); + + it("should delete biometric key", async () => { + await service.deleteBiometricKey(userId); + expect(biometrics_v2.unenroll).toHaveBeenCalled(); + }); + + it("should get biometric key", async () => { + (biometrics_v2.unlock as jest.Mock).mockResolvedValue(mockKey); + const result = await service.getBiometricKey(userId); + expect(result).toBeInstanceOf(SymmetricCryptoKey); + }); + + it("should return null if no biometric key", async () => { + const error = new Error("No key found"); + (biometrics_v2.unlock as jest.Mock).mockRejectedValue(error); + const result = await service.getBiometricKey(userId); + expect(result).toBeNull(); + expect(logService.warning).toHaveBeenCalledWith( + `[OsBiometricsServiceWindows] Fetching the biometric key failed: ${error} returning null`, + ); + }); + + it("should authenticate biometric", async () => { + (biometrics_v2.authenticate as jest.Mock).mockResolvedValue(true); + const result = await service.authenticateBiometric(); + expect(result).toBe(true); + }); + + it("should check if biometrics is supported", async () => { + (biometrics_v2.authenticateAvailable as jest.Mock).mockResolvedValue(true); + const result = await service.supportsBiometrics(); + expect(result).toBe(true); + }); + + it("should return needs setup false", async () => { + const result = await service.needsSetup(); + expect(result).toBe(false); + }); + + it("should return auto setup false", async () => { + const result = await service.canAutoSetup(); + expect(result).toBe(false); + }); + + it("should get biometrics first unlock status for user", async () => { + (biometrics_v2.unlockAvailable as jest.Mock).mockResolvedValue(true); + const result = await service.getBiometricsFirstUnlockStatusForUser(userId); + expect(result).toBe(BiometricsStatus.Available); + }); + + it("should return false for hasPersistentKey false", async () => { + (biometrics_v2.hasPersistent as jest.Mock).mockResolvedValue(false); + const result = await service.hasPersistentKey(userId); + expect(result).toBe(false); + }); + + it("should return false for hasPersistentKey true", async () => { + (biometrics_v2.hasPersistent as jest.Mock).mockResolvedValue(true); + const result = await service.hasPersistentKey(userId); + expect(result).toBe(true); + }); +}); diff --git a/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.ts new file mode 100644 index 00000000000..4d9794daa74 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-windows.service.ts @@ -0,0 +1,91 @@ +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +import { biometrics_v2 } from "@bitwarden/desktop-napi"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { WindowMain } from "../../../main/window.main"; +import { OsBiometricService } from "../os-biometrics.service"; + +export default class OsBiometricsServiceWindows implements OsBiometricService { + private biometricsSystem: biometrics_v2.BiometricLockSystem; + + constructor( + private i18nService: I18nService, + private windowMain: WindowMain, + private logService: LogService, + ) { + this.biometricsSystem = biometrics_v2.initBiometricSystem(); + } + + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { + await biometrics_v2.enrollPersistent( + this.biometricsSystem, + userId, + Buffer.from(key.toEncoded().buffer), + ); + } + + async hasPersistentKey(userId: UserId): Promise { + return await biometrics_v2.hasPersistent(this.biometricsSystem, userId); + } + + async supportsBiometrics(): Promise { + return await biometrics_v2.authenticateAvailable(this.biometricsSystem); + } + + async getBiometricKey(userId: UserId): Promise { + try { + const key = await biometrics_v2.unlock( + this.biometricsSystem, + userId, + this.windowMain.win.getNativeWindowHandle(), + ); + return key ? new SymmetricCryptoKey(Uint8Array.from(key)) : null; + } catch (error) { + this.logService.warning( + `[OsBiometricsServiceWindows] Fetching the biometric key failed: ${error} returning null`, + ); + return null; + } + } + + async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { + await biometrics_v2.provideKey( + this.biometricsSystem, + userId, + Buffer.from(key.toEncoded().buffer), + ); + } + + async deleteBiometricKey(userId: UserId): Promise { + await biometrics_v2.unenroll(this.biometricsSystem, userId); + } + + async authenticateBiometric(): Promise { + const hwnd = this.windowMain.win.getNativeWindowHandle(); + return await biometrics_v2.authenticate( + this.biometricsSystem, + hwnd, + this.i18nService.t("windowsHelloConsentMessage"), + ); + } + + async needsSetup() { + return false; + } + + async canAutoSetup(): Promise { + return false; + } + + async runSetup(): Promise {} + + async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { + return (await biometrics_v2.hasPersistent(this.biometricsSystem, userId)) || + (await biometrics_v2.unlockAvailable(this.biometricsSystem, userId)) + ? BiometricsStatus.Available + : BiometricsStatus.UnlockNeeded; + } +} diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts index 0ef3033b4c5..400918a69bb 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-linux.service.ts @@ -47,6 +47,12 @@ export default class OsBiometricsServiceLinux implements OsBiometricService { private logService: LogService, ) {} + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise {} + + async hasPersistentKey(userId: UserId): Promise { + return false; + } + private _iv: string | null = null; // Use getKeyMaterial helper instead of direct access private _osKeyHalf: string | null = null; diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts index 1dc64f1bcd5..87d63971750 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-mac.service.ts @@ -20,6 +20,14 @@ export default class OsBiometricsServiceMac implements OsBiometricService { private logService: LogService, ) {} + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { + return await passwords.setPassword(SERVICE, getLookupKeyForUser(userId), key.toBase64()); + } + + async hasPersistentKey(userId: UserId): Promise { + return (await passwords.getPassword(SERVICE, getLookupKeyForUser(userId))) != null; + } + async supportsBiometrics(): Promise { return systemPreferences.canPromptTouchID(); } diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts index 897304c9f61..a32d4678427 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts @@ -35,6 +35,12 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { private cryptoFunctionService: CryptoFunctionService, ) {} + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise {} + + async hasPersistentKey(userId: UserId): Promise { + return false; + } + async supportsBiometrics(): Promise { return await biometrics.available(); } diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts index 63e0527c034..064b28f2ff2 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics.service.ts @@ -25,4 +25,6 @@ export interface OsBiometricService { setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise; deleteBiometricKey(userId: UserId): Promise; getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise; + enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise; + hasPersistentKey(userId: UserId): Promise; } diff --git a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts index c7ed88d390f..bc3631ad1b8 100644 --- a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts @@ -68,4 +68,20 @@ export class RendererBiometricsService extends DesktopBiometricsService { BiometricsStatus.ManualSetupNeeded, ].includes(biometricStatus); } + + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise { + return await ipc.keyManagement.biometric.enrollPersistent(userId, key.toBase64()); + } + + async hasPersistentKey(userId: UserId): Promise { + return await ipc.keyManagement.biometric.hasPersistentKey(userId); + } + + async enableWindowsV2Biometrics(): Promise { + return await ipc.keyManagement.biometric.enableWindowsV2Biometrics(); + } + + async isWindowsV2BiometricsEnabled(): Promise { + return await ipc.keyManagement.biometric.isWindowsV2BiometricsEnabled(); + } } diff --git a/apps/desktop/src/key-management/key-connector/remove-password.component.ts b/apps/desktop/src/key-management/key-connector/remove-password.component.ts index 1b07f04ba8a..d9fea9409f8 100644 --- a/apps/desktop/src/key-management/key-connector/remove-password.component.ts +++ b/apps/desktop/src/key-management/key-connector/remove-password.component.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/key-management-ui"; +// 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: "app-remove-password", templateUrl: "remove-password.component.html", diff --git a/apps/desktop/src/key-management/preload.ts b/apps/desktop/src/key-management/preload.ts index 7f8576b8472..a9565790b86 100644 --- a/apps/desktop/src/key-management/preload.ts +++ b/apps/desktop/src/key-management/preload.ts @@ -50,6 +50,25 @@ const biometric = { action: BiometricAction.SetShouldAutoprompt, data: should, } satisfies BiometricMessage), + enrollPersistent: (userId: string, keyB64: string): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.EnrollPersistent, + userId: userId, + key: keyB64, + } satisfies BiometricMessage), + hasPersistentKey: (userId: string): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.HasPersistentKey, + userId: userId, + } satisfies BiometricMessage), + enableWindowsV2Biometrics: (): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.EnableWindowsV2, + } satisfies BiometricMessage), + isWindowsV2BiometricsEnabled: (): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.IsWindowsV2Enabled, + } satisfies BiometricMessage), }; export default { diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 498b8e6e413..3e004e270a3 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -1852,6 +1852,12 @@ "lockWithMasterPassOnRestart1": { "message": "Lock with master password on restart" }, + "requireMasterPasswordOrPinOnAppRestart": { + "message": "Require master password or PIN on app restart" + }, + "requireMasterPasswordOnAppRestart": { + "message": "Require master password on app restart" + }, "deleteAccount": { "message": "Delete account" }, @@ -4167,7 +4173,7 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, - "itemUnarchived": { + "itemWasUnarchived": { "message": "Item was unarchived" }, "archiveItem": { diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 993084f7724..f8ea7551c47 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -82,7 +82,12 @@ export class WindowMain { ipcMain.on("window-hide", () => { if (this.win != null) { - this.win.hide(); + if (isWindows()) { + // On windows, to return focus we need minimize + this.win.minimize(); + } else { + this.win.hide(); + } } }); diff --git a/apps/desktop/src/services/biometric-message-handler.service.spec.ts b/apps/desktop/src/services/biometric-message-handler.service.spec.ts index ad555729ab3..dec1e63d5e8 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.spec.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.spec.ts @@ -13,13 +13,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; -import { DialogService, I18nMockService } from "@bitwarden/components"; -import { - KeyService, - BiometricsService, - BiometricStateService, - BiometricsCommands, -} from "@bitwarden/key-management"; +import { DialogService } from "@bitwarden/components"; +import { KeyService, BiometricsService, BiometricsCommands } from "@bitwarden/key-management"; +import { ConfigService } from "@bitwarden/services/config.service"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; @@ -47,15 +43,14 @@ describe("BiometricMessageHandlerService", () => { let keyService: MockProxy; let encryptService: MockProxy; let logService: MockProxy; + let configService: MockProxy; let messagingService: MockProxy; let desktopSettingsService: DesktopSettingsService; - let biometricStateService: BiometricStateService; let biometricsService: MockProxy; let dialogService: MockProxy; let accountService: AccountService; let authService: MockProxy; let ngZone: MockProxy; - let i18nService: MockProxy; beforeEach(() => { cryptoFunctionService = mock(); @@ -64,14 +59,13 @@ describe("BiometricMessageHandlerService", () => { logService = mock(); messagingService = mock(); desktopSettingsService = mock(); - biometricStateService = mock(); + configService = mock(); biometricsService = mock(); dialogService = mock(); accountService = new FakeAccountService(accounts); authService = mock(); ngZone = mock(); - i18nService = mock(); desktopSettingsService.browserIntegrationEnabled$ = of(false); desktopSettingsService.browserIntegrationFingerprintEnabled$ = of(false); @@ -94,7 +88,7 @@ describe("BiometricMessageHandlerService", () => { cryptoFunctionService.rsaEncrypt.mockResolvedValue( Utils.fromUtf8ToArray("encrypted") as CsprngArray, ); - + configService.getFeatureFlag.mockResolvedValue(false); service = new BiometricMessageHandlerService( cryptoFunctionService, keyService, @@ -102,13 +96,12 @@ describe("BiometricMessageHandlerService", () => { logService, messagingService, desktopSettingsService, - biometricStateService, biometricsService, dialogService, accountService, authService, ngZone, - i18nService, + configService, ); }); @@ -160,13 +153,12 @@ describe("BiometricMessageHandlerService", () => { logService, messagingService, desktopSettingsService, - biometricStateService, biometricsService, dialogService, accountService, authService, ngZone, - i18nService, + configService, ); }); @@ -511,4 +503,19 @@ describe("BiometricMessageHandlerService", () => { }, ); }); + + describe("init", () => { + it("enables Windows v2 biometrics when feature flag enabled", async () => { + configService.getFeatureFlag.mockReturnValue(true); + + await service.init(); + expect(biometricsService.enableWindowsV2Biometrics).toHaveBeenCalled(); + }); + it("does not enable Windows v2 biometrics when feature flag disabled", async () => { + configService.getFeatureFlag.mockReturnValue(false); + + await service.init(); + expect(biometricsService.enableWindowsV2Biometrics).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/desktop/src/services/biometric-message-handler.service.ts b/apps/desktop/src/services/biometric-message-handler.service.ts index 8b4c3744a8d..6d07c4a2aa0 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.ts @@ -4,25 +4,21 @@ import { combineLatest, concatMap, firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; -import { - BiometricStateService, - BiometricsCommands, - BiometricsService, - BiometricsStatus, - KeyService, -} from "@bitwarden/key-management"; +import { BiometricsCommands, BiometricsStatus, KeyService } from "@bitwarden/key-management"; import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component"; +import { DesktopBiometricsService } from "../key-management/biometrics/desktop.biometrics.service"; import { LegacyMessage, LegacyMessageWrapper } from "../models/native-messaging"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; @@ -82,13 +78,12 @@ export class BiometricMessageHandlerService { private logService: LogService, private messagingService: MessagingService, private desktopSettingService: DesktopSettingsService, - private biometricStateService: BiometricStateService, - private biometricsService: BiometricsService, + private biometricsService: DesktopBiometricsService, private dialogService: DialogService, private accountService: AccountService, private authService: AuthService, private ngZone: NgZone, - private i18nService: I18nService, + private configService: ConfigService, ) { combineLatest([ this.desktopSettingService.browserIntegrationEnabled$, @@ -119,6 +114,19 @@ export class BiometricMessageHandlerService { private connectedApps: ConnectedApps = new ConnectedApps(); + async init() { + this.logService.debug( + "[BiometricMessageHandlerService] Initializing biometric message handler", + ); + + const windowsV2Enabled = await this.configService.getFeatureFlag( + FeatureFlag.WindowsBiometricsV2, + ); + if (windowsV2Enabled) { + await this.biometricsService.enableWindowsV2Biometrics(); + } + } + async handleMessage(msg: LegacyMessageWrapper) { const { appId, message: rawMessage } = msg as LegacyMessageWrapper; diff --git a/apps/desktop/src/types/biometric-message.ts b/apps/desktop/src/types/biometric-message.ts index 9711b49496d..a0a3967f468 100644 --- a/apps/desktop/src/types/biometric-message.ts +++ b/apps/desktop/src/types/biometric-message.ts @@ -13,6 +13,12 @@ export enum BiometricAction { GetShouldAutoprompt = "getShouldAutoprompt", SetShouldAutoprompt = "setShouldAutoprompt", + + EnrollPersistent = "enrollPersistent", + HasPersistentKey = "hasPersistentKey", + + EnableWindowsV2 = "enableWindowsV2", + IsWindowsV2Enabled = "isWindowsV2Enabled", } export type BiometricMessage = @@ -22,7 +28,15 @@ export type BiometricMessage = key: string; } | { - action: Exclude; + action: BiometricAction.EnrollPersistent; + userId: string; + key: string; + } + | { + action: Exclude< + BiometricAction, + BiometricAction.SetKeyForUser | BiometricAction.EnrollPersistent + >; userId?: string; data?: any; }; diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.html b/apps/desktop/src/vault/app/vault/item-footer.component.html index 162335d03bb..859b2f1bdc5 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.html +++ b/apps/desktop/src/vault/app/vault/item-footer.component.html @@ -46,7 +46,23 @@ -
+
+ + +
  • + + + +
  • diff --git a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts index 06654fb1a5c..290a38ac08c 100644 --- a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts @@ -7,6 +7,7 @@ import { distinctUntilChanged, debounceTime } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/vault/components/vault-items.component"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; @@ -33,8 +34,9 @@ export class VaultItemsV2Component extends BaseVaultIt cipherService: CipherService, accountService: AccountService, restrictedItemTypesService: RestrictedItemTypesService, + configService: ConfigService, ) { - super(searchService, cipherService, accountService, restrictedItemTypesService); + super(searchService, cipherService, accountService, restrictedItemTypesService, configService); this.searchBarService.searchText$ .pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed()) diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.html b/apps/desktop/src/vault/app/vault/vault-v2.component.html index a3f55f0ec63..2696dd0d452 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.html +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.html @@ -19,6 +19,7 @@ (onClone)="cloneCipher($event)" (onDelete)="deleteCipher()" (onCancel)="cancelCipher($event)" + (onArchiveToggle)="refreshCurrentCipher()" [masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId" >
    diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index 6dda97807be..b7b0bf2e1b2 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -20,6 +20,8 @@ import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -33,6 +35,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { getByIds } from "@bitwarden/common/platform/misc"; import { SyncService } from "@bitwarden/common/platform/sync"; import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; @@ -74,6 +77,7 @@ import { DefaultCipherFormConfigService, PasswordRepromptService, CipherFormComponent, + ArchiveCipherUtilitiesService, } from "@bitwarden/vault"; import { NavComponent } from "../../../app/layout/nav.component"; @@ -211,6 +215,9 @@ export class VaultV2Component private folderService: FolderService, private configService: ConfigService, private authRequestService: AuthRequestServiceAbstraction, + private cipherArchiveService: CipherArchiveService, + private policyService: PolicyService, + private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, ) {} async ngOnInit() { @@ -490,6 +497,12 @@ export class VaultV2Component async viewCipherMenu(c: CipherViewLike) { const cipher = await this.cipherService.getFullCipherView(c); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const userCanArchive = await firstValueFrom(this.cipherArchiveService.userCanArchive$(userId)); + const orgOwnershipPolicy = await firstValueFrom( + this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId), + ); + const menu: RendererMenuItem[] = [ { label: this.i18nService.t("view"), @@ -514,7 +527,11 @@ export class VaultV2Component }); }, }); - if (!cipher.organizationId) { + + const archivedWithOrgOwnership = cipher.isArchived && orgOwnershipPolicy; + const canCloneArchived = !cipher.isArchived || userCanArchive; + + if (!cipher.organizationId && !archivedWithOrgOwnership && canCloneArchived) { menu.push({ label: this.i18nService.t("clone"), click: () => { @@ -538,6 +555,26 @@ export class VaultV2Component } } + if (userCanArchive && !cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) { + menu.push({ + label: this.i18nService.t("archiveVerb"), + click: async () => { + await this.archiveCipherUtilitiesService.archiveCipher(cipher); + await this.refreshCurrentCipher(); + }, + }); + } + + if (cipher.isArchived) { + menu.push({ + label: this.i18nService.t("unArchive"), + click: async () => { + await this.archiveCipherUtilitiesService.unarchiveCipher(cipher); + await this.refreshCurrentCipher(); + }, + }); + } + switch (cipher.type) { case CipherType.Login: if ( @@ -723,8 +760,6 @@ export class VaultV2Component this.cipherId = cipher.id; this.cipher = cipher; - - await this.vaultItemsComponent?.load(this.activeFilter.buildFilter()).catch(() => {}); await this.go().catch(() => {}); await this.vaultItemsComponent?.refresh().catch(() => {}); } @@ -757,7 +792,11 @@ export class VaultV2Component ); this.activeFilter = vaultFilter; await this.vaultItemsComponent - ?.reload(this.activeFilter.buildFilter(), vaultFilter.status === "trash") + ?.reload( + this.activeFilter.buildFilter(), + vaultFilter.status === "trash", + vaultFilter.status === "archive", + ) .catch(() => {}); await this.go().catch(() => {}); } @@ -831,6 +870,20 @@ export class VaultV2Component } } + /** Refresh the current cipher object */ + protected async refreshCurrentCipher() { + if (!this.cipher) { + return; + } + + this.cipher = await firstValueFrom( + this.cipherService.cipherViews$(this.activeUserId!).pipe( + filter((c) => !!c), + map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null), + ), + ); + } + private dirtyInput(): boolean { return ( (this.action === "add" || this.action === "edit" || this.action === "clone") && diff --git a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts index 0bc6b1effbb..26d0c43ff8f 100644 --- a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts @@ -1,8 +1,11 @@ -import { NgModule } from "@angular/core"; +import { inject, NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { map } from "rxjs"; -import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; +import { componentRouteSwap } from "@bitwarden/angular/utils/component-route-swap"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; @@ -23,15 +26,22 @@ const routes: Routes = [ component: UserSubscriptionComponent, data: { titleId: "premiumMembership" }, }, - ...featureFlaggedRoute({ - defaultComponent: PremiumComponent, - flaggedComponent: PremiumVNextComponent, - featureFlag: FeatureFlag.PM24033PremiumUpgradeNewDesign, - routeOptions: { + ...componentRouteSwap( + PremiumComponent, + PremiumVNextComponent, + () => { + const configService = inject(ConfigService); + const platformUtilsService = inject(PlatformUtilsService); + + return configService + .getFeatureFlag$(FeatureFlag.PM24033PremiumUpgradeNewDesign) + .pipe(map((flagValue) => flagValue === true && !platformUtilsService.isSelfHost())); + }, + { data: { titleId: "goPremium" }, path: "premium", }, - }), + ), { path: "payment-details", component: AccountPaymentDetailsComponent, diff --git a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.ts b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.ts index 3e09f710062..09d00d38dcf 100644 --- a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.ts +++ b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.ts @@ -12,6 +12,8 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { DIALOG_DATA, ToastService } from "@bitwarden/components"; import { KdfConfig, KdfType } from "@bitwarden/key-management"; +// 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: "app-change-kdf-confirmation", templateUrl: "change-kdf-confirmation.component.html", diff --git a/apps/web/src/app/key-management/change-kdf/change-kdf.component.ts b/apps/web/src/app/key-management/change-kdf/change-kdf.component.ts index a059ede77b4..0463c6d4afc 100644 --- a/apps/web/src/app/key-management/change-kdf/change-kdf.component.ts +++ b/apps/web/src/app/key-management/change-kdf/change-kdf.component.ts @@ -18,6 +18,8 @@ import { import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.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({ selector: "app-change-kdf", templateUrl: "change-kdf.component.html", diff --git a/apps/web/src/app/key-management/key-connector/confirm-key-connector-domain.component.ts b/apps/web/src/app/key-management/key-connector/confirm-key-connector-domain.component.ts index 6127bd25a6f..858f3924790 100644 --- a/apps/web/src/app/key-management/key-connector/confirm-key-connector-domain.component.ts +++ b/apps/web/src/app/key-management/key-connector/confirm-key-connector-domain.component.ts @@ -3,6 +3,8 @@ import { Component } from "@angular/core"; import { ConfirmKeyConnectorDomainComponent as BaseConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; import { RouterService } from "@bitwarden/web-vault/app/core"; +// 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: "app-confirm-key-connector-domain", template: ` `, diff --git a/apps/web/src/app/key-management/key-connector/remove-password.component.ts b/apps/web/src/app/key-management/key-connector/remove-password.component.ts index 1b07f04ba8a..d9fea9409f8 100644 --- a/apps/web/src/app/key-management/key-connector/remove-password.component.ts +++ b/apps/web/src/app/key-management/key-connector/remove-password.component.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/key-management-ui"; +// 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: "app-remove-password", templateUrl: "remove-password.component.html", diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts index 0980beddd09..168dbe7442e 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts @@ -1,6 +1,7 @@ import { Injectable } from "@angular/core"; import { firstValueFrom, Observable } from "rxjs"; +import { LogoutService } from "@bitwarden/auth/common"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; @@ -14,7 +15,6 @@ import { WrappedPrivateKey, WrappedSigningKey, } from "@bitwarden/common/key-management/types"; -import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -89,7 +89,7 @@ export class UserKeyRotationService { private syncService: SyncService, private webauthnLoginAdminService: WebauthnLoginAdminService, private logService: LogService, - private vaultTimeoutService: VaultTimeoutService, + private logoutService: LogoutService, private toastService: ToastService, private i18nService: I18nService, private dialogService: DialogService, @@ -189,8 +189,7 @@ export class UserKeyRotationService { timeout: 15000, }); - // temporary until userkey can be better verified - await this.vaultTimeoutService.logOut(); + await this.logoutService.logout(user.id); } protected async ensureIsAllowedToRotateUserKey(): Promise { diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 7ffe69b7ee6..45ed6dc8eb9 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -24,6 +24,11 @@ import { SsoKeyIcon, LockIcon, BrowserExtensionIcon, + ActiveSendIcon, + TwoFactorAuthAuthenticatorIcon, + AccountWarning, + BusinessWelcome, + DomainIcon, } from "@bitwarden/assets/svg"; import { PasswordHintComponent, @@ -295,6 +300,7 @@ const routes: Routes = [ key: "viewSend", }, showReadonlyHostname: true, + pageIcon: ActiveSendIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, children: [ { @@ -314,6 +320,7 @@ const routes: Routes = [ component: SetInitialPasswordComponent, data: { maxWidth: "lg", + pageIcon: LockIcon, } satisfies AnonLayoutWrapperData, }, { @@ -379,6 +386,8 @@ const routes: Routes = [ pageTitle: { key: "verifyYourIdentity", }, + // `TwoFactorAuthComponent` manually sets its icon based on the 2fa type + pageIcon: null, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { @@ -439,6 +448,7 @@ const routes: Routes = [ key: "recoverAccountTwoStep", }, titleId: "recoverAccountTwoStep", + pageIcon: TwoFactorAuthAuthenticatorIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { @@ -469,6 +479,7 @@ const routes: Routes = [ }, titleId: "acceptEmergency", doNotSaveUrl: false, + pageIcon: VaultIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, children: [ { @@ -488,6 +499,7 @@ const routes: Routes = [ key: "deleteAccount", }, titleId: "deleteAccount", + pageIcon: AccountWarning, } satisfies RouteDataProperties & AnonLayoutWrapperData, children: [ { @@ -509,6 +521,7 @@ const routes: Routes = [ key: "deleteAccount", }, titleId: "deleteAccount", + pageIcon: AccountWarning, } satisfies RouteDataProperties & AnonLayoutWrapperData, children: [ { @@ -526,6 +539,7 @@ const routes: Routes = [ key: "removeMasterPassword", }, titleId: "removeMasterPassword", + pageIcon: LockIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { @@ -537,6 +551,7 @@ const routes: Routes = [ key: "confirmKeyConnectorDomain", }, titleId: "confirmKeyConnectorDomain", + pageIcon: DomainIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { @@ -548,6 +563,7 @@ const routes: Routes = [ }, data: { maxWidth: "3xl", + pageIcon: BusinessWelcome, } satisfies AnonLayoutWrapperData, }, { @@ -559,6 +575,7 @@ const routes: Routes = [ }, data: { maxWidth: "3xl", + pageIcon: BusinessWelcome, } satisfies AnonLayoutWrapperData, }, { @@ -582,12 +599,15 @@ const routes: Routes = [ path: "change-password", component: ChangePasswordComponent, canActivate: [authGuard], + data: { + pageIcon: LockIcon, + } satisfies AnonLayoutWrapperData, }, { path: "setup-extension", data: { hideCardWrapper: true, - hideIcon: true, + pageIcon: null, maxWidth: "4xl", } satisfies AnonLayoutWrapperData, children: [ diff --git a/apps/web/src/app/tools/credential-generator/credential-generator.component.ts b/apps/web/src/app/tools/credential-generator/credential-generator.component.ts index 7d62bff0ac1..093afa10bb5 100644 --- a/apps/web/src/app/tools/credential-generator/credential-generator.component.ts +++ b/apps/web/src/app/tools/credential-generator/credential-generator.component.ts @@ -9,6 +9,8 @@ import { import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; +// 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: "credential-generator", templateUrl: "credential-generator.component.html", diff --git a/apps/web/src/app/tools/import/import-web.component.ts b/apps/web/src/app/tools/import/import-web.component.ts index 17c586a1b9d..e7e57870bd9 100644 --- a/apps/web/src/app/tools/import/import-web.component.ts +++ b/apps/web/src/app/tools/import/import-web.component.ts @@ -15,6 +15,8 @@ import { safeProvider } from "@bitwarden/ui-common"; import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "import-web.component.html", imports: [SharedModule, ImportComponent, HeaderModule], diff --git a/apps/web/src/app/tools/import/org-import.component.ts b/apps/web/src/app/tools/import/org-import.component.ts index 4bc348f8769..b3e007fed25 100644 --- a/apps/web/src/app/tools/import/org-import.component.ts +++ b/apps/web/src/app/tools/import/org-import.component.ts @@ -27,6 +27,8 @@ import { SharedModule } from "../../shared"; import { ImportCollectionAdminService } from "./import-collection-admin.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({ templateUrl: "org-import.component.html", imports: [SharedModule, ImportComponent, HeaderModule], diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts index bb72ff75a39..80d1d0e1e12 100644 --- a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts @@ -10,6 +10,8 @@ import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { ButtonModule, DialogService, MenuModule } from "@bitwarden/components"; import { DefaultSendFormConfigService, SendAddEditDialogComponent } from "@bitwarden/send-ui"; +// 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: "tools-new-send-dropdown", templateUrl: "new-send-dropdown.component.html", @@ -21,6 +23,8 @@ import { DefaultSendFormConfigService, SendAddEditDialogComponent } from "@bitwa */ export class NewSendDropdownComponent { /** If true, the plus icon will be hidden */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hideIcon: boolean = false; /** SendType provided for the markup to pass back the selected type of Send */ diff --git a/apps/web/src/app/tools/send/send-access/access.component.html b/apps/web/src/app/tools/send/send-access/access.component.html index 16c1fcf0747..aec6e2a10b9 100644 --- a/apps/web/src/app/tools/send/send-access/access.component.html +++ b/apps/web/src/app/tools/send/send-access/access.component.html @@ -11,12 +11,12 @@ (setPasswordEvent)="setPassword($event)" *ngIf="passwordRequired && !error" > - - {{ "sendAccessUnavailable" | i18n }} - - - {{ "unexpectedErrorSend" | i18n }} - +
    +

    {{ "sendAccessUnavailable" | i18n }}

    +
    +
    +

    {{ "unexpectedErrorSend" | i18n }}

    +

    {{ send.name }} diff --git a/apps/web/src/app/tools/send/send-access/access.component.ts b/apps/web/src/app/tools/send/send-access/access.component.ts index a21644da924..273f1c8c979 100644 --- a/apps/web/src/app/tools/send/send-access/access.component.ts +++ b/apps/web/src/app/tools/send/send-access/access.component.ts @@ -4,7 +4,6 @@ import { Component, OnInit } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { ActiveSendIcon } from "@bitwarden/assets/svg"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -17,7 +16,7 @@ import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; -import { AnonLayoutWrapperDataService, NoItemsModule, ToastService } from "@bitwarden/components"; +import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { SharedModule } from "../../../shared"; @@ -26,6 +25,8 @@ import { SendAccessFileComponent } from "./send-access-file.component"; import { SendAccessPasswordComponent } from "./send-access-password.component"; import { SendAccessTextComponent } from "./send-access-text.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({ selector: "app-send-access", templateUrl: "access.component.html", @@ -34,7 +35,6 @@ import { SendAccessTextComponent } from "./send-access-text.component"; SendAccessTextComponent, SendAccessPasswordComponent, SharedModule, - NoItemsModule, ], }) export class AccessComponent implements OnInit { @@ -49,7 +49,6 @@ export class AccessComponent implements OnInit { protected hideEmail = false; protected decKey: SymmetricCryptoKey; protected accessRequest: SendAccessRequest; - protected sendIcon = ActiveSendIcon; protected formGroup = this.formBuilder.group({}); diff --git a/apps/web/src/app/tools/send/send-access/send-access-explainer.component.ts b/apps/web/src/app/tools/send/send-access/send-access-explainer.component.ts index d9f35a3d38e..c5baa98ebbb 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-explainer.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-access-explainer.component.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { SharedModule } from "../../../shared"; +// 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: "app-send-access-explainer", templateUrl: "send-access-explainer.component.html", diff --git a/apps/web/src/app/tools/send/send-access/send-access-file.component.ts b/apps/web/src/app/tools/send/send-access/send-access-file.component.ts index 239861dd244..dc7689f011a 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-file.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-access-file.component.ts @@ -15,14 +15,22 @@ import { ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; +// 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: "app-send-access-file", templateUrl: "send-access-file.component.html", imports: [SharedModule], }) export class SendAccessFileComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() send: SendAccessView; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() decKey: SymmetricCryptoKey; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() accessRequest: SendAccessRequest; constructor( private i18nService: I18nService, diff --git a/apps/web/src/app/tools/send/send-access/send-access-password.component.ts b/apps/web/src/app/tools/send/send-access/send-access-password.component.ts index 81e66c8acc4..34b183be10e 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-password.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-access-password.component.ts @@ -6,6 +6,8 @@ import { Subject, takeUntil } from "rxjs"; import { SharedModule } from "../../../shared"; +// 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: "app-send-access-password", templateUrl: "send-access-password.component.html", @@ -17,7 +19,11 @@ export class SendAccessPasswordComponent implements OnInit, OnDestroy { password: ["", [Validators.required]], }); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() loading: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() setPasswordEvent = new EventEmitter(); constructor(private formBuilder: FormBuilder) {} diff --git a/apps/web/src/app/tools/send/send-access/send-access-text.component.ts b/apps/web/src/app/tools/send/send-access/send-access-text.component.ts index 2b5405a3f27..794cfbc9678 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-text.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-access-text.component.ts @@ -10,6 +10,8 @@ import { ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; +// 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: "app-send-access-text", templateUrl: "send-access-text.component.html", @@ -34,6 +36,8 @@ export class SendAccessTextComponent { return this._send; } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set send(value: SendAccessView) { this._send = value; this.showText = this.send.text != null ? !this.send.text.hidden : true; diff --git a/apps/web/src/app/tools/send/send.component.ts b/apps/web/src/app/tools/send/send.component.ts index 20b93a10975..e9ad7aee1f1 100644 --- a/apps/web/src/app/tools/send/send.component.ts +++ b/apps/web/src/app/tools/send/send.component.ts @@ -39,6 +39,8 @@ import { NewSendDropdownComponent } from "./new-send/new-send-dropdown.component const BroadcasterSubscriptionId = "SendComponent"; +// 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: "app-send", imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule, NewSendDropdownComponent], diff --git a/apps/web/src/app/tools/vault-export/export-web.component.ts b/apps/web/src/app/tools/vault-export/export-web.component.ts index bf29e83b893..1b11a2dd36f 100644 --- a/apps/web/src/app/tools/vault-export/export-web.component.ts +++ b/apps/web/src/app/tools/vault-export/export-web.component.ts @@ -6,6 +6,8 @@ import { ExportComponent } from "@bitwarden/vault-export-ui"; import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "export-web.component.html", imports: [SharedModule, ExportComponent, HeaderModule], diff --git a/apps/web/src/app/tools/vault-export/org-vault-export.component.ts b/apps/web/src/app/tools/vault-export/org-vault-export.component.ts index 69dcce52823..19216e9e8e9 100644 --- a/apps/web/src/app/tools/vault-export/org-vault-export.component.ts +++ b/apps/web/src/app/tools/vault-export/org-vault-export.component.ts @@ -7,6 +7,8 @@ import { ExportComponent } from "@bitwarden/vault-export-ui"; import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "org-vault-export.component.html", imports: [SharedModule, ExportComponent, HeaderModule], diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts index f755c83832f..fbf61f9a277 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts @@ -138,7 +138,6 @@ describe("SetupExtensionComponent", () => { key: "somethingWentWrong", }, pageIcon: BrowserExtensionIcon, - hideIcon: false, hideCardWrapper: false, maxWidth: "md", }); diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts index a04c529004c..012ac370c70 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts @@ -164,7 +164,6 @@ export class SetupExtensionComponent implements OnInit, OnDestroy { key: "somethingWentWrong", }, pageIcon: BrowserExtensionIcon, - hideIcon: false, hideCardWrapper: false, maxWidth: "md", }); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 5eab55e2301..1f8c4ec55b2 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -154,6 +154,15 @@ } } }, + "newPasswordsAtRisk": { + "message": "$COUNT$ new passwords at-risk", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "notifiedMembersWithCount": { "message": "Notified members ($COUNT$)", "placeholders": { diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts index c0a5778dfb5..97db491823c 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/view/all-activities.service.ts @@ -76,7 +76,9 @@ export class AllActivitiesService { } setAllAppsReportDetails(applications: ApplicationHealthReportDetailEnriched[]) { - const totalAtRiskPasswords = applications.reduce( + // Only count at-risk passwords for CRITICAL applications + const criticalApps = applications.filter((app) => app.isMarkedAsCritical); + const totalAtRiskPasswords = criticalApps.reduce( (sum, app) => sum + app.atRiskPasswordCount, 0, ); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.html index 718efc4c67e..674bc0b5c62 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.html @@ -21,7 +21,11 @@

    - {{ "countOfAtRiskPasswords" | i18n: atRiskPasswordsCount }} + {{ + hasExistingTasks + ? ("newPasswordsAtRisk" | i18n: newAtRiskPasswordsCount) + : ("countOfAtRiskPasswords" | i18n: atRiskPasswordsCount) + }}
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts index d9bfe2c8075..910b326c662 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { Component, OnInit, ChangeDetectionStrategy } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { Subject, switchMap, takeUntil, of, BehaviorSubject, combineLatest } from "rxjs"; @@ -9,6 +9,7 @@ import { ApplicationHealthReportDetailEnriched, SecurityTasksApiService, TaskMetrics, + OrganizationReportSummary, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { ButtonModule, ProgressModule, TypographyModule } from "@bitwarden/components"; @@ -18,6 +19,7 @@ import { RenderMode } from "../../models/activity.models"; import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service"; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: "dirt-password-change-metric", imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule], templateUrl: "./password-change-metric.component.html", @@ -73,25 +75,8 @@ export class PasswordChangeMetricComponent implements OnInit { this.totalTasks = taskMetrics.totalTasks; this.allApplicationsDetails = allApplicationsDetails; - // No critical apps setup - this.renderMode = - summary.totalCriticalApplicationCount === 0 ? RenderMode.noCriticalApps : this.renderMode; - - // Critical apps setup with at-risk apps but no tasks - this.renderMode = - summary.totalCriticalApplicationCount > 0 && - summary.totalCriticalAtRiskApplicationCount >= 0 && - taskMetrics.totalTasks === 0 - ? RenderMode.criticalAppsWithAtRiskAppsAndNoTasks - : this.renderMode; - - // Critical apps setup with at-risk apps and tasks - this.renderMode = - summary.totalAtRiskApplicationCount > 0 && - summary.totalCriticalAtRiskApplicationCount >= 0 && - taskMetrics.totalTasks > 0 - ? RenderMode.criticalAppsWithAtRiskAppsAndTasks - : this.renderMode; + // Determine render mode based on state + this.renderMode = this.determineRenderMode(summary, taskMetrics, atRiskPasswordsCount); this.allActivitiesService.setPasswordChangeProgressMetricHasProgressBar( this.renderMode === RenderMode.criticalAppsWithAtRiskAppsAndTasks, @@ -99,6 +84,38 @@ export class PasswordChangeMetricComponent implements OnInit { }); } + private determineRenderMode( + summary: OrganizationReportSummary, + taskMetrics: TaskMetrics, + atRiskPasswordsCount: number, + ): RenderMode { + // State 1: No critical apps setup + if (summary.totalCriticalApplicationCount === 0) { + return RenderMode.noCriticalApps; + } + + // State 2: Critical apps with at-risk passwords but no tasks assigned yet + // OR tasks exist but NEW at-risk passwords detected (more at-risk passwords than tasks) + if ( + summary.totalCriticalApplicationCount > 0 && + (taskMetrics.totalTasks === 0 || atRiskPasswordsCount > taskMetrics.totalTasks) + ) { + return RenderMode.criticalAppsWithAtRiskAppsAndNoTasks; + } + + // State 3: Critical apps with at-risk apps and tasks (progress tracking) + if ( + summary.totalCriticalApplicationCount > 0 && + taskMetrics.totalTasks > 0 && + atRiskPasswordsCount <= taskMetrics.totalTasks + ) { + return RenderMode.criticalAppsWithAtRiskAppsAndTasks; + } + + // Default to no critical apps + return RenderMode.noCriticalApps; + } + get completedPercent(): number { if (this.totalTasks === 0) { return 0; @@ -137,7 +154,19 @@ export class PasswordChangeMetricComponent implements OnInit { } get canAssignTasks(): boolean { - return this.atRiskAppsCount > this.totalTasks ? true : false; + return this.atRiskPasswordsCount > this.totalTasks; + } + + get hasExistingTasks(): boolean { + return this.totalTasks > 0; + } + + get newAtRiskPasswordsCount(): number { + // Calculate new at-risk passwords as the difference between current count and tasks created + if (this.atRiskPasswordsCount > this.totalTasks) { + return this.atRiskPasswordsCount - this.totalTasks; + } + return 0; } get renderModes() { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts index c680eaba84e..27a42f7225f 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.spec.ts @@ -40,6 +40,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { const organizationId = "org-1" as OrganizationId; const apps = [ { + isMarkedAsCritical: true, atRiskPasswordCount: 1, atRiskCipherIds: ["cid1"], } as ApplicationHealthReportDetailEnriched, @@ -56,10 +57,12 @@ describe("AccessIntelligenceSecurityTasksService", () => { const organizationId = "org-2" as OrganizationId; const apps = [ { + isMarkedAsCritical: true, atRiskPasswordCount: 2, atRiskCipherIds: ["cid1", "cid2"], } as ApplicationHealthReportDetailEnriched, { + isMarkedAsCritical: true, atRiskPasswordCount: 1, atRiskCipherIds: ["cid2"], } as ApplicationHealthReportDetailEnriched, @@ -85,6 +88,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { const organizationId = "org-3" as OrganizationId; const apps = [ { + isMarkedAsCritical: true, atRiskPasswordCount: 1, atRiskCipherIds: ["cid3"], } as ApplicationHealthReportDetailEnriched, @@ -106,6 +110,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { const organizationId = "org-4" as OrganizationId; const apps = [ { + isMarkedAsCritical: true, atRiskPasswordCount: 0, atRiskCipherIds: ["cid4"], } as ApplicationHealthReportDetailEnriched, @@ -115,5 +120,20 @@ describe("AccessIntelligenceSecurityTasksService", () => { expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []); expect(result).toBe(0); }); + + it("should not create any tasks for non-critical apps", async () => { + const organizationId = "org-5" as OrganizationId; + const apps = [ + { + isMarkedAsCritical: false, + atRiskPasswordCount: 2, + atRiskCipherIds: ["cid5", "cid6"], + } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ]; + const result = await service.requestPasswordChange(organizationId, apps); + + expect(defaultAdminTaskServiceSpy.bulkCreateTasks).toHaveBeenCalledWith(organizationId, []); + expect(result).toBe(0); + }); }); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts index fd28ac5aea6..4d7a41007eb 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/security-tasks.service.ts @@ -30,8 +30,9 @@ export class AccessIntelligenceSecurityTasksService { organizationId: OrganizationId, apps: ApplicationHealthReportDetailEnriched[], ): Promise { + // Only create tasks for CRITICAL applications with at-risk passwords const cipherIds = apps - .filter((_) => _.atRiskPasswordCount > 0) + .filter((_) => _.isMarkedAsCritical && _.atRiskPasswordCount > 0) .flatMap((app) => app.atRiskCipherIds); const distinctCipherIds = Array.from(new Set(cipherIds)); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html index 4367c6c882e..423b0130385 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.html @@ -15,6 +15,17 @@ class="tw-block tw-mx-auto tw-h-auto tw-max-w-full tw-max-h-full" />
    + + @if (linkURL) { + + + }

    @@ -42,16 +53,6 @@ } - @if (linkURL) { - - - } @if (showNewBadge()) { {{ "new" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts index 539da9b31b1..1a684d4094b 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts @@ -269,25 +269,24 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { if (this.isEventBasedIntegrationsEnabled) { const crowdstrikeIntegration: Integration = { name: OrganizationIntegrationServiceType.CrowdStrike, - linkURL: "", + linkURL: "https://bitwarden.com/help/crowdstrike-siem/", image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg", type: IntegrationType.EVENT, description: "crowdstrikeEventIntegrationDesc", canSetupConnection: true, - integrationType: 5, // Assuming 5 corresponds to CrowdStrike in OrganizationIntegrationType + integrationType: OrganizationIntegrationType.Hec, }; this.integrationsList.push(crowdstrikeIntegration); const datadogIntegration: Integration = { name: OrganizationIntegrationServiceType.Datadog, - // TODO: Update link when help article is published - linkURL: "", + linkURL: "https://bitwarden.com/help/datadog-siem/", image: "../../../../../../../images/integrations/logo-datadog-color.svg", type: IntegrationType.EVENT, description: "datadogEventIntegrationDesc", canSetupConnection: true, - integrationType: 6, // Assuming 6 corresponds to Datadog in OrganizationIntegrationType + integrationType: OrganizationIntegrationType.Datadog, }; this.integrationsList.push(datadogIntegration); diff --git a/eslint.config.mjs b/eslint.config.mjs index 07bc5951786..d8b2094c37c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -72,6 +72,9 @@ export default tseslint.config( "@angular-eslint/no-output-on-prefix": 0, "@angular-eslint/no-output-rename": 0, "@angular-eslint/no-outputs-metadata-property": 0, + "@angular-eslint/prefer-on-push-component-change-detection": "warn", + "@angular-eslint/prefer-output-emitter-ref": "warn", + "@angular-eslint/prefer-signals": "warn", "@angular-eslint/prefer-standalone": 0, "@angular-eslint/use-lifecycle-interface": "error", "@angular-eslint/use-pipe-transform-interface": 0, @@ -322,7 +325,12 @@ export default tseslint.config( }, // Tailwind migrated clients & libs { - files: ["apps/web/**/*.html", "bitwarden_license/bit-web/**/*.html", "libs/**/*.html"], + files: [ + "apps/web/**/*.html", + "apps/browser/**/*.html", + "bitwarden_license/bit-web/**/*.html", + "libs/**/*.html", + ], rules: { "tailwindcss/no-custom-classname": [ "error", diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index f5642f45b2e..5d2a23444f0 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -891,7 +891,7 @@ const safeProviders: SafeProvider[] = [ LogService, BiometricsService, LOCKED_CALLBACK, - LOGOUT_CALLBACK, + LogoutService, ], }), safeProvider({ diff --git a/libs/angular/src/tools/password-strength/password-strength-v2.component.ts b/libs/angular/src/tools/password-strength/password-strength-v2.component.ts index c8a3b071746..a2571950936 100644 --- a/libs/angular/src/tools/password-strength/password-strength-v2.component.ts +++ b/libs/angular/src/tools/password-strength/password-strength-v2.component.ts @@ -17,6 +17,8 @@ export type PasswordStrengthScore = 0 | 1 | 2 | 3 | 4; type SizeTypes = "small" | "default" | "large"; type BackgroundTypes = "danger" | "primary" | "success" | "warning"; +// 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: "tools-password-strength", templateUrl: "password-strength-v2.component.html", @@ -27,24 +29,34 @@ export class PasswordStrengthV2Component implements OnChanges { * The size (height) of the password strength component. * Possible values are "default", "small" and "large". */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() size: SizeTypes = "default"; /** * Determines whether to show the password strength score text on the progress bar or not. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showText = false; /** * Optional email address which can be used as input for the password strength calculation */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() email: string; /** * Optional name which can be used as input for the password strength calculation */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() name: string; /** * Sets the password value and updates the password strength. * * @param value - password provided by the hosting component */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set password(value: string) { this.updatePasswordStrength(value); } @@ -55,11 +67,15 @@ export class PasswordStrengthV2Component implements OnChanges { * The password strength score represents the strength of a password. * It is emitted as an event when the password strength changes. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() passwordStrengthScore = new EventEmitter(); /** * Emits an event with the password score text and color. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() passwordScoreTextWithColor = new EventEmitter(); passwordScore: PasswordStrengthScore; diff --git a/libs/angular/src/tools/send/add-edit.component.ts b/libs/angular/src/tools/send/add-edit.component.ts index f87b5f9bf86..1680182f9de 100644 --- a/libs/angular/src/tools/send/add-edit.component.ts +++ b/libs/angular/src/tools/send/add-edit.component.ts @@ -56,11 +56,21 @@ interface DatePresetSelectOption { @Directive() export class AddEditComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() sendId: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() type: SendType; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSavedSend = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onDeletedSend = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onCancelled = new EventEmitter(); deletionDatePresets: DatePresetSelectOption[] = [ diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index 8ae4acfb687..414ec1509ed 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -11,11 +11,14 @@ import { of, shareReplay, switchMap, + take, } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -37,6 +40,7 @@ export class VaultItemsComponent implements OnDestroy loaded = false; ciphers: C[] = []; deleted = false; + archived = false; organization: Organization; CipherType = CipherType; @@ -73,13 +77,24 @@ export class VaultItemsComponent implements OnDestroy this._filter$.next(value); } + private archiveFeatureEnabled = false; + constructor( protected searchService: SearchService, protected cipherService: CipherService, protected accountService: AccountService, protected restrictedItemTypesService: RestrictedItemTypesService, + private configService: ConfigService, ) { this.subscribeToCiphers(); + + // Check if archive feature flag is enabled + this.configService + .getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive) + .pipe(takeUntilDestroyed(), take(1)) + .subscribe((isEnabled) => { + this.archiveFeatureEnabled = isEnabled; + }); } ngOnDestroy(): void { @@ -87,19 +102,20 @@ export class VaultItemsComponent implements OnDestroy this.destroy$.complete(); } - async load(filter: (cipher: C) => boolean = null, deleted = false) { + async load(filter: (cipher: C) => boolean = null, deleted = false, archived = false) { this.deleted = deleted ?? false; + this.archived = archived; await this.applyFilter(filter); this.loaded = true; } - async reload(filter: (cipher: C) => boolean = null, deleted = false) { + async reload(filter: (cipher: C) => boolean = null, deleted = false, archived = false) { this.loaded = false; - await this.load(filter, deleted); + await this.load(filter, deleted, archived); } async refresh() { - await this.reload(this.filter, this.deleted); + await this.reload(this.filter, this.deleted, this.archived); } async applyFilter(filter: (cipher: C) => boolean = null) { @@ -125,6 +141,16 @@ export class VaultItemsComponent implements OnDestroy protected deletedFilter: (cipher: C) => boolean = (c) => CipherViewLikeUtils.isDeleted(c) === this.deleted; + protected archivedFilter: (cipher: C) => boolean = (c) => { + // When the archive feature is not enabled, + // always return true to avoid filtering out any items. + if (!this.archiveFeatureEnabled) { + return true; + } + + return CipherViewLikeUtils.isArchived(c) === this.archived; + }; + /** * Creates stream of dependencies that results in the list of ciphers to display * within the vault list. @@ -158,7 +184,7 @@ export class VaultItemsComponent implements OnDestroy return this.searchService.searchCiphers( userId, searchText, - [filter, this.deletedFilter, restrictedTypeFilter], + [filter, this.deletedFilter, this.archivedFilter, restrictedTypeFilter], allCiphers, ); }), diff --git a/libs/angular/src/vault/services/custom-nudges-services/new-item-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/new-item-nudge.service.ts index e89b877562a..4afc356a095 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/new-item-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/new-item-nudge.service.ts @@ -1,5 +1,5 @@ import { inject, Injectable } from "@angular/core"; -import { combineLatest, Observable, switchMap } from "rxjs"; +import { combineLatest, filter, Observable, switchMap } from "rxjs"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -20,7 +20,7 @@ export class NewItemNudgeService extends DefaultSingleNudgeService { nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { return combineLatest([ this.getNudgeStatus$(nudgeType, userId), - this.cipherService.cipherViews$(userId), + this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)), ]).pipe( switchMap(async ([nudgeStatus, ciphers]) => { if (nudgeStatus.hasSpotlightDismissed) { diff --git a/libs/angular/src/vault/vault-filter/components/status-filter.component.ts b/libs/angular/src/vault/vault-filter/components/status-filter.component.ts index ba3842a6e11..dc6a90f928d 100644 --- a/libs/angular/src/vault/vault-filter/components/status-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/status-filter.component.ts @@ -9,11 +9,12 @@ import { VaultFilter } from "../models/vault-filter.model"; export class StatusFilterComponent { @Input() hideFavorites = false; @Input() hideTrash = false; + @Input() hideArchive = false; @Output() onFilterChange: EventEmitter = new EventEmitter(); @Input() activeFilter: VaultFilter; get show() { - return !(this.hideFavorites && this.hideTrash); + return !(this.hideFavorites && this.hideTrash && this.hideArchive); } applyFilter(cipherStatus: CipherStatus) { diff --git a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts index 0b0cb14bbb8..9199c53bfcb 100644 --- a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts @@ -10,6 +10,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; @@ -42,9 +43,12 @@ export class VaultFilterComponent implements OnInit { collections: DynamicTreeNode; folders$: Observable>; + protected showArchiveVaultFilter = false; + constructor( protected vaultFilterService: DeprecatedVaultFilterService, protected accountService: AccountService, + protected cipherArchiveService: CipherArchiveService, ) {} get displayCollections() { @@ -65,6 +69,15 @@ export class VaultFilterComponent implements OnInit { } this.folders$ = await this.vaultFilterService.buildNestedFolders(); this.collections = await this.initCollections(); + + const userCanArchive = await firstValueFrom( + this.cipherArchiveService.userCanArchive$(this.activeUserId), + ); + const showArchiveVault = await firstValueFrom( + this.cipherArchiveService.showArchiveVault$(this.activeUserId), + ); + + this.showArchiveVaultFilter = userCanArchive || showArchiveVault; this.isLoaded = true; } diff --git a/libs/angular/src/vault/vault-filter/models/cipher-status.model.ts b/libs/angular/src/vault/vault-filter/models/cipher-status.model.ts index f93cd8b2107..c05484a55fc 100644 --- a/libs/angular/src/vault/vault-filter/models/cipher-status.model.ts +++ b/libs/angular/src/vault/vault-filter/models/cipher-status.model.ts @@ -1 +1 @@ -export type CipherStatus = "all" | "favorites" | "trash"; +export type CipherStatus = "all" | "favorites" | "trash" | "archive"; diff --git a/libs/angular/src/vault/vault-filter/models/vault-filter.model.spec.ts b/libs/angular/src/vault/vault-filter/models/vault-filter.model.spec.ts index ea5e9eb9b24..a2f8aa7a352 100644 --- a/libs/angular/src/vault/vault-filter/models/vault-filter.model.spec.ts +++ b/libs/angular/src/vault/vault-filter/models/vault-filter.model.spec.ts @@ -56,6 +56,34 @@ describe("VaultFilter", () => { }); }); + describe("given a archived cipher", () => { + const cipher = createCipher({ archivedDate: new Date() }); + + it("should return true when filtering for archive", () => { + const filterFunction = createFilterFunction({ status: "archive" }); + + const result = filterFunction(cipher); + + expect(result).toBe(true); + }); + + it("should return false when filtering for favorites", () => { + const filterFunction = createFilterFunction({ status: "favorites" }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + + it("should return false when filtering for trash", () => { + const filterFunction = createFilterFunction({ status: "trash" }); + + const result = filterFunction(cipher); + + expect(result).toBe(false); + }); + }); + describe("given a cipher with type", () => { it("should return true when filter matches cipher type", () => { const cipher = createCipher({ type: CipherType.Identity }); @@ -103,12 +131,12 @@ describe("VaultFilter", () => { }); describe("given a cipher without folder", () => { - const cipher = createCipher({ folderId: null }); + const cipher = createCipher({ folderId: undefined }); it("should return true when filtering on unassigned folder", () => { const filterFunction = createFilterFunction({ selectedFolder: true, - selectedFolderId: null, + selectedFolderId: undefined, }); const result = filterFunction(cipher); @@ -175,7 +203,7 @@ describe("VaultFilter", () => { it("should return true when filtering for unassigned collection", () => { const filterFunction = createFilterFunction({ selectedCollection: true, - selectedCollectionId: null, + selectedCollectionId: undefined, }); const result = filterFunction(cipher); @@ -195,12 +223,12 @@ describe("VaultFilter", () => { }); describe("given an individual cipher (without organization or collection)", () => { - const cipher = createCipher({ organizationId: null, collectionIds: [] }); + const cipher = createCipher({ organizationId: undefined, collectionIds: [] }); it("should return false when filtering for unassigned collection", () => { const filterFunction = createFilterFunction({ selectedCollection: true, - selectedCollectionId: null, + selectedCollectionId: undefined, }); const result = filterFunction(cipher); @@ -209,7 +237,7 @@ describe("VaultFilter", () => { }); it("should return true when filtering for my vault only", () => { - const cipher = createCipher({ organizationId: null }); + const cipher = createCipher({ organizationId: undefined }); const filterFunction = createFilterFunction({ myVaultOnly: true, }); @@ -230,11 +258,12 @@ function createCipher(options: Partial = {}) { const cipher = new CipherView(); cipher.favorite = options.favorite ?? false; - cipher.deletedDate = options.deletedDate; - cipher.type = options.type; - cipher.folderId = options.folderId; - cipher.collectionIds = options.collectionIds; - cipher.organizationId = options.organizationId; + cipher.deletedDate = options.deletedDate ?? null; + cipher.archivedDate = options.archivedDate ?? null; + cipher.type = options.type ?? CipherType.Login; + cipher.folderId = options.folderId ?? undefined; + cipher.collectionIds = options.collectionIds ?? []; + cipher.organizationId = options.organizationId ?? undefined; return cipher; } diff --git a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts index bade2244ff0..87536036644 100644 --- a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts +++ b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts @@ -50,6 +50,9 @@ export class VaultFilter { if (this.status === "trash" && cipherPassesFilter) { cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher); } + if (this.status === "archive" && cipherPassesFilter) { + cipherPassesFilter = CipherViewLikeUtils.isArchived(cipher); + } if (this.cipherType != null && cipherPassesFilter) { cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType; } diff --git a/libs/assets/src/svg/svgs/account-warning.icon.ts b/libs/assets/src/svg/svgs/account-warning.icon.ts new file mode 100644 index 00000000000..80e29dad870 --- /dev/null +++ b/libs/assets/src/svg/svgs/account-warning.icon.ts @@ -0,0 +1,18 @@ +import { svgIcon } from "../icon-service"; + +export const AccountWarning = svgIcon` + + + + + + + + + + + + + + +`; diff --git a/libs/assets/src/svg/svgs/business-welcome.icon.ts b/libs/assets/src/svg/svgs/business-welcome.icon.ts new file mode 100644 index 00000000000..06c4950ec18 --- /dev/null +++ b/libs/assets/src/svg/svgs/business-welcome.icon.ts @@ -0,0 +1,32 @@ +import { svgIcon } from "../icon-service"; + +export const BusinessWelcome = svgIcon` + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/libs/assets/src/svg/svgs/index.ts b/libs/assets/src/svg/svgs/index.ts index 40a91f78d4d..3c6fe4b8306 100644 --- a/libs/assets/src/svg/svgs/index.ts +++ b/libs/assets/src/svg/svgs/index.ts @@ -1,3 +1,4 @@ +export * from "./account-warning.icon"; export * from "./active-send.icon"; export { default as AdminConsoleLogo } from "./admin-console"; export * from "./background-left-illustration"; @@ -6,6 +7,7 @@ export * from "./bitwarden-icon"; export * from "./bitwarden-logo.icon"; export * from "./browser-extension"; export { default as BusinessUnitPortalLogo } from "./business-unit-portal"; +export * from "./business-welcome.icon"; export * from "./carousel-icon"; export * from "./credit-card.icon"; export * from "./deactivated-org"; diff --git a/libs/assets/src/svg/svgs/shield.ts b/libs/assets/src/svg/svgs/shield.ts index eaf9780773e..38d429604aa 100644 --- a/libs/assets/src/svg/svgs/shield.ts +++ b/libs/assets/src/svg/svgs/shield.ts @@ -1,14 +1,5 @@ import { svgIcon } from "../icon-service"; -/** - * Shield logo with extra space in the viewbox. - */ -const AnonLayoutBitwardenShield = svgIcon` - - - -`; - const BitwardenShield = svgIcon` @@ -22,4 +13,4 @@ const BitwardenShield = svgIcon` `; -export { AnonLayoutBitwardenShield, BitwardenShield }; +export { BitwardenShield }; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 0ad63b630f4..6a561d29a0f 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -35,11 +35,14 @@ export enum FeatureFlag { EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation", ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption", + WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2", UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data", + NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change", /* Tools */ DesktopSendUIRefresh = "desktop-send-ui-refresh", UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators", + ChromiumImporterWithABE = "pm-25855-chromium-importer-abe", /* DIRT */ EventBasedOrganizationIntegrations = "event-based-organization-integrations", @@ -85,6 +88,7 @@ export const DefaultFeatureFlagValue = { /* Tools */ [FeatureFlag.DesktopSendUIRefresh]: FALSE, [FeatureFlag.UseSdkPasswordGenerators]: FALSE, + [FeatureFlag.ChromiumImporterWithABE]: FALSE, /* DIRT */ [FeatureFlag.EventBasedOrganizationIntegrations]: FALSE, @@ -115,7 +119,9 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EnrollAeadOnKeyRotation]: FALSE, [FeatureFlag.ForceUpdateKDFSettings]: FALSE, [FeatureFlag.PM25174_DisableType0Decryption]: FALSE, + [FeatureFlag.WindowsBiometricsV2]: FALSE, [FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE, + [FeatureFlag.NoLogoutOnKdfChange]: FALSE, /* Platform */ [FeatureFlag.IpcChannelFramework]: FALSE, diff --git a/libs/common/src/enums/index.ts b/libs/common/src/enums/index.ts index cf9dac80189..000f2a074d8 100644 --- a/libs/common/src/enums/index.ts +++ b/libs/common/src/enums/index.ts @@ -6,3 +6,4 @@ export * from "./http-status-code.enum"; export * from "./integration-type.enum"; export * from "./native-messaging-version.enum"; export * from "./notification-type.enum"; +export * from "./push-notification-logout-reason.enum"; diff --git a/libs/common/src/enums/push-notification-logout-reason.enum.ts b/libs/common/src/enums/push-notification-logout-reason.enum.ts new file mode 100644 index 00000000000..ab269248850 --- /dev/null +++ b/libs/common/src/enums/push-notification-logout-reason.enum.ts @@ -0,0 +1,6 @@ +export const PushNotificationLogOutReasonType = Object.freeze({ + KdfChange: 0, +} as const); + +export type PushNotificationLogOutReasonType = + (typeof PushNotificationLogOutReasonType)[keyof typeof PushNotificationLogOutReasonType]; diff --git a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout.service.ts b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout.service.ts index 1c88a5c51ea..401fb8b107b 100644 --- a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout.service.ts +++ b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout.service.ts @@ -1,5 +1,4 @@ export abstract class VaultTimeoutService { abstract checkVaultTimeout(): Promise; abstract lock(userId?: string): Promise; - abstract logOut(userId?: string): Promise; } diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts index da815f76f79..5ba434f7188 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { MockProxy, any, mock } from "jest-mock-extended"; +import { MockProxy, mock } from "jest-mock-extended"; import { BehaviorSubject, from, of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. @@ -8,7 +8,7 @@ import { BehaviorSubject, from, of } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { LogoutReason } from "@bitwarden/auth/common"; +import { LogoutService } from "@bitwarden/auth/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { BiometricsService } from "@bitwarden/key-management"; @@ -53,8 +53,8 @@ describe("VaultTimeoutService", () => { let taskSchedulerService: MockProxy; let logService: MockProxy; let biometricsService: MockProxy; + let logoutService: MockProxy; let lockedCallback: jest.Mock, [userId: string]>; - let loggedOutCallback: jest.Mock, [logoutReason: LogoutReason, userId?: string]>; let vaultTimeoutActionSubject: BehaviorSubject; let availableVaultTimeoutActionsSubject: BehaviorSubject; @@ -80,9 +80,9 @@ describe("VaultTimeoutService", () => { taskSchedulerService = mock(); logService = mock(); biometricsService = mock(); + logoutService = mock(); lockedCallback = jest.fn(); - loggedOutCallback = jest.fn(); vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock); @@ -110,7 +110,7 @@ describe("VaultTimeoutService", () => { logService, biometricsService, lockedCallback, - loggedOutCallback, + logoutService, ); }); @@ -213,12 +213,12 @@ describe("VaultTimeoutService", () => { }; const expectUserToHaveLoggedOut = (userId: string) => { - expect(loggedOutCallback).toHaveBeenCalledWith("vaultTimeout", userId); + expect(logoutService.logout).toHaveBeenCalledWith(userId, "vaultTimeout"); }; const expectNoAction = (userId: string) => { expect(lockedCallback).not.toHaveBeenCalledWith(userId); - expect(loggedOutCallback).not.toHaveBeenCalledWith(any(), userId); + expect(logoutService.logout).not.toHaveBeenCalledWith(userId, "vaultTimeout"); }; describe("checkVaultTimeout", () => { diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts index 8b523498c31..c0fa0423694 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts @@ -7,7 +7,7 @@ import { combineLatest, concatMap, filter, firstValueFrom, map, timeout } from " import { CollectionService } from "@bitwarden/admin-console/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { LogoutReason } from "@bitwarden/auth/common"; +import { LogoutService } from "@bitwarden/auth/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { BiometricsService } from "@bitwarden/key-management"; @@ -52,10 +52,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { protected logService: LogService, private biometricService: BiometricsService, private lockedCallback: (userId: UserId) => Promise = null, - private loggedOutCallback: ( - logoutReason: LogoutReason, - userId?: string, - ) => Promise = null, + private logoutService: LogoutService, ) { this.taskSchedulerService.registerTaskHandler( ScheduledTaskNames.vaultTimeoutCheckInterval, @@ -123,7 +120,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { ); const supportsLock = availableActions.includes(VaultTimeoutAction.Lock); if (!supportsLock) { - await this.logOut(userId); + await this.logoutService.logout(userId, "vaultTimeout"); } // HACK: Start listening for the transition of the locking user from something to the locked state. @@ -165,12 +162,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { } } - async logOut(userId?: string): Promise { - if (this.loggedOutCallback != null) { - await this.loggedOutCallback("vaultTimeout", userId); - } - } - private async shouldLock( userId: string, lastActive: Date, @@ -214,7 +205,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(userId), ); timeoutAction === VaultTimeoutAction.LogOut - ? await this.logOut(userId) + ? await this.logoutService.logout(userId, "vaultTimeout") : await this.lock(userId); } } diff --git a/libs/common/src/models/request/verify-bank.request.ts b/libs/common/src/models/request/verify-bank.request.ts deleted file mode 100644 index b827b875949..00000000000 --- a/libs/common/src/models/request/verify-bank.request.ts +++ /dev/null @@ -1,6 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -export class VerifyBankRequest { - amount1: number; - amount2: number; -} diff --git a/libs/common/src/models/response/notification.response.ts b/libs/common/src/models/response/notification.response.ts index 56b22fd3117..167864208ee 100644 --- a/libs/common/src/models/response/notification.response.ts +++ b/libs/common/src/models/response/notification.response.ts @@ -1,6 +1,6 @@ import { NotificationViewResponse as EndUserNotificationResponse } from "@bitwarden/common/vault/notifications/models"; -import { NotificationType } from "../../enums"; +import { NotificationType, PushNotificationLogOutReasonType } from "../../enums"; import { BaseResponse } from "./base.response"; @@ -41,9 +41,11 @@ export class NotificationResponse extends BaseResponse { case NotificationType.SyncOrganizations: case NotificationType.SyncOrgKeys: case NotificationType.SyncSettings: - case NotificationType.LogOut: this.payload = new UserNotification(payload); break; + case NotificationType.LogOut: + this.payload = new LogOutNotification(payload); + break; case NotificationType.SyncSendCreate: case NotificationType.SyncSendUpdate: case NotificationType.SyncSendDelete: @@ -184,3 +186,14 @@ export class ProviderBankAccountVerifiedPushNotification extends BaseResponse { this.adminId = this.getResponseProperty("AdminId"); } } + +export class LogOutNotification extends BaseResponse { + userId: string; + reason?: PushNotificationLogOutReasonType; + + constructor(response: any) { + super(response); + this.userId = this.getResponseProperty("UserId"); + this.reason = this.getResponseProperty("Reason"); + } +} diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts index a7b608f5b56..b2aa4fbd315 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts @@ -11,7 +11,7 @@ import { Matrix } from "../../../../spec/matrix"; import { AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; -import { NotificationType } from "../../../enums"; +import { NotificationType, PushNotificationLogOutReasonType } from "../../../enums"; import { NotificationResponse } from "../../../models/response/notification.response"; import { UserId } from "../../../types/guid"; import { AppIdService } from "../../abstractions/app-id.service"; @@ -340,4 +340,56 @@ describe("NotificationsService", () => { expect(webPushNotificationConnectionService.supportStatus$).toHaveBeenCalledTimes(1); subscription.unsubscribe(); }); + + describe("processNotification", () => { + beforeEach(async () => { + appIdService.getAppId.mockResolvedValue("test-app-id"); + activeAccount.next({ id: mockUser1, email: "email", name: "Test Name", emailVerified: true }); + }); + + describe("NotificationType.LogOut", () => { + it.each([ + { featureFlagEnabled: false, reason: undefined }, + { featureFlagEnabled: true, reason: undefined }, + { featureFlagEnabled: false, reason: PushNotificationLogOutReasonType.KdfChange }, + ])( + "should call logout callback when featureFlag=$featureFlagEnabled and reason=$reason", + async ({ featureFlagEnabled, reason }) => { + configService.getFeatureFlag$.mockReturnValue(of(featureFlagEnabled)); + + const payload: { UserId: UserId; Reason?: PushNotificationLogOutReasonType } = { + UserId: mockUser1, + Reason: undefined, + }; + if (reason != null) { + payload.Reason = reason; + } + + const notification = new NotificationResponse({ + type: NotificationType.LogOut, + payload, + contextId: "different-app-id", + }); + + await sut["processNotification"](notification, mockUser1); + + expect(logoutCallback).toHaveBeenCalledWith("logoutNotification", mockUser1); + }, + ); + + it("should skip logout when receiving KDF change reason with feature flag enabled", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + const notification = new NotificationResponse({ + type: NotificationType.LogOut, + payload: { UserId: mockUser1, Reason: PushNotificationLogOutReasonType.KdfChange }, + contextId: "different-app-id", + }); + + await sut["processNotification"](notification, mockUser1); + + expect(logoutCallback).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts index 47af8f5e00c..efe0a8ae408 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts @@ -22,8 +22,9 @@ import { trackedMerge } from "@bitwarden/common/platform/misc"; import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; -import { NotificationType } from "../../../enums"; +import { NotificationType, PushNotificationLogOutReasonType } from "../../../enums"; import { + LogOutNotification, NotificationResponse, SyncCipherNotification, SyncFolderNotification, @@ -263,10 +264,25 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer this.activitySubject.next("inactive"); // Force a disconnect this.activitySubject.next("active"); // Allow a reconnect break; - case NotificationType.LogOut: + case NotificationType.LogOut: { this.logService.info("[Notifications Service] Received logout notification"); - await this.logoutCallback("logoutNotification", userId); + + const logOutNotification = notification.payload as LogOutNotification; + const noLogoutOnKdfChange = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.NoLogoutOnKdfChange), + ); + if ( + noLogoutOnKdfChange && + logOutNotification.reason === PushNotificationLogOutReasonType.KdfChange + ) { + this.logService.info( + "[Notifications Service] Skipping logout due to no logout KDF change", + ); + } else { + await this.logoutCallback("logoutNotification", userId); + } break; + } case NotificationType.SyncSendCreate: case NotificationType.SyncSendUpdate: await this.syncService.syncUpsertSend( diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index c586297d6a5..7412c68d695 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -160,6 +160,10 @@ export class CipherView implements View, InitializerMetadata { } get canAssignToCollections(): boolean { + if (this.isArchived) { + return false; + } + if (this.organizationId == null) { return true; } diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 9b1d8096fc7..e6c22961673 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -923,4 +923,75 @@ describe("Cipher Service", () => { sub.unsubscribe(); }); }); + + describe("getCipherForUrl localData application", () => { + beforeEach(() => { + Object.defineProperty(autofillSettingsService, "autofillOnPageLoadDefault$", { + value: of(true), + writable: true, + }); + }); + + it("should apply localData to ciphers when getCipherForUrl is called via getLastLaunchedForUrl", async () => { + const testUrl = "https://test-url.com"; + const cipherId = "test-cipher-id" as CipherId; + const testLocalData = { + lastLaunched: Date.now().valueOf(), + lastUsedDate: Date.now().valueOf() - 1000, + }; + + jest.spyOn(cipherService, "localData$").mockReturnValue(of({ [cipherId]: testLocalData })); + + const mockCipherView = new CipherView(); + mockCipherView.id = cipherId; + mockCipherView.localData = null; + + jest.spyOn(cipherService, "getAllDecryptedForUrl").mockResolvedValue([mockCipherView]); + + const result = await cipherService.getLastLaunchedForUrl(testUrl, userId, true); + + expect(result.localData).toEqual(testLocalData); + }); + + it("should apply localData to ciphers when getCipherForUrl is called via getLastUsedForUrl", async () => { + const testUrl = "https://test-url.com"; + const cipherId = "test-cipher-id" as CipherId; + const testLocalData = { lastUsedDate: Date.now().valueOf() - 1000 }; + + jest.spyOn(cipherService, "localData$").mockReturnValue(of({ [cipherId]: testLocalData })); + + const mockCipherView = new CipherView(); + mockCipherView.id = cipherId; + mockCipherView.localData = null; + + jest.spyOn(cipherService, "getAllDecryptedForUrl").mockResolvedValue([mockCipherView]); + + const result = await cipherService.getLastUsedForUrl(testUrl, userId, true); + + expect(result.localData).toEqual(testLocalData); + }); + + it("should not modify localData if it already matches in getCipherForUrl", async () => { + const testUrl = "https://test-url.com"; + const cipherId = "test-cipher-id" as CipherId; + const existingLocalData = { + lastLaunched: Date.now().valueOf(), + lastUsedDate: Date.now().valueOf() - 1000, + }; + + jest + .spyOn(cipherService, "localData$") + .mockReturnValue(of({ [cipherId]: existingLocalData })); + + const mockCipherView = new CipherView(); + mockCipherView.id = cipherId; + mockCipherView.localData = existingLocalData; + + jest.spyOn(cipherService, "getAllDecryptedForUrl").mockResolvedValue([mockCipherView]); + + const result = await cipherService.getLastLaunchedForUrl(testUrl, userId, true); + + expect(result.localData).toBe(existingLocalData); + }); + }); }); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 4bdc0d9b9fd..41f94e02cdf 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1947,13 +1947,23 @@ export class CipherService implements CipherServiceAbstraction { autofillOnPageLoad: boolean, ): Promise { const cacheKey = autofillOnPageLoad ? "autofillOnPageLoad-" + url : url; - if (!this.sortedCiphersCache.isCached(cacheKey)) { let ciphers = await this.getAllDecryptedForUrl(url, userId); - if (!ciphers) { + + if (!ciphers?.length) { return null; } + const localData = await firstValueFrom(this.localData$(userId)); + if (localData) { + for (const view of ciphers) { + const data = localData[view.id as CipherId]; + if (data) { + view.localData = data; + } + } + } + if (autofillOnPageLoad) { const autofillOnPageLoadDefault = await this.getAutofillOnPageLoadDefault(); diff --git a/libs/components/src/anon-layout/anon-layout-wrapper-data.service.ts b/libs/components/src/anon-layout/anon-layout-wrapper-data.service.ts index 4135e6e0fd6..4789ac5c46e 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper-data.service.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper-data.service.ts @@ -11,10 +11,10 @@ export abstract class AnonLayoutWrapperDataService { * * @param data - The data to set on the AnonLayoutWrapperComponent to feed into the AnonLayoutComponent. */ - abstract setAnonLayoutWrapperData(data: AnonLayoutWrapperData): void; + abstract setAnonLayoutWrapperData(data: Partial): void; /** * Reactively gets the current AnonLayoutWrapperData. */ - abstract anonLayoutWrapperData$(): Observable; + abstract anonLayoutWrapperData$(): Observable>; } diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.html b/libs/components/src/anon-layout/anon-layout-wrapper.component.html index b08418c39a1..73a3d34261b 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.html +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.html @@ -5,7 +5,6 @@ [showReadonlyHostname]="showReadonlyHostname" [maxWidth]="maxWidth" [hideCardWrapper]="hideCardWrapper" - [hideIcon]="hideIcon" [hideBackgroundIllustration]="hideBackgroundIllustration" > diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts index 5785609189c..a17e11b424c 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -25,13 +25,9 @@ export interface AnonLayoutWrapperData { */ pageSubtitle?: string | Translation | null; /** - * The optional icon to display on the page. + * The icon to display on the page. Pass null to hide the icon. */ - pageIcon?: Icon | null; - /** - * Hides the default Bitwarden shield icon. - */ - hideIcon?: boolean; + pageIcon: Icon | null; /** * Optional flag to either show the optional environment selector (false) or just a readonly hostname (true). */ @@ -59,11 +55,10 @@ export class AnonLayoutWrapperComponent implements OnInit { protected pageTitle?: string | null; protected pageSubtitle?: string | null; - protected pageIcon?: Icon | null; + protected pageIcon: Icon | null = null; protected showReadonlyHostname?: boolean | null; protected maxWidth?: AnonLayoutMaxWidth | null; protected hideCardWrapper?: boolean | null; - protected hideIcon?: boolean | null; protected hideBackgroundIllustration?: boolean | null; constructor( @@ -115,10 +110,6 @@ export class AnonLayoutWrapperComponent implements OnInit { this.pageIcon = firstChildRouteData["pageIcon"]; } - if (firstChildRouteData["hideIcon"] !== undefined) { - this.hideIcon = firstChildRouteData["hideIcon"]; - } - this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]); this.maxWidth = firstChildRouteData["maxWidth"]; this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]); @@ -129,12 +120,12 @@ export class AnonLayoutWrapperComponent implements OnInit { this.anonLayoutWrapperDataService .anonLayoutWrapperData$() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((data: AnonLayoutWrapperData) => { + .subscribe((data: Partial) => { this.setAnonLayoutWrapperData(data); }); } - private setAnonLayoutWrapperData(data: AnonLayoutWrapperData) { + private setAnonLayoutWrapperData(data: Partial) { if (!data) { return; } @@ -166,11 +157,6 @@ export class AnonLayoutWrapperComponent implements OnInit { if (data.hideBackgroundIllustration !== undefined) { this.hideBackgroundIllustration = data.hideBackgroundIllustration; } - - if (data.hideIcon !== undefined) { - this.hideIcon = data.hideIcon; - } - if (data.maxWidth !== undefined) { this.maxWidth = data.maxWidth; } @@ -197,7 +183,6 @@ export class AnonLayoutWrapperComponent implements OnInit { this.showReadonlyHostname = null; this.maxWidth = null; this.hideCardWrapper = null; - this.hideIcon = null; this.hideBackgroundIllustration = null; } } diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts b/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts index 08f04eca9ff..7fc022a5ad9 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts @@ -147,7 +147,9 @@ export const DefaultContentExample: Story = { children: [ { path: "default-example", - data: {}, + data: { + pageIcon: LockIcon, + } satisfies AnonLayoutWrapperData, children: [ { path: "", diff --git a/libs/components/src/anon-layout/anon-layout.component.html b/libs/components/src/anon-layout/anon-layout.component.html index 84ad8742051..f88bdd3f920 100644 --- a/libs/components/src/anon-layout/anon-layout.component.html +++ b/libs/components/src/anon-layout/anon-layout.component.html @@ -14,13 +14,15 @@
    + @let iconInput = icon(); +
    - +
    diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index 9decb7cb4f7..c58b8d7e164 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -12,7 +12,6 @@ import { RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { - AnonLayoutBitwardenShield, BackgroundLeftIllustration, BackgroundRightIllustration, BitwardenLogo, @@ -45,11 +44,10 @@ export class AnonLayoutComponent implements OnInit, OnChanges { readonly title = input(); readonly subtitle = input(); - readonly icon = model(); + readonly icon = model.required(); readonly showReadonlyHostname = input(false); readonly hideLogo = input(false); readonly hideFooter = input(false); - readonly hideIcon = input(false); readonly hideCardWrapper = input(false); readonly hideBackgroundIllustration = input(false); @@ -99,11 +97,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges { this.maxWidth.set(this.maxWidth() ?? "md"); this.hostname = (await firstValueFrom(this.environmentService.environment$)).getHostname(); this.version = await this.platformUtilsService.getApplicationVersion(); - - // If there is no icon input, then use the default icon - if (this.icon() == null) { - this.icon.set(AnonLayoutBitwardenShield); - } } async ngOnChanges(changes: SimpleChanges) { diff --git a/libs/components/src/anon-layout/anon-layout.stories.ts b/libs/components/src/anon-layout/anon-layout.stories.ts index 3593cb4f30e..3994fc28854 100644 --- a/libs/components/src/anon-layout/anon-layout.stories.ts +++ b/libs/components/src/anon-layout/anon-layout.stories.ts @@ -62,12 +62,8 @@ export default { }), ], render: (args) => { - const { useDefaultIcon, icon, ...rest } = args; return { - props: { - ...rest, - icon: useDefaultIcon ? null : icon, - }, + props: args, template: /*html*/ ` (); + protected anonLayoutWrapperDataSubject = new Subject>(); - setAnonLayoutWrapperData(data: AnonLayoutWrapperData): void { + setAnonLayoutWrapperData(data: Partial): void { this.anonLayoutWrapperDataSubject.next(data); } - anonLayoutWrapperData$(): Observable { + anonLayoutWrapperData$(): Observable> { return this.anonLayoutWrapperDataSubject.asObservable(); } } diff --git a/libs/components/src/tooltip/tooltip.component.html b/libs/components/src/tooltip/tooltip.component.html index c75cd5fb0d4..4d354fc2765 100644 --- a/libs/components/src/tooltip/tooltip.component.html +++ b/libs/components/src/tooltip/tooltip.component.html @@ -1,4 +1,3 @@ -
    (); + readonly format = input.required(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() onLoadProfilesFromBrowser: (browser: string) => Promise; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() onImportFromBrowser: (browser: string, profile: string) => Promise; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() csvDataLoaded = new EventEmitter(); constructor( diff --git a/libs/importer/src/components/dialog/file-password-prompt.component.ts b/libs/importer/src/components/dialog/file-password-prompt.component.ts index 9ad62b7e8f5..e4eca94e7a8 100644 --- a/libs/importer/src/components/dialog/file-password-prompt.component.ts +++ b/libs/importer/src/components/dialog/file-password-prompt.component.ts @@ -12,6 +12,8 @@ import { IconButtonModule, } from "@bitwarden/components"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "file-password-prompt.component.html", imports: [ diff --git a/libs/importer/src/components/dialog/import-error-dialog.component.ts b/libs/importer/src/components/dialog/import-error-dialog.component.ts index cb998c2dfe9..af8d8a0966e 100644 --- a/libs/importer/src/components/dialog/import-error-dialog.component.ts +++ b/libs/importer/src/components/dialog/import-error-dialog.component.ts @@ -16,6 +16,8 @@ export interface ErrorListItem { message: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./import-error-dialog.component.html", imports: [CommonModule, JslibModule, DialogModule, TableModule, ButtonModule], diff --git a/libs/importer/src/components/dialog/import-success-dialog.component.ts b/libs/importer/src/components/dialog/import-success-dialog.component.ts index ff9a5d7b014..34b9728ef44 100644 --- a/libs/importer/src/components/dialog/import-success-dialog.component.ts +++ b/libs/importer/src/components/dialog/import-success-dialog.component.ts @@ -20,6 +20,8 @@ export interface ResultList { count: number; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./import-success-dialog.component.html", imports: [CommonModule, JslibModule, DialogModule, TableModule, ButtonModule], diff --git a/libs/importer/src/components/dialog/sshkey-password-prompt.component.ts b/libs/importer/src/components/dialog/sshkey-password-prompt.component.ts index 8c199ee5577..a60569c5f4c 100644 --- a/libs/importer/src/components/dialog/sshkey-password-prompt.component.ts +++ b/libs/importer/src/components/dialog/sshkey-password-prompt.component.ts @@ -12,6 +12,8 @@ import { IconButtonModule, } from "@bitwarden/components"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "sshkey-password-prompt.component.html", imports: [ diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index fcb79c1c5df..0ff62b00e78 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -86,6 +86,8 @@ import { import { ImporterProviders } from "./importer-providers"; import { ImportLastPassComponent } from "./lastpass"; +// 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: "tools-import", templateUrl: "import.component.html", @@ -131,6 +133,8 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { * Enables the hosting control to pass in an organizationId * If a organizationId is provided, the organization selection is disabled. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set organizationId(value: OrganizationId | string | undefined) { if (Utils.isNullOrEmpty(value)) { this._organizationId = undefined; @@ -157,9 +161,13 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { }); } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() onLoadProfilesFromBrowser: (browser: string) => Promise; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() onImportFromBrowser: (browser: string, profile: string) => Promise; @@ -191,15 +199,23 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { chromiumLoader: [Loader.file as DataLoader], }); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(BitSubmitDirective) private bitSubmit: BitSubmitDirective; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() formLoading = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() formDisabled = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSuccessfulImport = new EventEmitter(); diff --git a/libs/importer/src/components/lastpass/dialog/lastpass-multifactor-prompt.component.ts b/libs/importer/src/components/lastpass/dialog/lastpass-multifactor-prompt.component.ts index f497a3bf32c..b9cddc95f8e 100644 --- a/libs/importer/src/components/lastpass/dialog/lastpass-multifactor-prompt.component.ts +++ b/libs/importer/src/components/lastpass/dialog/lastpass-multifactor-prompt.component.ts @@ -21,6 +21,8 @@ type LastPassMultifactorPromptData = { variant: LastPassMultifactorPromptVariant; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "lastpass-multifactor-prompt.component.html", imports: [ diff --git a/libs/importer/src/components/lastpass/dialog/lastpass-password-prompt.component.ts b/libs/importer/src/components/lastpass/dialog/lastpass-password-prompt.component.ts index 861f184f94d..995fc2362c9 100644 --- a/libs/importer/src/components/lastpass/dialog/lastpass-password-prompt.component.ts +++ b/libs/importer/src/components/lastpass/dialog/lastpass-password-prompt.component.ts @@ -15,6 +15,8 @@ import { TypographyModule, } from "@bitwarden/components"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "lastpass-password-prompt.component.html", imports: [ diff --git a/libs/importer/src/components/lastpass/import-lastpass.component.ts b/libs/importer/src/components/lastpass/import-lastpass.component.ts index 7fbf7dd8a7a..62fc5325d26 100644 --- a/libs/importer/src/components/lastpass/import-lastpass.component.ts +++ b/libs/importer/src/components/lastpass/import-lastpass.component.ts @@ -25,6 +25,8 @@ import { import { LastPassDirectImportService } from "./lastpass-direct-import.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: "import-lastpass", templateUrl: "import-lastpass.component.html", @@ -60,6 +62,8 @@ export class ImportLastPassComponent implements OnInit, OnDestroy { }), ); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() csvDataLoaded = new EventEmitter(); constructor( diff --git a/libs/importer/src/services/default-import-metadata.service.ts b/libs/importer/src/services/default-import-metadata.service.ts index a9e767178aa..05b8472869d 100644 --- a/libs/importer/src/services/default-import-metadata.service.ts +++ b/libs/importer/src/services/default-import-metadata.service.ts @@ -1,9 +1,11 @@ -import { map, Observable } from "rxjs"; +import { combineLatest, map, Observable } from "rxjs"; +import { ClientType, DeviceType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { SemanticLogger } from "@bitwarden/common/tools/log"; import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; -import { ImporterMetadata, Importers, ImportersMetadata } from "../metadata"; +import { DataLoader, ImporterMetadata, Importers, ImportersMetadata, Loader } from "../metadata"; import { ImportType } from "../models/import-options"; import { availableLoaders } from "../util"; @@ -13,8 +15,13 @@ export class DefaultImportMetadataService implements ImportMetadataServiceAbstra protected importers: ImportersMetadata = Importers; private logger: SemanticLogger; + private chromiumWithABE$: Observable; + constructor(protected system: SystemServiceProvider) { this.logger = system.log({ type: "ImportMetadataService" }); + this.chromiumWithABE$ = this.system.configService.getFeatureFlag$( + FeatureFlag.ChromiumImporterWithABE, + ); } async init(): Promise { @@ -23,13 +30,13 @@ export class DefaultImportMetadataService implements ImportMetadataServiceAbstra metadata$(type$: Observable): Observable { const client = this.system.environment.getClientType(); - const capabilities$ = type$.pipe( - map((type) => { + const capabilities$ = combineLatest([type$, this.chromiumWithABE$]).pipe( + map(([type, enabled]) => { if (!this.importers) { return { type, loaders: [] }; } - const loaders = availableLoaders(this.importers, type, client); + const loaders = this.availableLoaders(this.importers, type, client, enabled); if (!loaders || loaders.length === 0) { return { type, loaders: [] }; @@ -48,4 +55,33 @@ export class DefaultImportMetadataService implements ImportMetadataServiceAbstra return capabilities$; } + + /** Determine the available loaders for the given import type and client, considering feature flags and environments */ + private availableLoaders( + importers: ImportersMetadata, + type: ImportType, + client: ClientType, + enabled: boolean, + ): DataLoader[] | undefined { + let loaders = availableLoaders(importers, type, client); + let includeABE = false; + + if (enabled && (type === "bravecsv" || type === "chromecsv" || type === "edgecsv")) { + try { + const device = this.system.environment.getDevice(); + const isWindowsDesktop = device === DeviceType.WindowsDesktop; + if (isWindowsDesktop) { + includeABE = true; + } + } catch { + includeABE = true; + } + } + + // If the browser is unsupported, remove the chromium loader + if (!includeABE) { + loaders = loaders?.filter((loader) => loader !== Loader.chromium); + } + return loaders; + } } diff --git a/libs/importer/src/services/import-metadata.service.spec.ts b/libs/importer/src/services/import-metadata.service.spec.ts index 25ce41251b6..908ce6ad476 100644 --- a/libs/importer/src/services/import-metadata.service.spec.ts +++ b/libs/importer/src/services/import-metadata.service.spec.ts @@ -1,19 +1,19 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { Subject, firstValueFrom } from "rxjs"; +import { BehaviorSubject, Subject, firstValueFrom } from "rxjs"; import { ClientType } from "@bitwarden/client-type"; +import { DeviceType } from "@bitwarden/common/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; -import { ImporterMetadata, Instructions } from "../metadata"; +import { ImporterMetadata, ImportersMetadata, Instructions, Loader } from "../metadata"; import { ImportType } from "../models"; import { DefaultImportMetadataService } from "./default-import-metadata.service"; -import { ImportMetadataServiceAbstraction } from "./import-metadata.service.abstraction"; describe("ImportMetadataService", () => { - let sut: ImportMetadataServiceAbstraction; + let sut: DefaultImportMetadataService; let systemServiceProvider: MockProxy; beforeEach(() => { @@ -34,15 +34,18 @@ describe("ImportMetadataService", () => { describe("metadata$", () => { let typeSubject: Subject; let mockLogger: { debug: jest.Mock }; + let featureFlagSubject: BehaviorSubject; + + const environment = mock(); + environment.getClientType.mockReturnValue(ClientType.Desktop); beforeEach(() => { typeSubject = new Subject(); mockLogger = { debug: jest.fn() }; + featureFlagSubject = new BehaviorSubject(false); const configService = mock(); - - const environment = mock(); - environment.getClientType.mockReturnValue(ClientType.Desktop); + configService.getFeatureFlag$.mockReturnValue(featureFlagSubject); systemServiceProvider = mock({ configService, @@ -56,6 +59,7 @@ describe("ImportMetadataService", () => { afterEach(() => { typeSubject.complete(); + featureFlagSubject.complete(); }); it("should emit metadata when type$ emits", async () => { @@ -106,5 +110,76 @@ describe("ImportMetadataService", () => { "capabilities updated", ); }); + + it("should update when feature flag changes", async () => { + const testType: ImportType = "bravecsv"; // Use bravecsv which supports chromium loader + const emissions: ImporterMetadata[] = []; + + const subscription = sut.metadata$(typeSubject).subscribe((metadata) => { + emissions.push(metadata); + }); + + typeSubject.next(testType); + featureFlagSubject.next(true); + + // Wait for emissions + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emissions).toHaveLength(2); + expect(emissions[0].loaders).not.toContain(Loader.chromium); + expect(emissions[1].loaders).toContain(Loader.file); + + subscription.unsubscribe(); + }); + + it("should exclude chromium loader when ABE is disabled but on Windows Desktop", async () => { + environment.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders + featureFlagSubject.next(false); + + const metadataPromise = firstValueFrom(sut.metadata$(typeSubject)); + typeSubject.next(testType); + + const result = await metadataPromise; + + expect(result.loaders).not.toContain(Loader.chromium); + expect(result.loaders).toContain(Loader.file); + }); + + it("should exclude chromium loader when ABE is enabled but not on Windows Desktop", async () => { + environment.getDevice.mockReturnValue(DeviceType.MacOsDesktop); + const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders + featureFlagSubject.next(true); + + const metadataPromise = firstValueFrom(sut.metadata$(typeSubject)); + typeSubject.next(testType); + + const result = await metadataPromise; + + expect(result.loaders).not.toContain(Loader.chromium); + expect(result.loaders).toContain(Loader.file); + }); + + it("should include chromium loader when ABE is enabled and on Windows Desktop", async () => { + // Set up importers to include bravecsv with chromium loader + sut["importers"] = { + bravecsv: { + type: "bravecsv", + loaders: [Loader.file, Loader.chromium], + instructions: Instructions.chromium, + }, + } as ImportersMetadata; + + environment.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders + featureFlagSubject.next(true); + + const metadataPromise = firstValueFrom(sut.metadata$(typeSubject)); + typeSubject.next(testType); + + const result = await metadataPromise; + + expect(result.loaders).toContain(Loader.chromium); + }); }); }); diff --git a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts index 586c1cc113a..fe96e4620ad 100644 --- a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts +++ b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts @@ -13,6 +13,8 @@ import { UserId } from "@bitwarden/common/types/guid"; import { BitActionDirective, ButtonModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +// 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: "confirm-key-connector-domain", templateUrl: "confirm-key-connector-domain.component.html", @@ -24,6 +26,8 @@ export class ConfirmKeyConnectorDomainComponent implements OnInit { keyConnectorUrl!: string; userId!: UserId; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() onBeforeNavigation: () => Promise = async () => {}; constructor( diff --git a/libs/key-management-ui/src/key-rotation/key-rotation-trust-info.component.ts b/libs/key-management-ui/src/key-rotation/key-rotation-trust-info.component.ts index a2d3de3b30f..762b1f573df 100644 --- a/libs/key-management-ui/src/key-rotation/key-rotation-trust-info.component.ts +++ b/libs/key-management-ui/src/key-rotation/key-rotation-trust-info.component.ts @@ -17,6 +17,8 @@ type KeyRotationTrustDialogData = { numberOfEmergencyAccessUsers: number; }; +// 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: "key-rotation-trust-info", templateUrl: "key-rotation-trust-info.component.html", diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index 7fcb050f34e..801a9d191f5 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -86,6 +86,8 @@ type AfterUnlockActions = { /// Fixes safari autoprompt behavior const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; +// 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-lock", templateUrl: "lock.component.html", diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts index c9399cc3ab2..ff1e7f53e5f 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts @@ -25,6 +25,8 @@ import { UnlockOptionValue, } from "../../services/lock-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-master-password-lock", templateUrl: "master-password-lock.component.html", @@ -45,13 +47,13 @@ export class MasterPasswordLockComponent { private readonly logService = inject(LogService); UnlockOption = UnlockOption; - activeUnlockOption = model.required(); + readonly activeUnlockOption = model.required(); - unlockOptions = input.required(); - biometricUnlockBtnText = input.required(); - showPinSwap = computed(() => this.unlockOptions().pin.enabled ?? false); - biometricsAvailable = computed(() => this.unlockOptions().biometrics.enabled ?? false); - showBiometricsSwap = computed(() => { + readonly unlockOptions = input.required(); + readonly biometricUnlockBtnText = input.required(); + readonly showPinSwap = computed(() => this.unlockOptions().pin.enabled ?? false); + readonly biometricsAvailable = computed(() => this.unlockOptions().biometrics.enabled ?? false); + readonly showBiometricsSwap = computed(() => { const status = this.unlockOptions().biometrics.biometricsStatus; return ( status !== BiometricsStatus.PlatformUnsupported && diff --git a/libs/key-management-ui/src/trust/account-recovery-trust.component.ts b/libs/key-management-ui/src/trust/account-recovery-trust.component.ts index 8eec776bbb6..a4cfe4a41c1 100644 --- a/libs/key-management-ui/src/trust/account-recovery-trust.component.ts +++ b/libs/key-management-ui/src/trust/account-recovery-trust.component.ts @@ -25,6 +25,8 @@ type AccountRecoveryTrustDialogData = { /** org public key */ publicKey: Uint8Array; }; +// 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: "account-recovery-trust", templateUrl: "account-recovery-trust.component.html", diff --git a/libs/key-management-ui/src/trust/emergency-access-trust.component.ts b/libs/key-management-ui/src/trust/emergency-access-trust.component.ts index 35c6b16c873..da55475af1d 100644 --- a/libs/key-management-ui/src/trust/emergency-access-trust.component.ts +++ b/libs/key-management-ui/src/trust/emergency-access-trust.component.ts @@ -25,6 +25,8 @@ type EmergencyAccessTrustDialogData = { /** user public key */ publicKey: Uint8Array; }; +// 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: "emergency-access-trust", templateUrl: "emergency-access-trust.component.html", diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts index acd7d9129bd..213d15d61a8 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts @@ -13,6 +13,8 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { CalloutModule } from "@bitwarden/components"; import { ExportFormat } from "@bitwarden/vault-export-core"; +// 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: "tools-export-scope-callout", templateUrl: "export-scope-callout.component.html", diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index 567480ac1bd..19921b35162 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -73,6 +73,8 @@ import { EncryptedExportType } from "../enums/encrypted-export-type.enum"; import { ExportScopeCalloutComponent } from "./export-scope-callout.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({ selector: "tools-export", templateUrl: "export.component.html", @@ -101,6 +103,8 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { * Enables the hosting control to pass in an organizationId * If a organizationId is provided, the organization selection is disabled. */ + // TODO: Fix this the next time the file is edited. + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set organizationId(value: OrganizationId | string | undefined) { if (Utils.isNullOrEmpty(value)) { this._organizationId$.next(undefined); @@ -158,6 +162,8 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { * The hosting control also needs a bitSubmitDirective (on the Submit button) which calls this components {@link submit}-method. * This components formState (loading/disabled) is emitted back up to the hosting component so for example the Submit button can be enabled/disabled and show loading state. */ + // TODO: Fix this the next time the file is edited. + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(BitSubmitDirective) private bitSubmit: BitSubmitDirective; @@ -165,6 +171,8 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { * Emits true when the BitSubmitDirective({@link bitSubmit} is executing {@link submit} and false when execution has completed. * Example: Used to show the loading state of the submit button present on the hosting component * */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() formLoading = new EventEmitter(); @@ -172,6 +180,8 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { * Emits true when this form gets disabled and false when enabled. * Example: Used to disable the submit button, which is present on the hosting component * */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() formDisabled = new EventEmitter(); @@ -180,9 +190,13 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { * - Emits an undefined when exporting from an individual vault * - Emits the organizationId when exporting from an organizational vault * */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSuccessfulExport = new EventEmitter(); + // TODO: Fix this the next time the file is edited. + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(PasswordStrengthV2Component) passwordStrengthComponent: PasswordStrengthV2Component; encryptedExportType = EncryptedExportType; @@ -296,9 +310,9 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { ), ); - /* - Determines how organization exports are described in the callout. - Admins are exempted from organization data ownership policy, + /* + Determines how organization exports are described in the callout. + Admins are exempted from organization data ownership policy, and so this needs to determine if the policy is enabled for the org, not if it applies to the user. */ this.organizationDataOwnershipPolicyEnabledForOrg$ = combineLatest([ @@ -401,9 +415,9 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { }); } - /* - Initialize component for organization only export - Hides "My Vault" option by returning immediately + /* + Initialize component for organization only export + Hides "My Vault" option by returning immediately */ private initOrganizationOnly(): void { this.organizations$ = this.accountService.activeAccount$.pipe( diff --git a/libs/tools/generator/components/src/catchall-settings.component.ts b/libs/tools/generator/components/src/catchall-settings.component.ts index a836b26f98b..0fb953b86dc 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.ts +++ b/libs/tools/generator/components/src/catchall-settings.component.ts @@ -19,6 +19,8 @@ import { } from "@bitwarden/generator-core"; /** Options group for catchall emails */ +// 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: "tools-catchall-settings", templateUrl: "catchall-settings.component.html", @@ -38,6 +40,8 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy, OnChanges { * @remarks this is initialized to null but since it's a required input it'll * never have that value in practice. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) account!: Account; @@ -48,6 +52,8 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy, OnChanges { * to receive live settings updates including the initial update, * use `CredentialGeneratorService.settings(...)` instead. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() readonly onUpdated = new EventEmitter(); diff --git a/libs/tools/generator/components/src/credential-generator-history-dialog.component.ts b/libs/tools/generator/components/src/credential-generator-history-dialog.component.ts index 9ec0e636f9a..31419cefe1d 100644 --- a/libs/tools/generator/components/src/credential-generator-history-dialog.component.ts +++ b/libs/tools/generator/components/src/credential-generator-history-dialog.component.ts @@ -27,6 +27,8 @@ import { GeneratorHistoryService } from "@bitwarden/generator-history"; import { CredentialGeneratorHistoryComponent as CredentialGeneratorHistoryToolsComponent } from "./credential-generator-history.component"; import { EmptyCredentialHistoryComponent } from "./empty-credential-history.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({ templateUrl: "credential-generator-history-dialog.component.html", imports: [ @@ -49,6 +51,8 @@ export class CredentialGeneratorHistoryDialogComponent implements OnChanges, OnI private logService: LogService, ) {} + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() account: Account | null; @@ -59,6 +63,8 @@ export class CredentialGeneratorHistoryDialogComponent implements OnChanges, OnI * * @warning this may reveal sensitive information in plaintext. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() debug: boolean = false; diff --git a/libs/tools/generator/components/src/credential-generator-history.component.ts b/libs/tools/generator/components/src/credential-generator-history.component.ts index 3965b2be83e..a09a82c74b8 100644 --- a/libs/tools/generator/components/src/credential-generator-history.component.ts +++ b/libs/tools/generator/components/src/credential-generator-history.component.ts @@ -26,6 +26,8 @@ import { GeneratedCredential, GeneratorHistoryService } from "@bitwarden/generat import { GeneratorModule } from "./generator.module"; import { translate } from "./util"; +// 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-credential-generator-history", templateUrl: "credential-generator-history.component.html", @@ -50,6 +52,8 @@ export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, O private logService: LogService, ) {} + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) account: Account; @@ -60,6 +64,8 @@ export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, O * * @warning this may reveal sensitive information in plaintext. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() debug: boolean = false; diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 78b803392df..f48180d93bd 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -64,6 +64,8 @@ const IDENTIFIER = "identifier"; const FORWARDER = "forwarder"; const NONE_SELECTED = "none"; +// 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: "tools-credential-generator", templateUrl: "credential-generator.component.html", @@ -90,6 +92,8 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro /** Binds the component to a specific user's settings. When this input is not provided, * the form binds to the active user */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() account: Account | null = null; @@ -98,6 +102,8 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro * * @warning this may reveal sensitive information in plaintext. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() debug: boolean = false; @@ -123,10 +129,14 @@ export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestro /** * The website associated with the credential generation request. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() website: string | null = null; /** Emits credentials created from a generation request. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() readonly onGenerated = new EventEmitter(); diff --git a/libs/tools/generator/components/src/empty-credential-history.component.ts b/libs/tools/generator/components/src/empty-credential-history.component.ts index 4b9bc24b33b..593d7ac45f1 100644 --- a/libs/tools/generator/components/src/empty-credential-history.component.ts +++ b/libs/tools/generator/components/src/empty-credential-history.component.ts @@ -4,6 +4,8 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { NoCredentialsIcon } from "@bitwarden/assets/svg"; import { NoItemsModule } from "@bitwarden/components"; +// 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-empty-credential-history", templateUrl: "empty-credential-history.component.html", diff --git a/libs/tools/generator/components/src/forwarder-settings.component.ts b/libs/tools/generator/components/src/forwarder-settings.component.ts index c961cd5bb7a..32fa3effdf6 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.ts +++ b/libs/tools/generator/components/src/forwarder-settings.component.ts @@ -26,6 +26,8 @@ const Controls = Object.freeze({ }); /** Options group for forwarder integrations */ +// 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: "tools-forwarder-settings", templateUrl: "forwarder-settings.component.html", @@ -45,11 +47,15 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy * @remarks this is initialized to null but since it's a required input it'll * never have that value in practice. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) account: Account = null!; protected account$ = new ReplaySubject(1); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) forwarder: VendorId = null!; @@ -58,6 +64,8 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy * to receive live settings updates including the initial update, * use `CredentialGeneratorService.settings$(...)` instead. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() readonly onUpdated = new EventEmitter(); diff --git a/libs/tools/generator/components/src/nudge-generator-spotlight.component.ts b/libs/tools/generator/components/src/nudge-generator-spotlight.component.ts index 6807a987a85..f22bf1b12cc 100644 --- a/libs/tools/generator/components/src/nudge-generator-spotlight.component.ts +++ b/libs/tools/generator/components/src/nudge-generator-spotlight.component.ts @@ -10,6 +10,8 @@ import { UserId } from "@bitwarden/common/types/guid"; import { TypographyModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +// 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: "nudge-generator-spotlight", templateUrl: "nudge-generator-spotlight.component.html", diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index b3525251392..7e4ae8b5af9 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -34,6 +34,8 @@ const Controls = Object.freeze({ }); /** Options group for passphrases */ +// 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: "tools-passphrase-settings", templateUrl: "passphrase-settings.component.html", @@ -57,6 +59,8 @@ export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy * * @warning this may reveal sensitive information in plaintext. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() debug: boolean = false; @@ -67,6 +71,8 @@ export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy * @remarks this is initialized to null but since it's a required input it'll * never have that value in practice. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) account: Account = null!; @@ -79,10 +85,14 @@ export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy } /** When `true`, an options header is displayed by the component. Otherwise, the header is hidden. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showHeader: boolean = true; /** Removes bottom margin from `bit-section` */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: coerceBooleanProperty }) disableMargin = false; /** Emits settings updates and completes if the settings become unavailable. @@ -90,6 +100,8 @@ export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy * to receive live settings updates including the initial update, * use {@link CredentialGeneratorService.settings} instead. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() readonly onUpdated = new EventEmitter(); diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index b293aeb7e2d..2b1d5044651 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -53,6 +53,8 @@ import { GeneratorHistoryService } from "@bitwarden/generator-history"; import { toAlgorithmInfo, translate } from "./util"; /** Options group for passwords */ +// 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: "tools-password-generator", templateUrl: "password-generator.component.html", @@ -76,6 +78,8 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy /** Binds the component to a specific user's settings. When this input is not provided, * the form binds to the active user */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() account: Account | null = null; @@ -86,6 +90,8 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy * * @warning this may reveal sensitive information in plaintext. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() debug: boolean = false; @@ -110,10 +116,14 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy } } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() profile: GeneratorProfile = Profile.account; /** Removes bottom margin, passed to downstream components */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: coerceBooleanProperty }) disableMargin = false; @@ -154,10 +164,14 @@ export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy } /** Emits credentials created from a generation request. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() readonly onGenerated = new EventEmitter(); /** emits algorithm info when the selected algorithm changes */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() readonly onAlgorithm = new EventEmitter(); diff --git a/libs/tools/generator/components/src/password-settings.component.ts b/libs/tools/generator/components/src/password-settings.component.ts index 965ada38146..5d5980edf1b 100644 --- a/libs/tools/generator/components/src/password-settings.component.ts +++ b/libs/tools/generator/components/src/password-settings.component.ts @@ -34,6 +34,8 @@ const Controls = Object.freeze({ }); /** Options group for passwords */ +// 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: "tools-password-settings", templateUrl: "password-settings.component.html", @@ -55,6 +57,8 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { * @remarks this is initialized to null but since it's a required input it'll * never have that value in practice. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) account: Account = null!; @@ -67,14 +71,20 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { } /** When `true`, an options header is displayed by the component. Otherwise, the header is hidden. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showHeader: boolean = true; /** Number of milliseconds to wait before accepting user input. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() waitMs: number = 100; /** Removes bottom margin from `bit-section` */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: coerceBooleanProperty }) disableMargin = false; /** Emits settings updates and completes if the settings become unavailable. @@ -82,6 +92,8 @@ export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { * to receive live settings updates including the initial update, * use `CredentialGeneratorService.settings(...)` instead. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() readonly onUpdated = new EventEmitter(); diff --git a/libs/tools/generator/components/src/subaddress-settings.component.ts b/libs/tools/generator/components/src/subaddress-settings.component.ts index 27ed6d5f9f3..f9cef2341ba 100644 --- a/libs/tools/generator/components/src/subaddress-settings.component.ts +++ b/libs/tools/generator/components/src/subaddress-settings.component.ts @@ -19,6 +19,8 @@ import { } from "@bitwarden/generator-core"; /** Options group for plus-addressed emails */ +// 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: "tools-subaddress-settings", templateUrl: "subaddress-settings.component.html", @@ -38,6 +40,8 @@ export class SubaddressSettingsComponent implements OnInit, OnChanges, OnDestroy * @remarks this is initialized to null but since it's a required input it'll * never have that value in practice. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) account: Account = null!; @@ -54,6 +58,8 @@ export class SubaddressSettingsComponent implements OnInit, OnChanges, OnDestroy * to receive live settings updates including the initial update, * use `CredentialGeneratorService.settings(...)` instead. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() readonly onUpdated = new EventEmitter(); diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 6227bcd3f7c..dc4b8d26e7e 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -64,6 +64,8 @@ const FORWARDER = "forwarder"; const NONE_SELECTED = "none"; /** Component that generates usernames and emails */ +// 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: "tools-username-generator", templateUrl: "username-generator.component.html", @@ -95,6 +97,8 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy /** Binds the component to a specific user's settings. When this input is not provided, * the form binds to the active user */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() account: Account | null = null; @@ -105,6 +109,8 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy * * @warning this may reveal sensitive information in plaintext. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() debug: boolean = false; @@ -132,18 +138,26 @@ export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy /** * The website associated with the credential generation request. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() website: string | null = null; /** Emits credentials created from a generation request. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() readonly onGenerated = new EventEmitter(); /** emits algorithm info when the selected algorithm changes */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() readonly onAlgorithm = new EventEmitter(); /** Removes bottom margin from internal elements */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: coerceBooleanProperty }) disableMargin = false; /** Tracks the selected generation algorithm */ diff --git a/libs/tools/generator/components/src/username-settings.component.ts b/libs/tools/generator/components/src/username-settings.component.ts index 7a12957f906..fae1a3aca04 100644 --- a/libs/tools/generator/components/src/username-settings.component.ts +++ b/libs/tools/generator/components/src/username-settings.component.ts @@ -19,6 +19,8 @@ import { } from "@bitwarden/generator-core"; /** Options group for usernames */ +// 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: "tools-username-settings", templateUrl: "username-settings.component.html", @@ -38,6 +40,8 @@ export class UsernameSettingsComponent implements OnInit, OnChanges, OnDestroy { * @remarks this is initialized to null but since it's a required input it'll * never have that value in practice. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) account: Account = null!; @@ -54,6 +58,8 @@ export class UsernameSettingsComponent implements OnInit, OnChanges, OnDestroy { * to receive live settings updates including the initial update, * use `CredentialGeneratorService.settings(...)` instead. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() readonly onUpdated = new EventEmitter(); diff --git a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts index 383bf4be7ec..5b4e913f693 100644 --- a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts +++ b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts @@ -52,6 +52,8 @@ export enum SendItemDialogResult { /** * Component for adding or editing a send item. */ +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "send-add-edit-dialog.component.html", imports: [ diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts index b553a343cdd..1ffd9644208 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts @@ -10,13 +10,19 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components"; +// 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: "tools-new-send-dropdown", templateUrl: "new-send-dropdown.component.html", imports: [JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule, PremiumBadgeComponent], }) export class NewSendDropdownComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hideIcon: boolean = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() buttonType: ButtonType = "primary"; sendType = SendType; diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts index b2ab149f2f2..2ddb10dc80b 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts @@ -33,6 +33,8 @@ import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/ge import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; +// 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: "tools-send-options", templateUrl: "./send-options.component.html", @@ -52,8 +54,12 @@ import { SendFormContainer } from "../../send-form-container"; ], }) export class SendOptionsComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) config: SendFormConfig; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() originalSendView: SendView; disableHideEmail = false; diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts index e1fbf5dbc50..2996c18bf63 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts @@ -47,6 +47,8 @@ export interface DatePresetSelectOption { value: DatePreset | string; } +// 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: "tools-send-details", templateUrl: "./send-details.component.html", @@ -68,7 +70,11 @@ export interface DatePresetSelectOption { ], }) export class SendDetailsComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() config: SendFormConfig; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() originalSendView?: SendView; FileSendType = SendType.File; diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts index 83b966c2b7e..4e4900039c7 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts @@ -17,6 +17,8 @@ import { import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; +// 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: "tools-send-file-details", templateUrl: "./send-file-details.component.html", @@ -32,8 +34,8 @@ import { SendFormContainer } from "../../send-form-container"; ], }) export class SendFileDetailsComponent implements OnInit { - config = input.required(); - originalSendView = input(); + readonly config = input.required(); + readonly originalSendView = input(); sendFileDetailsForm = this.formBuilder.group({ file: this.formBuilder.control(null, Validators.required), diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-text-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-text-details.component.ts index 202d43d67ef..70a20ab63d8 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-text-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-text-details.component.ts @@ -10,6 +10,8 @@ import { CheckboxModule, FormFieldModule, SectionComponent } from "@bitwarden/co import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; +// 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: "tools-send-text-details", templateUrl: "./send-text-details.component.html", @@ -23,8 +25,8 @@ import { SendFormContainer } from "../../send-form-container"; ], }) export class SendTextDetailsComponent implements OnInit { - config = input.required(); - originalSendView = input(); + readonly config = input.required(); + readonly originalSendView = input(); sendTextDetailsForm = this.formBuilder.group({ text: new FormControl("", Validators.required), diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts index b8593c735b7..fcd3b0cb7ea 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts @@ -38,6 +38,8 @@ import { SendForm, SendFormContainer } from "../send-form-container"; import { SendDetailsComponent } from "./send-details/send-details.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({ selector: "tools-send-form", templateUrl: "./send-form.component.html", @@ -59,6 +61,8 @@ import { SendDetailsComponent } from "./send-details/send-details.component"; ], }) export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, SendFormContainer { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(BitSubmitDirective) private bitSubmit: BitSubmitDirective; private destroyRef = inject(DestroyRef); @@ -68,27 +72,37 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send /** * The form ID to use for the form. Used to connect it to a submit button. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) formId: string; /** * The configuration for the add/edit form. Used to determine which controls are shown and what values are available. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) config: SendFormConfig; /** * Optional submit button that will be disabled or marked as loading when the form is submitting. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() submitBtn?: ButtonComponent; /** * Event emitted when the send is created successfully. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSendCreated = new EventEmitter(); /** * Event emitted when the send is updated successfully. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSendUpdated = new EventEmitter(); /** diff --git a/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.ts b/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.ts index b7c60145bbf..8de88282b7c 100644 --- a/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.ts +++ b/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.ts @@ -10,6 +10,8 @@ import { ChipSelectComponent } from "@bitwarden/components"; import { SendListFiltersService } from "../services/send-list-filters.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: "app-send-list-filters", templateUrl: "./send-list-filters.component.html", diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts index f67880eb73f..d885f279bc6 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts @@ -25,6 +25,8 @@ import { TypographyModule, } from "@bitwarden/components"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ imports: [ CommonModule, @@ -46,9 +48,13 @@ export class SendListItemsContainerComponent { /** * The list of sends to display. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() sends: SendView[] = []; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() headerText: string; diff --git a/libs/tools/send/send-ui/src/send-search/send-search.component.ts b/libs/tools/send/send-ui/src/send-search/send-search.component.ts index 90b31a206fc..02cb5ef2eda 100644 --- a/libs/tools/send/send-ui/src/send-search/send-search.component.ts +++ b/libs/tools/send/send-ui/src/send-search/send-search.component.ts @@ -11,6 +11,8 @@ import { SendItemsService } from "../services/send-items.service"; const SearchTextDebounceInterval = 200; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ imports: [CommonModule, SearchModule, JslibModule, FormsModule], selector: "tools-send-search", diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.spec.ts b/libs/vault/src/cipher-form/components/cipher-form.component.spec.ts index 08afee33ae3..a002956b54a 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.spec.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.spec.ts @@ -2,7 +2,7 @@ import { ChangeDetectorRef } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ReactiveFormsModule } from "@angular/forms"; import { mock } from "jest-mock-extended"; -import { of } from "rxjs"; +import { Observable, of } from "rxjs"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -13,7 +13,6 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view"; import { ToastService } from "@bitwarden/components"; -import { UserId } from "@bitwarden/user-core"; import { CipherFormConfig } from "../abstractions/cipher-form-config.service"; import { CipherFormService } from "../abstractions/cipher-form.service"; @@ -72,7 +71,7 @@ describe("CipherFormComponent", () => { }); it("should remove archivedDate when user cannot archive and cipher is archived", async () => { - mockAccountService.activeAccount$ = of({ id: "user-id" as UserId } as Account); + mockAccountService.activeAccount$ = of({ id: "user-id" }) as Observable; mockCipherArchiveService.userCanArchive$.mockReturnValue(of(false)); mockAddEditFormService.saveCipher = jest.fn().mockResolvedValue(new CipherView()); @@ -154,6 +153,15 @@ describe("CipherFormComponent", () => { expect(component["updatedCipherView"]?.login.fido2Credentials).toBeNull(); }); + + it("clears archiveDate on updatedCipherView", async () => { + cipherView.archivedDate = new Date(); + decryptCipher.mockResolvedValue(cipherView); + + await component.ngOnInit(); + + expect(component["updatedCipherView"]?.archivedDate).toBeNull(); + }); }); describe("enableFormFields", () => { diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts index 117dd98ba43..f7676818edf 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -263,6 +263,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci if (this.config.mode === "clone") { this.updatedCipherView.id = null; + this.updatedCipherView.archivedDate = null; if (this.updatedCipherView.login) { this.updatedCipherView.login.fido2Credentials = null; diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index efaefc77ade..3f05c753da4 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -27,3 +27,4 @@ export { SshImportPromptService } from "./services/ssh-import-prompt.service"; export * from "./abstractions/change-login-password.service"; export * from "./services/default-change-login-password.service"; +export * from "./services/archive-cipher-utilities.service"; diff --git a/libs/vault/src/services/archive-cipher-utilities.service.spec.ts b/libs/vault/src/services/archive-cipher-utilities.service.spec.ts new file mode 100644 index 00000000000..76a4073325e --- /dev/null +++ b/libs/vault/src/services/archive-cipher-utilities.service.spec.ts @@ -0,0 +1,122 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { ArchiveCipherUtilitiesService } from "./archive-cipher-utilities.service"; +import { PasswordRepromptService } from "./password-reprompt.service"; + +describe("ArchiveCipherUtilitiesService", () => { + let service: ArchiveCipherUtilitiesService; + + let cipherArchiveService: MockProxy; + let dialogService: MockProxy; + let passwordRepromptService: MockProxy; + let toastService: MockProxy; + let i18nService: MockProxy; + let accountService: MockProxy; + + const mockCipher = new CipherView(); + mockCipher.id = "cipher-id" as CipherId; + const mockUserId = "user-id"; + + beforeEach(() => { + cipherArchiveService = mock(); + dialogService = mock(); + passwordRepromptService = mock(); + toastService = mock(); + i18nService = mock(); + accountService = mock(); + + accountService.activeAccount$ = new BehaviorSubject({ id: mockUserId } as any).asObservable(); + + dialogService.openSimpleDialog.mockResolvedValue(true); + passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true); + cipherArchiveService.archiveWithServer.mockResolvedValue(undefined); + cipherArchiveService.unarchiveWithServer.mockResolvedValue(undefined); + i18nService.t.mockImplementation((key) => key); + + service = new ArchiveCipherUtilitiesService( + cipherArchiveService, + dialogService, + passwordRepromptService, + toastService, + i18nService, + accountService, + ); + }); + + describe("archiveCipher()", () => { + it("returns early when confirmation dialog is cancelled", async () => { + dialogService.openSimpleDialog.mockResolvedValue(false); + + await service.archiveCipher(mockCipher); + + expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalled(); + expect(cipherArchiveService.archiveWithServer).not.toHaveBeenCalled(); + }); + + it("returns early when password reprompt fails", async () => { + passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false); + + await service.archiveCipher(mockCipher); + + expect(cipherArchiveService.archiveWithServer).not.toHaveBeenCalled(); + }); + + it("archives cipher and shows success toast when successful", async () => { + await service.archiveCipher(mockCipher); + + expect(cipherArchiveService.archiveWithServer).toHaveBeenCalledWith( + mockCipher.id, + mockUserId, + ); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "itemWasSentToArchive", + }); + }); + + it("shows error toast when archiving fails", async () => { + cipherArchiveService.archiveWithServer.mockRejectedValue(new Error("test error")); + + await service.archiveCipher(mockCipher); + + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "errorOccurred", + }); + }); + }); + + describe("unarchiveCipher()", () => { + it("unarchives cipher and shows success toast when successful", async () => { + await service.unarchiveCipher(mockCipher); + + expect(cipherArchiveService.unarchiveWithServer).toHaveBeenCalledWith( + mockCipher.id, + mockUserId, + ); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "itemWasUnarchived", + }); + }); + + it("shows error toast when unarchiving fails", async () => { + cipherArchiveService.unarchiveWithServer.mockRejectedValue(new Error("test error")); + + await service.unarchiveCipher(mockCipher); + + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "errorOccurred", + }); + }); + }); +}); diff --git a/libs/vault/src/services/archive-cipher-utilities.service.ts b/libs/vault/src/services/archive-cipher-utilities.service.ts new file mode 100644 index 00000000000..bbe7dba6715 --- /dev/null +++ b/libs/vault/src/services/archive-cipher-utilities.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { PasswordRepromptService } from "./password-reprompt.service"; + +/** + * Wrapper around {@link CipherArchiveService} to provide UI enhancements for archiving/unarchiving ciphers. + */ +@Injectable({ providedIn: "root" }) +export class ArchiveCipherUtilitiesService { + constructor( + private cipherArchiveService: CipherArchiveService, + private dialogService: DialogService, + private passwordRepromptService: PasswordRepromptService, + private toastService: ToastService, + private i18nService: I18nService, + private accountService: AccountService, + ) {} + + /** Archive a cipher, with confirmation dialog and password reprompt checks. */ + async archiveCipher(cipher: CipherView) { + const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); + if (!repromptPassed) { + return; + } + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "archiveItem" }, + content: { key: "archiveItemConfirmDesc" }, + type: "info", + }); + + if (!confirmed) { + return; + } + + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + await this.cipherArchiveService + .archiveWithServer(cipher.id as CipherId, userId) + .then(() => { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasSentToArchive"), + }); + }) + .catch(() => { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + }); + } + + /** Unarchives a cipher */ + async unarchiveCipher(cipher: CipherView) { + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + await this.cipherArchiveService + .unarchiveWithServer(cipher.id as CipherId, userId) + .then(() => { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasUnarchived"), + }); + }) + .catch(() => { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + }); + } +}