diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ae5d62a90c2..f03cf3ee2a8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,7 @@ apps/desktop/desktop_native @bitwarden/team-platform-dev apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-desktop-dev +apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-management-dev ## No ownership for Cargo.lock and Cargo.toml to allow dependency updates apps/desktop/desktop_native/Cargo.lock apps/desktop/desktop_native/Cargo.toml diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 6fcffb1f875..f898df460c9 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -400,7 +400,16 @@ reviewers: ["team:team-vault-dev"], }, { - matchPackageNames: ["aes", "big-integer", "cbc", "rsa", "russh-cryptovec", "sha2"], + matchPackageNames: [ + "aes", + "big-integer", + "cbc", + "rsa", + "russh-cryptovec", + "sha2", + "memsec", + "linux-keyutils", + ], description: "Key Management owned dependencies", commitMessagePrefix: "[deps] KM:", reviewers: ["team:team-key-management-dev"], diff --git a/.gitignore b/.gitignore index 61a20195592..6b13d22caa7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ npm-debug.log # Build directories dist build +target .angular/cache .flatpak .flatpak-repo diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index e7a75dd537a..ef530563f48 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5733,6 +5733,9 @@ "confirmKeyConnectorDomain": { "message": "Confirm Key Connector domain" }, + "atRiskLoginsSecured": { + "message": "Great job securing your at-risk logins!" + }, "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts index 48fd57431a2..9e9a1ecf570 100644 --- a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts @@ -33,6 +33,8 @@ import { AccountComponent } from "./account.component"; import { CurrentAccountComponent } from "./current-account.component"; import { AccountSwitcherService } from "./services/account-switcher.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: "account-switcher.component.html", imports: [ diff --git a/apps/browser/src/auth/popup/account-switching/account.component.ts b/apps/browser/src/auth/popup/account-switching/account.component.ts index c060d9161ef..edfad2a54b3 100644 --- a/apps/browser/src/auth/popup/account-switching/account.component.ts +++ b/apps/browser/src/auth/popup/account-switching/account.component.ts @@ -13,13 +13,19 @@ import { BiometricsService } from "@bitwarden/key-management"; import { AccountSwitcherService, AvailableAccount } from "./services/account-switcher.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: "auth-account", templateUrl: "account.component.html", imports: [CommonModule, JslibModule, AvatarModule, ItemModule], }) export class AccountComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() account: AvailableAccount; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() loading = new EventEmitter(); constructor( diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.ts b/apps/browser/src/auth/popup/account-switching/current-account.component.ts index 63e8481621a..2dde3b5a266 100644 --- a/apps/browser/src/auth/popup/account-switching/current-account.component.ts +++ b/apps/browser/src/auth/popup/account-switching/current-account.component.ts @@ -21,6 +21,8 @@ export type CurrentAccount = { avatarColor: 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: "app-current-account", templateUrl: "current-account.component.html", diff --git a/apps/browser/src/auth/popup/components/set-pin.component.ts b/apps/browser/src/auth/popup/components/set-pin.component.ts index a9e8e1b122f..dbb71ae3b07 100644 --- a/apps/browser/src/auth/popup/components/set-pin.component.ts +++ b/apps/browser/src/auth/popup/components/set-pin.component.ts @@ -13,6 +13,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: "set-pin.component.html", imports: [ diff --git a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts index 2335c5c2e69..aa3639e9e93 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts @@ -41,6 +41,8 @@ import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popu import { AccountSecurityComponent } from "./account-security.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-pop-out", template: ` `, diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 4eb24d19605..65a0d33f93e 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -78,6 +78,8 @@ import { SetPinComponent } from "../components/set-pin.component"; import { AwaitDesktopDialogComponent } from "./await-desktop-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({ templateUrl: "account-security.component.html", imports: [ diff --git a/apps/browser/src/auth/popup/settings/await-desktop-dialog.component.ts b/apps/browser/src/auth/popup/settings/await-desktop-dialog.component.ts index 11bb9683bb9..a64cea1ef3e 100644 --- a/apps/browser/src/auth/popup/settings/await-desktop-dialog.component.ts +++ b/apps/browser/src/auth/popup/settings/await-desktop-dialog.component.ts @@ -3,6 +3,8 @@ import { Component } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ButtonModule, DialogModule, DialogService } 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: "await-desktop-dialog.component.html", imports: [JslibModule, ButtonModule, DialogModule], diff --git a/apps/browser/src/auth/popup/settings/extension-device-management.component.ts b/apps/browser/src/auth/popup/settings/extension-device-management.component.ts index 793965db141..b431fc874dd 100644 --- a/apps/browser/src/auth/popup/settings/extension-device-management.component.ts +++ b/apps/browser/src/auth/popup/settings/extension-device-management.component.ts @@ -7,6 +7,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({ standalone: true, selector: "extension-device-management", diff --git a/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts b/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts index 074e23d642d..adabae2c31d 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts @@ -28,9 +28,17 @@ import { ], }) export class Fido2CipherRowComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSelected = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() cipher: CipherView; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() last: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() title: string; protected selectCipher(c: CipherView) { diff --git a/apps/browser/src/autofill/popup/fido2/fido2-use-browser-link.component.ts b/apps/browser/src/autofill/popup/fido2/fido2-use-browser-link.component.ts index b8b49f993e3..f4c4c871478 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2-use-browser-link.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2-use-browser-link.component.ts @@ -15,6 +15,8 @@ import { MenuModule } from "@bitwarden/components"; import { fido2PopoutSessionData$ } from "../../../vault/popup/utils/fido2-popout-session-data"; import { BrowserFido2UserInterfaceSession } from "../../fido2/services/browser-fido2-user-interface.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-fido2-use-browser-link", templateUrl: "fido2-use-browser-link.component.html", diff --git a/apps/browser/src/autofill/popup/fido2/fido2.component.ts b/apps/browser/src/autofill/popup/fido2/fido2.component.ts index 11e00749bdf..c6799f93a5e 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2.component.ts @@ -71,6 +71,8 @@ interface ViewData { fallbackSupported: boolean; } +// 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-fido2", templateUrl: "fido2.component.html", diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index c3b5915a10a..62e5ba3a151 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -77,6 +77,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: "autofill.component.html", imports: [ diff --git a/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts b/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts index 15379eff436..30a64e03c56 100644 --- a/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/blocked-domains.component.ts @@ -41,6 +41,8 @@ import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-heade import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.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-blocked-domains", templateUrl: "blocked-domains.component.html", @@ -66,6 +68,8 @@ import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popu ], }) export class BlockedDomainsComponent implements AfterViewInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChildren("uriInput") uriInputElements: QueryList> = new QueryList(); diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts index a5bfad726f5..e67c826cac6 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts @@ -42,6 +42,8 @@ import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-heade import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.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-excluded-domains", templateUrl: "excluded-domains.component.html", @@ -67,6 +69,8 @@ import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popu ], }) export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChildren("uriInput") uriInputElements: QueryList> = new QueryList(); diff --git a/apps/browser/src/autofill/popup/settings/notifications.component.ts b/apps/browser/src/autofill/popup/settings/notifications.component.ts index cb10dec620b..3c77d746e9c 100644 --- a/apps/browser/src/autofill/popup/settings/notifications.component.ts +++ b/apps/browser/src/autofill/popup/settings/notifications.component.ts @@ -21,6 +21,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: "notifications.component.html", imports: [ diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index bcceac6fb84..8170c2a65a0 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -319,6 +319,7 @@ import I18nService from "../platform/services/i18n.service"; import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service"; import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service"; import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; +import { PopupRouterCacheBackgroundService } from "../platform/services/popup-router-cache-background.service"; import { PopupViewCacheBackgroundService } from "../platform/services/popup-view-cache-background.service"; import { BrowserSdkLoadService } from "../platform/services/sdk/browser-sdk-load.service"; import { BackgroundTaskSchedulerService } from "../platform/services/task-scheduler/background-task-scheduler.service"; @@ -488,6 +489,7 @@ export default class MainBackground { private nativeMessagingBackground: NativeMessagingBackground; private popupViewCacheBackgroundService: PopupViewCacheBackgroundService; + private popupRouterCacheBackgroundService: PopupRouterCacheBackgroundService; constructor() { // Services @@ -684,6 +686,9 @@ export default class MainBackground { this.globalStateProvider, this.taskSchedulerService, ); + this.popupRouterCacheBackgroundService = new PopupRouterCacheBackgroundService( + this.globalStateProvider, + ); this.migrationRunner = new MigrationRunner( this.storageService, @@ -1045,6 +1050,7 @@ export default class MainBackground { this.authService, this.stateProvider, this.securityStateService, + this.kdfConfigService, ); this.syncServiceListener = new SyncServiceListener( @@ -1514,6 +1520,7 @@ export default class MainBackground { (this.eventUploadService as EventUploadService).init(true); this.popupViewCacheBackgroundService.startObservingMessages(); + this.popupRouterCacheBackgroundService.init(); await this.vaultTimeoutService.init(true); this.fido2Background.init(); diff --git a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts index 2e9746642f4..abb7c6405c2 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts @@ -5,6 +5,7 @@ import { Injectable, inject } from "@angular/core"; import { ActivatedRouteSnapshot, CanActivateFn, + Data, NavigationEnd, Router, UrlSerializer, @@ -14,7 +15,10 @@ import { filter, first, firstValueFrom, map, Observable, of, switchMap, tap } fr import { GlobalStateProvider } from "@bitwarden/common/platform/state"; -import { POPUP_ROUTE_HISTORY_KEY } from "../../../platform/services/popup-view-cache-background.service"; +import { + POPUP_ROUTE_HISTORY_KEY, + RouteHistoryCacheState, +} from "../../../platform/services/popup-view-cache-background.service"; import BrowserPopupUtils from "../../browser/browser-popup-utils"; /** @@ -42,8 +46,7 @@ export class PopupRouterCacheService { this.history$() .pipe(first()) .subscribe( - (history) => - Array.isArray(history) && history.forEach((location) => this.location.go(location)), + (history) => Array.isArray(history) && history.forEach(({ url }) => this.location.go(url)), ); // update state when route change occurs @@ -54,31 +57,33 @@ export class PopupRouterCacheService { // `Location.back()` can now be called successfully this.hasNavigated = true; }), - filter((_event: NavigationEnd) => { + map((event) => { const state: ActivatedRouteSnapshot = this.router.routerState.snapshot.root; let child = state.firstChild; while (child.firstChild) { child = child.firstChild; } - - return !child?.data?.doNotSaveUrl; + return { event, data: child.data }; }), - switchMap((event) => this.push(event.url)), + filter(({ data }) => { + return !data?.doNotSaveUrl; + }), + switchMap(({ event, data }) => this.push(event.url, data)), ) .subscribe(); } - history$(): Observable { + history$(): Observable { return this.state.state$; } - async setHistory(state: string[]): Promise { + async setHistory(state: RouteHistoryCacheState[]): Promise { return this.state.update(() => state); } /** Get the last item from the history stack, or `null` if empty */ - last$(): Observable { + last$(): Observable { return this.history$().pipe( map((history) => { if (!history || history.length === 0) { @@ -92,11 +97,24 @@ export class PopupRouterCacheService { /** * If in browser popup, push new route onto history stack */ - private async push(url: string) { - if (!BrowserPopupUtils.inPopup(window) || url === (await firstValueFrom(this.last$()))) { + private async push(url: string, data: Data) { + if ( + !BrowserPopupUtils.inPopup(window) || + url === (await firstValueFrom(this.last$().pipe(map((h) => h?.url)))) + ) { return; } - await this.state.update((prevState) => (prevState == null ? [url] : prevState.concat(url))); + + const routeEntry: RouteHistoryCacheState = { + url, + options: { + resetRouterCacheOnTabChange: data?.resetRouterCacheOnTabChange ?? false, + }, + }; + + await this.state.update((prevState) => + prevState == null ? [routeEntry] : prevState.concat(routeEntry), + ); } /** @@ -142,13 +160,13 @@ export const popupRouterCacheGuard = ((): Observable => { } return popupHistoryService.last$().pipe( - map((url: string) => { - if (!url) { + map((entry) => { + if (!entry) { return true; } popupHistoryService.markCacheRestored(); - return urlSerializer.parse(url); + return urlSerializer.parse(entry.url); }), ); }) satisfies CanActivateFn; diff --git a/apps/browser/src/platform/popup/view-cache/popup-router-cache.spec.ts b/apps/browser/src/platform/popup/view-cache/popup-router-cache.spec.ts index 22fb7bf99b9..3304a99023e 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-router-cache.spec.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-router-cache.spec.ts @@ -96,11 +96,31 @@ describe("Popup router cache guard", () => { // wait for router events subscription await flushPromises(); - expect(await firstValueFrom(service.history$())).toEqual(["/a", "/b"]); + expect(await firstValueFrom(service.history$())).toEqual([ + { + options: { + resetRouterCacheOnTabChange: false, + }, + url: "/a", + }, + { + options: { + resetRouterCacheOnTabChange: false, + }, + url: "/b", + }, + ]); await service.back(); - expect(await firstValueFrom(service.history$())).toEqual(["/a"]); + expect(await firstValueFrom(service.history$())).toEqual([ + { + options: { + resetRouterCacheOnTabChange: false, + }, + url: "/a", + }, + ]); }); it("does not save ignored routes", async () => { @@ -121,6 +141,13 @@ describe("Popup router cache guard", () => { await flushPromises(); - expect(await firstValueFrom(service.history$())).toEqual(["/a"]); + expect(await firstValueFrom(service.history$())).toEqual([ + { + options: { + resetRouterCacheOnTabChange: false, + }, + url: "/a", + }, + ]); }); }); diff --git a/apps/browser/src/platform/services/popup-router-cache-background.service.ts b/apps/browser/src/platform/services/popup-router-cache-background.service.ts new file mode 100644 index 00000000000..37e9f7cd4a0 --- /dev/null +++ b/apps/browser/src/platform/services/popup-router-cache-background.service.ts @@ -0,0 +1,55 @@ +import { switchMap, filter, map, first, of } from "rxjs"; + +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; + +import { BrowserApi } from "../browser/browser-api"; +import { fromChromeEvent } from "../browser/from-chrome-event"; + +import { POPUP_ROUTE_HISTORY_KEY } from "./popup-view-cache-background.service"; + +export class PopupRouterCacheBackgroundService { + private popupRouteHistoryState = this.globalStateProvider.get(POPUP_ROUTE_HISTORY_KEY); + + constructor(private globalStateProvider: GlobalStateProvider) {} + + init() { + fromChromeEvent(chrome.tabs.onActivated) + .pipe( + switchMap((tabs) => BrowserApi.getTab(tabs[0].tabId)!), + switchMap((tab) => { + // FireFox sets the `url` to "about:blank" and won't populate the `url` until the `onUpdated` event + if (tab.url !== "about:blank") { + return of(tab); + } + + return fromChromeEvent(chrome.tabs.onUpdated).pipe( + first(), + switchMap(([tabId]) => BrowserApi.getTab(tabId)!), + ); + }), + map((tab) => tab.url || tab.pendingUrl), + filter((url) => !url?.startsWith(chrome.runtime.getURL(""))), + switchMap(() => + this.popupRouteHistoryState.update((state) => { + if (!state || state.length === 0) { + return state; + } + + const lastRoute = state.at(-1); + if (!lastRoute) { + return state; + } + + // When the last route has resetRouterCacheOnTabChange set + // Reset the route history to empty to force the user to the default route + if (lastRoute.options?.resetRouterCacheOnTabChange) { + return []; + } + + return state; + }), + ), + ) + .subscribe(); + } +} diff --git a/apps/browser/src/platform/services/popup-view-cache-background.service.ts b/apps/browser/src/platform/services/popup-view-cache-background.service.ts index 49eae15fbbd..6a0a72ceccd 100644 --- a/apps/browser/src/platform/services/popup-view-cache-background.service.ts +++ b/apps/browser/src/platform/services/popup-view-cache-background.service.ts @@ -42,6 +42,22 @@ export type ViewCacheState = { options?: ViewCacheOptions; }; +export type RouteCacheOptions = { + /** + * When true, the route history will be reset on tab change and respective route was the last visited route. + * i.e. Upon the user re-opening the extension the route history will be empty and the user will be taken to the default route. + */ + resetRouterCacheOnTabChange?: boolean; +}; + +export type RouteHistoryCacheState = { + /** Route URL */ + url: string; + + /** Options for managing the route history cache */ + options?: RouteCacheOptions; +}; + /** We cannot use `UserKeyDefinition` because we must be able to store state when there is no active user. */ export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record( POPUP_VIEW_MEMORY, @@ -51,9 +67,9 @@ export const POPUP_VIEW_CACHE_KEY = KeyDefinition.record( }, ); -export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition( +export const POPUP_ROUTE_HISTORY_KEY = new KeyDefinition( POPUP_VIEW_MEMORY, - "popup-route-history", + "popup-route-history-details", { deserializer: (jsonValue) => jsonValue, }, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index e3d63d20c17..02adaff9b83 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -59,6 +59,7 @@ import { ProtectedByComponent } from "../dirt/phishing-detection/pages/protected import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import BrowserPopupUtils from "../platform/browser/browser-popup-utils"; import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service"; +import { RouteCacheOptions } from "../platform/services/popup-view-cache-background.service"; import { CredentialGeneratorHistoryComponent } from "../tools/popup/generator/credential-generator-history.component"; import { CredentialGeneratorComponent } from "../tools/popup/generator/credential-generator.component"; import { SendAddEditComponent as SendAddEditV2Component } from "../tools/popup/send-v2/add-edit/send-add-edit.component"; @@ -76,7 +77,10 @@ import { IntroCarouselComponent } from "../vault/popup/components/vault-v2/intro import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component"; import { VaultV2Component } from "../vault/popup/components/vault-v2/vault-v2.component"; import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component"; -import { canAccessAtRiskPasswords } from "../vault/popup/guards/at-risk-passwords.guard"; +import { + canAccessAtRiskPasswords, + hasAtRiskPasswords, +} from "../vault/popup/guards/at-risk-passwords.guard"; import { clearVaultStateGuard } from "../vault/popup/guards/clear-vault-state.guard"; import { IntroCarouselGuard } from "../vault/popup/guards/intro-carousel.guard"; import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component"; @@ -98,7 +102,7 @@ import { TabsV2Component } from "./tabs-v2.component"; /** * Data properties acceptable for use in extension route objects */ -export interface RouteDataProperties { +export interface RouteDataProperties extends RouteCacheOptions { elevation: RouteElevation; /** @@ -204,7 +208,7 @@ const routes: Routes = [ path: "add-cipher", component: AddEditV2Component, canActivate: [authGuard, debounceNavigationGuard()], - data: { elevation: 1 } satisfies RouteDataProperties, + data: { elevation: 1, resetRouterCacheOnTabChange: true } satisfies RouteDataProperties, runGuardsAndResolvers: "always", }, { @@ -214,6 +218,7 @@ const routes: Routes = [ data: { // Above "trash" elevation: 3, + resetRouterCacheOnTabChange: true, } satisfies RouteDataProperties, runGuardsAndResolvers: "always", }, @@ -690,7 +695,7 @@ const routes: Routes = [ { path: "at-risk-passwords", component: AtRiskPasswordsComponent, - canActivate: [authGuard, canAccessAtRiskPasswords], + canActivate: [authGuard, canAccessAtRiskPasswords, hasAtRiskPasswords], }, { path: "account-switcher", diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html index 6c2bc3f77a0..0efe2bd14e2 100644 --- a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html +++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.html @@ -1,8 +1,28 @@ - - - {{ - (taskCount === 1 ? "reviewAndChangeAtRiskPassword" : "reviewAndChangeAtRiskPasswordsPlural") - | i18n: taskCount.toString() - }} - - +@if ((currentPendingTasks$ | async)?.length > 0) { + + + {{ + ((currentPendingTasks$ | async)?.length === 1 + ? "reviewAndChangeAtRiskPassword" + : "reviewAndChangeAtRiskPasswordsPlural" + ) | i18n: (currentPendingTasks$ | async)?.length.toString() + }} + + +} + +@if (showCompletedTasksBanner$ | async) { + + {{ "atRiskLoginsSecured" | i18n }} + +} diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts index 3c3270e557c..c3d4f461d70 100644 --- a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts @@ -1,42 +1,47 @@ import { CommonModule } from "@angular/common"; import { Component, inject } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { combineLatest, map, switchMap } from "rxjs"; +import { firstValueFrom, switchMap } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; -import { AnchorLinkDirective, CalloutModule } from "@bitwarden/components"; +import { AnchorLinkDirective, CalloutModule, BannerModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwarden/vault"; @Component({ selector: "vault-at-risk-password-callout", - imports: [CommonModule, AnchorLinkDirective, RouterModule, CalloutModule, I18nPipe], + imports: [ + AnchorLinkDirective, + CommonModule, + RouterModule, + CalloutModule, + I18nPipe, + BannerModule, + JslibModule, + ], + providers: [AtRiskPasswordCalloutService], templateUrl: "./at-risk-password-callout.component.html", }) export class AtRiskPasswordCalloutComponent { - private taskService = inject(TaskService); - private cipherService = inject(CipherService); private activeAccount$ = inject(AccountService).activeAccount$.pipe(getUserId); + private atRiskPasswordCalloutService = inject(AtRiskPasswordCalloutService); - protected pendingTasks$ = this.activeAccount$.pipe( - switchMap((userId) => - combineLatest([ - this.taskService.pendingTasks$(userId), - this.cipherService.cipherViews$(userId), - ]), - ), - map(([tasks, ciphers]) => - tasks.filter((t) => { - const associatedCipher = ciphers.find((c) => c.id === t.cipherId); - - return ( - t.type === SecurityTaskType.UpdateAtRiskCredential && - associatedCipher && - !associatedCipher.isDeleted - ); - }), - ), + showCompletedTasksBanner$ = this.activeAccount$.pipe( + switchMap((userId) => this.atRiskPasswordCalloutService.showCompletedTasksBanner$(userId)), ); + + currentPendingTasks$ = this.activeAccount$.pipe( + switchMap((userId) => this.atRiskPasswordCalloutService.pendingTasks$(userId)), + ); + + async successBannerDismissed() { + const updateObject: AtRiskPasswordCalloutData = { + hasInteractedWithTasks: true, + tasksBannerDismissed: true, + }; + const userId = await firstValueFrom(this.activeAccount$); + this.atRiskPasswordCalloutService.updateAtRiskPasswordState(userId, updateObject); + } } diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts index e40ffcf31c9..0cbfa037e35 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts @@ -14,6 +14,9 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { FakeAccountService, FakeStateProvider } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { EndUserNotificationService } from "@bitwarden/common/vault/notifications"; @@ -24,6 +27,7 @@ import { ChangeLoginPasswordService, DefaultChangeLoginPasswordService, PasswordRepromptService, + AtRiskPasswordCalloutService, } from "@bitwarden/vault"; import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; @@ -68,6 +72,9 @@ describe("AtRiskPasswordsComponent", () => { let mockNotifications$: BehaviorSubject; let mockInlineMenuVisibility$: BehaviorSubject; let calloutDismissed$: BehaviorSubject; + let mockAtRiskPasswordCalloutService: any; + let stateProvider: FakeStateProvider; + let mockAccountService: FakeAccountService; const setInlineMenuVisibility = jest.fn(); const mockToastService = mock(); const mockAtRiskPasswordPageService = mock(); @@ -112,6 +119,11 @@ describe("AtRiskPasswordsComponent", () => { mockToastService.showToast.mockClear(); mockDialogService.open.mockClear(); mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$); + mockAccountService = { + activeAccount$: of({ id: "user" as UserId }), + activeUserId: "user" as UserId, + } as unknown as FakeAccountService; + stateProvider = new FakeStateProvider(mockAccountService); await TestBed.configureTestingModule({ imports: [AtRiskPasswordsComponent], @@ -141,7 +153,7 @@ describe("AtRiskPasswordsComponent", () => { }, }, { provide: I18nService, useValue: { t: (key: string) => key } }, - { provide: AccountService, useValue: { activeAccount$: of({ id: "user" }) } }, + { provide: AccountService, useValue: mockAccountService }, { provide: PlatformUtilsService, useValue: mock() }, { provide: PasswordRepromptService, useValue: mock() }, { @@ -152,6 +164,8 @@ describe("AtRiskPasswordsComponent", () => { }, }, { provide: ToastService, useValue: mockToastService }, + { provide: StateProvider, useValue: stateProvider }, + { provide: AtRiskPasswordCalloutService, useValue: mockAtRiskPasswordCalloutService }, ], }) .overrideModule(JslibModule, { diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts index e4a8293c52e..6918bedb9bf 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts @@ -41,6 +41,7 @@ import { TypographyModule, } from "@bitwarden/components"; import { + AtRiskPasswordCalloutService, ChangeLoginPasswordService, DefaultChangeLoginPasswordService, PasswordRepromptService, @@ -75,6 +76,7 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service"; providers: [ AtRiskPasswordPageService, { provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService }, + AtRiskPasswordCalloutService, ], selector: "vault-at-risk-passwords", templateUrl: "./at-risk-passwords.component.html", @@ -95,13 +97,14 @@ export class AtRiskPasswordsComponent implements OnInit { private dialogService = inject(DialogService); private endUserNotificationService = inject(EndUserNotificationService); private destroyRef = inject(DestroyRef); + private atRiskPasswordCalloutService = inject(AtRiskPasswordCalloutService); /** * The cipher that is currently being launched. Used to show a loading spinner on the badge button. * The UI utilize a bitBadge which does not support async actions (like bitButton does). * @protected */ - protected launchingCipher = signal(null); + protected readonly launchingCipher = signal(null); private activeUserData$ = this.accountService.activeAccount$.pipe( filterOutNullish(), @@ -199,6 +202,11 @@ export class AtRiskPasswordsComponent implements OnInit { } this.markTaskNotificationsAsRead(); + + this.atRiskPasswordCalloutService.updateAtRiskPasswordState(userId, { + hasInteractedWithTasks: true, + tasksBannerDismissed: false, + }); } private markTaskNotificationsAsRead() { diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 83535b09e66..1b8403e6024 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -267,6 +267,11 @@ export class ItemMoreOptionsComponent { } protected async delete() { + const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(this.cipher); + if (!repromptPassed) { + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "deleteItem" }, content: { key: "deleteItemConfirmation" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index a56eef4dfc1..07d3f042e60 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -41,6 +41,9 @@ + + +
-
diff --git a/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts b/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts index fc302dd6c36..03111859165 100644 --- a/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts +++ b/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts @@ -1,10 +1,11 @@ import { inject } from "@angular/core"; import { CanActivateFn, Router } from "@angular/router"; -import { map, switchMap } from "rxjs"; +import { combineLatest, map, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { TaskService } from "@bitwarden/common/vault/tasks"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { ToastService } from "@bitwarden/components"; @@ -32,3 +33,38 @@ export const canAccessAtRiskPasswords: CanActivateFn = () => { }), ); }; + +export const hasAtRiskPasswords: CanActivateFn = () => { + const accountService = inject(AccountService); + const taskService = inject(TaskService); + const cipherService = inject(CipherService); + const router = inject(Router); + + return accountService.activeAccount$.pipe( + filterOutNullish(), + switchMap((user) => + combineLatest([ + taskService.pendingTasks$(user.id), + cipherService.cipherViews$(user.id).pipe( + filterOutNullish(), + map((ciphers) => Object.fromEntries(ciphers.map((c) => [c.id, c]))), + ), + ]).pipe( + map(([tasks, ciphers]) => { + const hasAtRiskCiphers = tasks.some( + (t) => + t.type === SecurityTaskType.UpdateAtRiskCredential && + t.cipherId != null && + ciphers[t.cipherId] != null && + !ciphers[t.cipherId].isDeleted, + ); + + if (!hasAtRiskCiphers) { + return router.createUrlTree(["/tabs/vault"]); + } + return true; + }), + ), + ), + ); +}; diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 5ce3ccdd8ea..1cccbba65e3 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -852,6 +852,7 @@ export class ServiceContainer { this.authService, this.stateProvider, this.securityStateService, + this.kdfConfigService, ); this.totpService = new TotpService(this.sdkService); diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 0aa040bbcf1..3df6b41734b 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -927,6 +927,8 @@ dependencies = [ "interprocess", "keytar", "libc", + "linux-keyutils", + "memsec", "oo7", "pin-project", "pkcs8", @@ -1793,6 +1795,16 @@ dependencies = [ "cc", ] +[[package]] +name = "linux-keyutils" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1878,6 +1890,17 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memsec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492" +dependencies = [ + "getrandom 0.2.16", + "libc", + "windows-sys 0.45.0", +] + [[package]] name = "mime" version = "0.3.17" @@ -3993,6 +4016,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4020,6 +4052,21 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -4068,6 +4115,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -4086,6 +4139,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -4104,6 +4163,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -4134,6 +4199,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -4161,6 +4232,12 @@ dependencies = [ "windows-core 0.61.0", ] +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -4179,6 +4256,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -4197,6 +4280,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -4416,9 +4505,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 855b0b3aa43..edf3cb44eca 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -39,7 +39,9 @@ homedir = "=0.3.4" interprocess = "=2.2.1" keytar = "=0.1.6" libc = "=0.2.172" +linux-keyutils = "=0.2.4" log = "=0.4.25" +memsec = "=0.7.0" napi = "=2.16.17" napi-build = "=2.2.0" napi-derive = "=2.16.13" diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index b7e4c9b7a83..f6c9d669df6 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -32,6 +32,7 @@ ed25519 = { workspace = true, features = ["pkcs8"] } futures = { workspace = true } homedir = { workspace = true } interprocess = { workspace = true, features = ["tokio"] } +memsec = { workspace = true, features = ["alloc_ext"] } pin-project = { workspace = true } pkcs8 = { workspace = true, features = ["alloc", "encryption", "pem"] } rand = { workspace = true } @@ -87,6 +88,7 @@ desktop_objc = { path = "../objc" } [target.'cfg(target_os = "linux")'.dependencies] oo7 = { workspace = true } libc = { workspace = true } +linux-keyutils = { workspace = true } ashpd = { workspace = true } zbus = { workspace = true, optional = true } diff --git a/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs index ca9b6081d69..3ff8a6d3d83 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs @@ -54,7 +54,7 @@ impl SecureMemoryStore for DpapiSecretKVStore { self.map.insert(key, padded_data); } - fn get(&self, key: &str) -> Option> { + fn get(&mut 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(); diff --git a/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs b/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs new file mode 100644 index 00000000000..a8952d8f55a --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs @@ -0,0 +1,105 @@ +use tracing::error; + +use crate::secure_memory::{ + secure_key::{EncryptedMemory, SecureMemoryEncryptionKey}, + SecureMemoryStore, +}; + +/// An encrypted memory store holds a platform protected symmetric encryption key, and uses it +/// to encrypt all items it stores. The ciphertexts for the items are not specially protected. This +/// allows circumventing length and amount limitations on platform specific secure memory APIs since +/// only a single short item needs to be protected. +/// +/// The key is briefly in process memory during encryption and decryption, in memory that is protected +/// from swapping to disk via mlock, and then zeroed out immediately after use. +#[allow(unused)] +pub(crate) struct EncryptedMemoryStore { + map: std::collections::HashMap, + memory_encryption_key: SecureMemoryEncryptionKey, +} + +impl EncryptedMemoryStore { + #[allow(unused)] + pub(crate) fn new() -> Self { + EncryptedMemoryStore { + map: std::collections::HashMap::new(), + memory_encryption_key: SecureMemoryEncryptionKey::new(), + } + } +} + +impl SecureMemoryStore for EncryptedMemoryStore { + fn put(&mut self, key: String, value: &[u8]) { + let encrypted_value = self.memory_encryption_key.encrypt(value); + self.map.insert(key, encrypted_value); + } + + fn get(&mut self, key: &str) -> Option> { + let encrypted_memory = self.map.get(key); + if let Some(encrypted_memory) = encrypted_memory { + match self.memory_encryption_key.decrypt(encrypted_memory) { + Ok(plaintext) => Some(plaintext), + Err(_) => { + error!("In memory store, decryption failed for key {}. The memory may have been tampered with. re-keying.", key); + self.memory_encryption_key = SecureMemoryEncryptionKey::new(); + self.clear(); + None + } + } + } else { + None + } + } + + 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 EncryptedMemoryStore { + fn drop(&mut self) { + self.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_secret_kv_store_various_sizes() { + let mut store = EncryptedMemoryStore::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_crud() { + let mut store = EncryptedMemoryStore::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 index 0cb604e03f2..8695904758e 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/mod.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs @@ -1,6 +1,9 @@ #[cfg(target_os = "windows")] pub(crate) mod dpapi; +mod encrypted_memory_store; +mod secure_key; + /// 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 @@ -12,7 +15,9 @@ pub(crate) trait SecureMemoryStore { /// 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>; + /// + /// Note: If memory was tampered with, this will re-key the store and return None. + fn get(&mut 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. diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs new file mode 100644 index 00000000000..1ee6c4cdf40 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs @@ -0,0 +1,96 @@ +use std::ptr::NonNull; + +use chacha20poly1305::{aead::Aead, Key, KeyInit}; +use rand::{rng, Rng}; + +pub(super) const KEY_SIZE: usize = 32; +pub(super) const NONCE_SIZE: usize = 24; + +/// The encryption performed here is xchacha-poly1305. Any tampering with the key or the ciphertexts will result +/// in a decryption failure and panic. The key's memory contents are protected from being swapped to disk +/// via mlock. +pub(super) struct MemoryEncryptionKey(NonNull<[u8]>); + +/// An encrypted memory blob that must be decrypted using the same key that it was encrypted with. +pub struct EncryptedMemory { + nonce: [u8; NONCE_SIZE], + ciphertext: Vec, +} + +impl MemoryEncryptionKey { + pub fn new() -> Self { + let mut key = [0u8; KEY_SIZE]; + rng().fill(&mut key); + MemoryEncryptionKey::from(&key) + } + + /// Encrypts the given plaintext using the key. + #[allow(unused)] + pub(super) fn encrypt(&self, plaintext: &[u8]) -> EncryptedMemory { + let cipher = chacha20poly1305::XChaCha20Poly1305::new(Key::from_slice(self.as_ref())); + let mut nonce = [0u8; NONCE_SIZE]; + rng().fill(&mut nonce); + let ciphertext = cipher + .encrypt(chacha20poly1305::XNonce::from_slice(&nonce), plaintext) + .expect("encryption should not fail"); + EncryptedMemory { nonce, ciphertext } + } + + /// Decrypts the given encrypted memory using the key. A decryption failure will panic. This is + /// okay because neither the keys nor ciphertexts should ever fail to decrypt, and doing so + /// indicates that the process memory was tampered with. + #[allow(unused)] + pub(super) fn decrypt(&self, encrypted: &EncryptedMemory) -> Result, DecryptionError> { + let cipher = chacha20poly1305::XChaCha20Poly1305::new(Key::from_slice(self.as_ref())); + cipher + .decrypt( + chacha20poly1305::XNonce::from_slice(&encrypted.nonce), + encrypted.ciphertext.as_ref(), + ) + .map_err(|_| DecryptionError::CouldNotDecrypt) + } +} + +impl Drop for MemoryEncryptionKey { + fn drop(&mut self) { + unsafe { + memsec::free(self.0); + } + } +} + +impl From<&[u8; KEY_SIZE]> for MemoryEncryptionKey { + fn from(value: &[u8; KEY_SIZE]) -> Self { + let mut ptr: NonNull<[u8]> = + unsafe { memsec::malloc_sized(KEY_SIZE).expect("malloc_sized should work") }; + unsafe { + std::ptr::copy_nonoverlapping(value.as_ptr(), ptr.as_mut().as_mut_ptr(), KEY_SIZE); + } + MemoryEncryptionKey(ptr) + } +} + +impl AsRef<[u8]> for MemoryEncryptionKey { + fn as_ref(&self) -> &[u8] { + unsafe { self.0.as_ref() } + } +} + +#[derive(Debug)] +pub(crate) enum DecryptionError { + CouldNotDecrypt, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_memory_encryption_key() { + let key = MemoryEncryptionKey::new(); + let data = b"Hello, world!"; + let encrypted = key.encrypt(data); + let decrypted = key.decrypt(&encrypted).unwrap(); + assert_eq!(data.as_ref(), decrypted.as_slice()); + } +} diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/dpapi.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/dpapi.rs new file mode 100644 index 00000000000..0975b542877 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/dpapi.rs @@ -0,0 +1,93 @@ +use super::crypto::{MemoryEncryptionKey, KEY_SIZE}; +use super::SecureKeyContainer; +use windows::Win32::Security::Cryptography::{ + CryptProtectMemory, CryptUnprotectMemory, CRYPTPROTECTMEMORY_BLOCK_SIZE, + CRYPTPROTECTMEMORY_SAME_PROCESS, +}; + +/// 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(super) struct DpapiSecureKeyContainer { + dpapi_encrypted_key: [u8; KEY_SIZE + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize], +} + +// SAFETY: The encrypted data is fully owned by this struct, and not exposed outside or cloned, +// and is disposed on drop of this struct. +unsafe impl Send for DpapiSecureKeyContainer {} +// SAFETY: The container is non-mutable and thus safe to share between threads. +unsafe impl Sync for DpapiSecureKeyContainer {} + +impl SecureKeyContainer for DpapiSecureKeyContainer { + fn as_key(&self) -> MemoryEncryptionKey { + let mut decrypted_key = self.dpapi_encrypted_key; + unsafe { + CryptUnprotectMemory( + decrypted_key.as_mut_ptr() as *mut core::ffi::c_void, + decrypted_key.len() as u32, + CRYPTPROTECTMEMORY_SAME_PROCESS, + ) + } + .expect("crypt_unprotect_memory should work"); + let mut key = [0u8; KEY_SIZE]; + key.copy_from_slice(&decrypted_key[..KEY_SIZE]); + MemoryEncryptionKey::from(&key) + } + + fn from_key(key: MemoryEncryptionKey) -> Self { + let mut padded_key = [0u8; KEY_SIZE + CRYPTPROTECTMEMORY_BLOCK_SIZE as usize]; + padded_key[..KEY_SIZE].copy_from_slice(key.as_ref()); + unsafe { + CryptProtectMemory( + padded_key.as_mut_ptr() as *mut core::ffi::c_void, + padded_key.len() as u32, + CRYPTPROTECTMEMORY_SAME_PROCESS, + ) + } + .expect("crypt_protect_memory should work"); + DpapiSecureKeyContainer { + dpapi_encrypted_key: padded_key, + } + } + + fn is_supported() -> bool { + // DPAPI is supported on all Windows versions that we support. + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_multiple_keys() { + let key1 = MemoryEncryptionKey::new(); + let key2 = MemoryEncryptionKey::new(); + let container1 = DpapiSecureKeyContainer::from_key(key1); + let container2 = DpapiSecureKeyContainer::from_key(key2); + + // Capture at time 1 + let data_1_1 = container1.as_key(); + let data_2_1 = container2.as_key(); + // Capture at time 2 + let data_1_2 = container1.as_key(); + let data_2_2 = container2.as_key(); + + // Same keys should be equal + assert_eq!(data_1_1.as_ref(), data_1_2.as_ref()); + assert_eq!(data_2_1.as_ref(), data_2_2.as_ref()); + + // Different keys should be different + assert_ne!(data_1_1.as_ref(), data_2_1.as_ref()); + assert_ne!(data_1_2.as_ref(), data_2_2.as_ref()); + } + + #[test] + fn test_is_supported() { + assert!(DpapiSecureKeyContainer::is_supported()); + } +} diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/keyctl.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/keyctl.rs new file mode 100644 index 00000000000..a738d964671 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/keyctl.rs @@ -0,0 +1,100 @@ +use crate::secure_memory::secure_key::crypto::MemoryEncryptionKey; + +use super::crypto::KEY_SIZE; +use super::SecureKeyContainer; +use linux_keyutils::{KeyRing, KeyRingIdentifier}; + +/// The keys are bound to the process keyring. +const KEY_RING_IDENTIFIER: KeyRingIdentifier = KeyRingIdentifier::Process; +/// This is an atomic global counter used to help generate unique key IDs +static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +/// Generates a unique ID for the key in the kernel keyring. +/// SAFETY: This function is safe to call from multiple threads because it uses an atomic counter. +fn make_id() -> String { + let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // In case multiple processes are running, include the PID in the key ID. + let pid = std::process::id(); + format!("bitwarden_desktop_{}_{}", pid, counter) +} + +/// A secure key container that uses the Linux kernel keyctl API to store the key. +/// `https://man7.org/linux/man-pages/man1/keyctl.1.html`. The kernel enforces only +/// the correct process can read them, and they do not live in process memory space +/// and cannot be dumped. +pub(super) struct KeyctlSecureKeyContainer { + /// The kernel has an identifier for the key. This is randomly generated on construction. + id: String, +} + +// SAFETY: The key id is fully owned by this struct and not exposed or cloned, and cleaned up on drop. +// Further, since we use `KeyRingIdentifier::Process` and not `KeyRingIdentifier::Thread`, the key +// is accessible across threads within the same process bound. +unsafe impl Send for KeyctlSecureKeyContainer {} +// SAFETY: The container is non-mutable and thus safe to share between threads. +unsafe impl Sync for KeyctlSecureKeyContainer {} + +impl SecureKeyContainer for KeyctlSecureKeyContainer { + fn as_key(&self) -> MemoryEncryptionKey { + let ring = KeyRing::from_special_id(KEY_RING_IDENTIFIER, false) + .expect("should get process keyring"); + let key = ring.search(&self.id).expect("should find key"); + let mut buffer = [0u8; KEY_SIZE]; + key.read(&mut buffer).expect("should read key"); + MemoryEncryptionKey::from(&buffer) + } + + fn from_key(data: MemoryEncryptionKey) -> Self { + let ring = KeyRing::from_special_id(KEY_RING_IDENTIFIER, true) + .expect("should get process keyring"); + let id = make_id(); + ring.add_key(&id, &data).expect("should add key"); + KeyctlSecureKeyContainer { id } + } + + fn is_supported() -> bool { + KeyRing::from_special_id(KEY_RING_IDENTIFIER, true).is_ok() + } +} + +impl Drop for KeyctlSecureKeyContainer { + fn drop(&mut self) { + let ring = KeyRing::from_special_id(KEY_RING_IDENTIFIER, false) + .expect("should get process keyring"); + if let Ok(key) = ring.search(&self.id) { + let _ = key.invalidate(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_multiple_keys() { + let key1 = MemoryEncryptionKey::new(); + let key2 = MemoryEncryptionKey::new(); + let container1 = KeyctlSecureKeyContainer::from_key(key1); + let container2 = KeyctlSecureKeyContainer::from_key(key2); + + // Capture at time 1 + let data_1_1 = container1.as_key(); + let data_2_1 = container2.as_key(); + // Capture at time 2 + let data_1_2 = container1.as_key(); + let data_2_2 = container2.as_key(); + + // Same keys should be equal + assert_eq!(data_1_1.as_ref(), data_1_2.as_ref()); + assert_eq!(data_2_1.as_ref(), data_2_2.as_ref()); + + // Different keys should be different + assert_ne!(data_1_1.as_ref(), data_2_1.as_ref()); + assert_ne!(data_1_2.as_ref(), data_2_2.as_ref()); + } + + #[test] + fn test_is_supported() { + assert!(KeyctlSecureKeyContainer::is_supported()); + } +} diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/memfd_secret.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/memfd_secret.rs new file mode 100644 index 00000000000..4e6a2c4d7ac --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/memfd_secret.rs @@ -0,0 +1,109 @@ +use std::{ptr::NonNull, sync::LazyLock}; + +use super::crypto::MemoryEncryptionKey; +use super::crypto::KEY_SIZE; +use super::SecureKeyContainer; + +/// https://man.archlinux.org/man/memfd_secret.2.en +/// The memfd_secret store protects the data using the `memfd_secret` syscall. The +/// data is inaccessible to other user-mode processes, and even to root in most cases. +/// If arbitrary data can be executed in the kernel, the data can still be retrieved: +/// https://github.com/JonathonReinhart/nosecmem +pub(super) struct MemfdSecretSecureKeyContainer { + ptr: NonNull<[u8]>, +} +// SAFETY: The pointers in this struct are allocated by `memfd_secret`, and we have full ownership. +// They are never exposed outside or cloned, and are cleaned up by drop. +unsafe impl Send for MemfdSecretSecureKeyContainer {} +// SAFETY: The container is non-mutable and thus safe to share between threads. Further, memfd-secret +// is accessible across threads within the same process bound. +unsafe impl Sync for MemfdSecretSecureKeyContainer {} + +impl SecureKeyContainer for MemfdSecretSecureKeyContainer { + fn as_key(&self) -> MemoryEncryptionKey { + MemoryEncryptionKey::from( + &unsafe { self.ptr.as_ref() } + .try_into() + .expect("slice should be KEY_SIZE"), + ) + } + + fn from_key(key: MemoryEncryptionKey) -> Self { + let mut ptr: NonNull<[u8]> = unsafe { + memsec::memfd_secret_sized(KEY_SIZE).expect("memfd_secret_sized should work") + }; + unsafe { + std::ptr::copy_nonoverlapping( + key.as_ref().as_ptr(), + ptr.as_mut().as_mut_ptr(), + KEY_SIZE, + ); + } + MemfdSecretSecureKeyContainer { ptr } + } + + /// Note, `memfd_secret` is only available since Linux 6.5, so fallbacks are needed. + fn is_supported() -> bool { + // To test if memfd_secret is supported, we try to allocate a 1 byte and see if that + // succeeds. + static IS_SUPPORTED: LazyLock = LazyLock::new(|| { + let Some(ptr): Option> = (unsafe { memsec::memfd_secret_sized(1) }) + else { + return false; + }; + + // Check that the pointer is readable and writable + let result = unsafe { + let ptr = ptr.as_ptr() as *mut u8; + *ptr = 30; + *ptr += 107; + *ptr == 137 + }; + + unsafe { memsec::free_memfd_secret(ptr) }; + result + }); + *IS_SUPPORTED + } +} + +impl Drop for MemfdSecretSecureKeyContainer { + fn drop(&mut self) { + unsafe { + memsec::free_memfd_secret(self.ptr); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_multiple_keys() { + let key1 = MemoryEncryptionKey::new(); + let key2 = MemoryEncryptionKey::new(); + let container1 = MemfdSecretSecureKeyContainer::from_key(key1); + let container2 = MemfdSecretSecureKeyContainer::from_key(key2); + + // Capture at time 1 + let data_1_1 = container1.as_key(); + let data_2_1 = container2.as_key(); + // Capture at time 2 + let data_1_2 = container1.as_key(); + let data_2_2 = container2.as_key(); + + // Same keys should be equal + assert_eq!(data_1_1.as_ref(), data_1_2.as_ref()); + assert_eq!(data_2_1.as_ref(), data_2_2.as_ref()); + + // Different keys should be different + assert_ne!(data_1_1.as_ref(), data_2_1.as_ref()); + assert_ne!(data_1_2.as_ref(), data_2_2.as_ref()); + } + + #[test] + fn test_is_supported() { + assert!(MemfdSecretSecureKeyContainer::is_supported()); + } +} diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mlock.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mlock.rs new file mode 100644 index 00000000000..db21cd7fedc --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mlock.rs @@ -0,0 +1,83 @@ +use std::ptr::NonNull; + +use super::crypto::MemoryEncryptionKey; +use super::crypto::KEY_SIZE; +use super::SecureKeyContainer; + +/// A SecureKeyContainer that uses mlock to prevent the memory from being swapped to disk. +/// This does not provide as strong protections as other methods, but is always supported. +pub(super) struct MlockSecureKeyContainer { + ptr: NonNull<[u8]>, +} +// SAFETY: The pointers in this struct are allocated by `malloc_sized`, and we have full ownership. +// They are never exposed outside or cloned, and are cleaned up by drop. +unsafe impl Send for MlockSecureKeyContainer {} +// SAFETY: The container is non-mutable and thus safe to share between threads. +unsafe impl Sync for MlockSecureKeyContainer {} + +impl SecureKeyContainer for MlockSecureKeyContainer { + fn as_key(&self) -> MemoryEncryptionKey { + MemoryEncryptionKey::from( + &unsafe { self.ptr.as_ref() } + .try_into() + .expect("slice should be KEY_SIZE"), + ) + } + fn from_key(key: MemoryEncryptionKey) -> Self { + let mut ptr: NonNull<[u8]> = + unsafe { memsec::malloc_sized(KEY_SIZE).expect("malloc_sized should work") }; + unsafe { + std::ptr::copy_nonoverlapping( + key.as_ref().as_ptr(), + ptr.as_mut().as_mut_ptr(), + KEY_SIZE, + ); + } + MlockSecureKeyContainer { ptr } + } + + fn is_supported() -> bool { + true + } +} + +impl Drop for MlockSecureKeyContainer { + fn drop(&mut self) { + unsafe { + memsec::free(self.ptr); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_multiple_keys() { + let key1 = MemoryEncryptionKey::new(); + let key2 = MemoryEncryptionKey::new(); + let container1 = MlockSecureKeyContainer::from_key(key1); + let container2 = MlockSecureKeyContainer::from_key(key2); + + // Capture at time 1 + let data_1_1 = container1.as_key(); + let data_2_1 = container2.as_key(); + // Capture at time 2 + let data_1_2 = container1.as_key(); + let data_2_2 = container2.as_key(); + + // Same keys should be equal + assert_eq!(data_1_1.as_ref(), data_1_2.as_ref()); + assert_eq!(data_2_1.as_ref(), data_2_2.as_ref()); + + // Different keys should be different + assert_ne!(data_1_1.as_ref(), data_2_1.as_ref()); + assert_ne!(data_1_2.as_ref(), data_2_2.as_ref()); + } + + #[test] + fn test_is_supported() { + assert!(MlockSecureKeyContainer::is_supported()); + } +} diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs new file mode 100644 index 00000000000..6c3b53117a5 --- /dev/null +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs @@ -0,0 +1,242 @@ +//! This module provides hardened storage for single cryptographic keys. These are meant for encrypting large amounts of memory. +//! Some platforms restrict how many keys can be protected by their APIs, which necessitates this layer of indirection. This significantly +//! reduces the complexity of each platform specific implementation, since all that's needed is implementing protecting a single fixed sized key +//! instead of protecting many arbitrarily sized secrets. This significantly lowers the effort to maintain each implementation. +//! +//! The implementations include DPAPI on Windows, `keyctl` on Linux, and `memfd_secret` on Linux, and a fallback implementation using mlock. + +use tracing::info; + +mod crypto; +#[cfg(target_os = "windows")] +mod dpapi; +#[cfg(target_os = "linux")] +mod keyctl; +#[cfg(target_os = "linux")] +mod memfd_secret; +mod mlock; + +pub use crypto::EncryptedMemory; + +use crate::secure_memory::secure_key::crypto::DecryptionError; + +/// An ephemeral key that is protected using a platform mechanism. It is generated on construction freshly, and can be used +/// to encrypt and decrypt segments of memory. Since the key is ephemeral, persistent data cannot be encrypted with this key. +/// On Linux and Windows, in most cases the protection mechanisms prevent memory dumps/debuggers from reading the key. +/// +/// Note: This can be circumvented if code can be injected into the process and is only effective in combination with the +/// memory isolation provided in `process_isolation`. +/// - https://github.com/zer1t0/keydump +#[allow(unused)] +pub(crate) struct SecureMemoryEncryptionKey(CrossPlatformSecureKeyContainer); + +impl SecureMemoryEncryptionKey { + pub fn new() -> Self { + SecureMemoryEncryptionKey(CrossPlatformSecureKeyContainer::from_key( + crypto::MemoryEncryptionKey::new(), + )) + } + + /// Encrypts the provided plaintext using the contained key, returning an EncryptedMemory blob. + #[allow(unused)] + pub fn encrypt(&self, plaintext: &[u8]) -> crypto::EncryptedMemory { + self.0.as_key().encrypt(plaintext) + } + + /// Decrypts the provided EncryptedMemory blob using the contained key, returning the plaintext. + /// If the decryption fails, that means the memory was tampered with, and the function panics. + #[allow(unused)] + pub fn decrypt(&self, encrypted: &crypto::EncryptedMemory) -> Result, DecryptionError> { + self.0.as_key().decrypt(encrypted) + } +} + +/// A platform specific implementation of a key container that protects a single encryption key +/// from memory attacks. +#[allow(unused)] +trait SecureKeyContainer: Sync + Send { + /// Returns the key as a byte slice. This slice does not have additional memory protections applied. + fn as_key(&self) -> crypto::MemoryEncryptionKey; + /// Creates a new SecureKeyContainer from the provided key. + fn from_key(key: crypto::MemoryEncryptionKey) -> Self; + /// Returns true if this platform supports this secure key container implementation. + fn is_supported() -> bool; +} + +#[allow(unused)] +enum CrossPlatformSecureKeyContainer { + #[cfg(target_os = "windows")] + Dpapi(dpapi::DpapiSecureKeyContainer), + #[cfg(target_os = "linux")] + Keyctl(keyctl::KeyctlSecureKeyContainer), + #[cfg(target_os = "linux")] + MemfdSecret(memfd_secret::MemfdSecretSecureKeyContainer), + Mlock(mlock::MlockSecureKeyContainer), +} + +impl SecureKeyContainer for CrossPlatformSecureKeyContainer { + fn as_key(&self) -> crypto::MemoryEncryptionKey { + match self { + #[cfg(target_os = "windows")] + CrossPlatformSecureKeyContainer::Dpapi(c) => c.as_key(), + #[cfg(target_os = "linux")] + CrossPlatformSecureKeyContainer::Keyctl(c) => c.as_key(), + #[cfg(target_os = "linux")] + CrossPlatformSecureKeyContainer::MemfdSecret(c) => c.as_key(), + CrossPlatformSecureKeyContainer::Mlock(c) => c.as_key(), + } + } + + fn from_key(key: crypto::MemoryEncryptionKey) -> Self { + if let Some(container) = get_env_forced_container() { + return container; + } + + #[cfg(target_os = "windows")] + { + if dpapi::DpapiSecureKeyContainer::is_supported() { + info!("Using DPAPI for secure key storage"); + return CrossPlatformSecureKeyContainer::Dpapi( + dpapi::DpapiSecureKeyContainer::from_key(key), + ); + } + } + #[cfg(target_os = "linux")] + { + // Memfd_secret is slightly better in some cases of the kernel being compromised. + // Note that keyctl may sometimes not be available in e.g. snap. Memfd_secret is + // not available on kernels older than 6.5 while keyctl is supported since 2.6. + // + // Note: This may prevent the system from hibernating but not sleeping. Hibernate + // would write the memory to disk, exposing the keys. If this is an issue, + // the environment variable `SECURE_KEY_CONTAINER_BACKEND` can be used + // to force the use of keyctl or mlock. + if memfd_secret::MemfdSecretSecureKeyContainer::is_supported() { + info!("Using memfd_secret for secure key storage"); + return CrossPlatformSecureKeyContainer::MemfdSecret( + memfd_secret::MemfdSecretSecureKeyContainer::from_key(key), + ); + } + if keyctl::KeyctlSecureKeyContainer::is_supported() { + info!("Using keyctl for secure key storage"); + return CrossPlatformSecureKeyContainer::Keyctl( + keyctl::KeyctlSecureKeyContainer::from_key(key), + ); + } + } + + // Falling back to mlock means that the key is accessible via memory dumping. + info!("Falling back to mlock for secure key storage"); + CrossPlatformSecureKeyContainer::Mlock(mlock::MlockSecureKeyContainer::from_key(key)) + } + + fn is_supported() -> bool { + // Mlock is always supported as a fallback. + true + } +} + +fn get_env_forced_container() -> Option { + let env_var = std::env::var("SECURE_KEY_CONTAINER_BACKEND"); + match env_var.as_deref() { + #[cfg(target_os = "windows")] + Ok("dpapi") => { + info!("Forcing DPAPI secure key container via environment variable"); + Some(CrossPlatformSecureKeyContainer::Dpapi( + dpapi::DpapiSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::new()), + )) + } + #[cfg(target_os = "linux")] + Ok("memfd_secret") => { + info!("Forcing memfd_secret secure key container via environment variable"); + Some(CrossPlatformSecureKeyContainer::MemfdSecret( + memfd_secret::MemfdSecretSecureKeyContainer::from_key( + crypto::MemoryEncryptionKey::new(), + ), + )) + } + #[cfg(target_os = "linux")] + Ok("keyctl") => { + info!("Forcing keyctl secure key container via environment variable"); + Some(CrossPlatformSecureKeyContainer::Keyctl( + keyctl::KeyctlSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::new()), + )) + } + Ok("mlock") => { + info!("Forcing mlock secure key container via environment variable"); + Some(CrossPlatformSecureKeyContainer::Mlock( + mlock::MlockSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::new()), + )) + } + _ => { + info!( + "{} is not a valid secure key container backend, using automatic selection", + env_var.unwrap_or_default() + ); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_multiple_keys() { + // Create 20 different keys + let original_keys: Vec = (0..20) + .map(|_| crypto::MemoryEncryptionKey::new()) + .collect(); + + // Store them in secure containers + let containers: Vec = original_keys + .iter() + .map(|key| { + let key_bytes: &[u8; crypto::KEY_SIZE] = key.as_ref().try_into().unwrap(); + CrossPlatformSecureKeyContainer::from_key(crypto::MemoryEncryptionKey::from( + key_bytes, + )) + }) + .collect(); + + // Read all keys back and validate they match the originals + for (i, (original_key, container)) in + original_keys.iter().zip(containers.iter()).enumerate() + { + let retrieved_key = container.as_key(); + assert_eq!( + original_key.as_ref(), + retrieved_key.as_ref(), + "Key {} should match after storage and retrieval", + i + ); + } + + // Verify all keys are different from each other + for i in 0..original_keys.len() { + for j in (i + 1)..original_keys.len() { + assert_ne!( + original_keys[i].as_ref(), + original_keys[j].as_ref(), + "Keys {} and {} should be different", + i, + j + ); + } + } + + // Read keys back a second time to ensure consistency + for (i, (original_key, container)) in + original_keys.iter().zip(containers.iter()).enumerate() + { + let retrieved_key_again = container.as_key(); + assert_eq!( + original_key.as_ref(), + retrieved_key_again.as_ref(), + "Key {} should still match on second retrieval", + i + ); + } + } +} diff --git a/apps/desktop/src/auth/components/set-pin.component.ts b/apps/desktop/src/auth/components/set-pin.component.ts index 93e1ea0d25c..5bb8e761b32 100644 --- a/apps/desktop/src/auth/components/set-pin.component.ts +++ b/apps/desktop/src/auth/components/set-pin.component.ts @@ -12,6 +12,8 @@ import { FormFieldModule, 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: "set-pin.component.html", imports: [ diff --git a/apps/desktop/src/auth/delete-account.component.ts b/apps/desktop/src/auth/delete-account.component.ts index b6c6650375d..5cd73896e07 100644 --- a/apps/desktop/src/auth/delete-account.component.ts +++ b/apps/desktop/src/auth/delete-account.component.ts @@ -20,6 +20,8 @@ import { import { UserVerificationComponent } from "../app/components/user-verification.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-delete-account", templateUrl: "delete-account.component.html", diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html new file mode 100644 index 00000000000..2388bb06bd8 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html @@ -0,0 +1,91 @@ +
+ + + @let title = (multiStepSubmit | async)[currentStep()]?.titleContent(); + @if (title) { + + } + + + + @if (loading) { +
+ + {{ "loading" | i18n }} +
+ } +
+ @if (policy.showDescription) { +

{{ policy.description | i18n }}

+ } +
+ +
+ + @let footer = (multiStepSubmit | async)[currentStep()]?.footerContent(); + @if (footer) { + + } + +
+
+ + +
+ @let showBadge = firstTimeDialog(); + @if (showBadge) { + {{ "availableNow" | i18n }} + } + + {{ (firstTimeDialog ? "autoConfirm" : "editPolicy") | i18n }} + @if (!firstTimeDialog) { + + {{ policy.name | i18n }} + + } + +
+
+ + + {{ "howToTurnOnAutoConfirm" | i18n }} + + + + + + + + + + + diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts new file mode 100644 index 00000000000..25b9ddb6591 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts @@ -0,0 +1,249 @@ +import { + AfterViewInit, + ChangeDetectorRef, + Component, + Inject, + signal, + Signal, + TemplateRef, + viewChild, +} from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { Router } from "@angular/router"; +import { + combineLatest, + firstValueFrom, + map, + Observable, + of, + shareReplay, + startWith, + switchMap, + tap, +} from "rxjs"; + +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + DIALOG_DATA, + DialogConfig, + DialogRef, + DialogService, + ToastService, +} from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; + +import { SharedModule } from "../../../shared"; + +import { AutoConfirmPolicyEditComponent } from "./policy-edit-definitions/auto-confirm-policy.component"; +import { + PolicyEditDialogComponent, + PolicyEditDialogData, + PolicyEditDialogResult, +} from "./policy-edit-dialog.component"; + +export type MultiStepSubmit = { + sideEffect: () => Promise; + footerContent: Signal | undefined>; + titleContent: Signal | undefined>; +}; + +export type AutoConfirmPolicyDialogData = PolicyEditDialogData & { + firstTimeDialog?: boolean; +}; + +/** + * Custom policy dialog component for Auto-Confirm policy. + * Satisfies the PolicyDialogComponent interface structurally + * via its static open() function. + */ +@Component({ + templateUrl: "auto-confirm-edit-policy-dialog.component.html", + imports: [SharedModule], +}) +export class AutoConfirmPolicyDialogComponent + extends PolicyEditDialogComponent + implements AfterViewInit +{ + policyType = PolicyType; + + protected readonly firstTimeDialog = signal(false); + protected readonly currentStep = signal(0); + protected multiStepSubmit: Observable = of([]); + protected autoConfirmEnabled$: Observable = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.policyService.policies$(userId)), + map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm)?.enabled ?? false), + ); + + private readonly submitPolicy: Signal | undefined> = viewChild("step0"); + private readonly openExtension: Signal | undefined> = viewChild("step1"); + + private readonly submitPolicyTitle: Signal | undefined> = viewChild("step0Title"); + private readonly openExtensionTitle: Signal | undefined> = viewChild("step1Title"); + + override policyComponent: AutoConfirmPolicyEditComponent | undefined; + + constructor( + @Inject(DIALOG_DATA) protected data: AutoConfirmPolicyDialogData, + accountService: AccountService, + policyApiService: PolicyApiServiceAbstraction, + i18nService: I18nService, + cdr: ChangeDetectorRef, + formBuilder: FormBuilder, + dialogRef: DialogRef, + toastService: ToastService, + configService: ConfigService, + keyService: KeyService, + private policyService: PolicyService, + private router: Router, + ) { + super( + data, + accountService, + policyApiService, + i18nService, + cdr, + formBuilder, + dialogRef, + toastService, + configService, + keyService, + ); + + this.firstTimeDialog.set(data.firstTimeDialog ?? false); + } + + /** + * Instantiates the child policy component and inserts it into the view. + */ + async ngAfterViewInit() { + await super.ngAfterViewInit(); + + if (this.policyComponent) { + this.saveDisabled$ = combineLatest([ + this.autoConfirmEnabled$, + this.policyComponent.enabled.valueChanges.pipe( + startWith(this.policyComponent.enabled.value), + ), + ]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value)); + } + + this.multiStepSubmit = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.policyService.policies$(userId)), + map((policies) => policies.find((p) => p.type === PolicyType.SingleOrg)?.enabled ?? false), + tap((singleOrgPolicyEnabled) => + this.policyComponent?.setSingleOrgEnabled(singleOrgPolicyEnabled), + ), + map((singleOrgPolicyEnabled) => [ + { + sideEffect: () => this.handleSubmit(singleOrgPolicyEnabled ?? false), + footerContent: this.submitPolicy, + titleContent: this.submitPolicyTitle, + }, + { + sideEffect: () => this.openBrowserExtension(), + footerContent: this.openExtension, + titleContent: this.openExtensionTitle, + }, + ]), + shareReplay({ bufferSize: 1, refCount: true }), + ); + } + + private async handleSubmit(singleOrgEnabled: boolean) { + if (!singleOrgEnabled) { + await this.submitSingleOrg(); + } + await this.submitAutoConfirm(); + } + + /** + * Triggers policy submission for auto confirm. + * @returns boolean: true if multi-submit workflow should continue, false otherwise. + */ + private async submitAutoConfirm() { + if (!this.policyComponent) { + throw new Error("PolicyComponent not initialized."); + } + + const autoConfirmRequest = await this.policyComponent.buildRequest(); + await this.policyApiService.putPolicy( + this.data.organizationId, + this.data.policy.type, + autoConfirmRequest, + ); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)), + }); + + if (!this.policyComponent.enabled.value) { + this.dialogRef.close("saved"); + } + } + + private async submitSingleOrg(): Promise { + const singleOrgRequest: PolicyRequest = { + type: PolicyType.SingleOrg, + enabled: true, + data: null, + }; + + await this.policyApiService.putPolicy( + this.data.organizationId, + PolicyType.SingleOrg, + singleOrgRequest, + ); + } + + private async openBrowserExtension() { + await this.router.navigate(["/browser-extension-prompt"], { + queryParams: { url: "AutoConfirm" }, + }); + } + + submit = async () => { + if (!this.policyComponent) { + throw new Error("PolicyComponent not initialized."); + } + + if ((await this.policyComponent.confirm()) == false) { + this.dialogRef.close(); + return; + } + + try { + const multiStepSubmit = await firstValueFrom(this.multiStepSubmit); + await multiStepSubmit[this.currentStep()].sideEffect(); + + if (this.currentStep() === multiStepSubmit.length - 1) { + this.dialogRef.close("saved"); + return; + } + + this.currentStep.update((value) => value + 1); + this.policyComponent.setStep(this.currentStep()); + } catch (error: any) { + this.toastService.showToast({ + variant: "error", + message: error.message, + }); + } + }; + + static open = ( + dialogService: DialogService, + config: DialogConfig, + ) => { + return dialogService.open(AutoConfirmPolicyDialogComponent, config); + }; +} diff --git a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts index 9293c686b7f..9bf0ad24b1b 100644 --- a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts @@ -8,8 +8,20 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request"; import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; -import type { PolicyEditDialogComponent } from "./policy-edit-dialog.component"; +import type { PolicyEditDialogData, PolicyEditDialogResult } from "./policy-edit-dialog.component"; + +/** + * Interface for policy dialog components. + * Any component that implements this interface can be used as a custom policy edit dialog. + */ +export interface PolicyDialogComponent { + open: ( + dialogService: DialogService, + config: DialogConfig, + ) => DialogRef; +} /** * A metadata class that defines how a policy is displayed in the Admin Console Policies page for editing. @@ -37,9 +49,8 @@ export abstract class BasePolicyEditDefinition { /** * The dialog component that will be opened when editing this policy. * This allows customizing the look and feel of each policy's dialog contents. - * If not specified, defaults to {@link PolicyEditDialogComponent}. */ - editDialogComponent?: typeof PolicyEditDialogComponent; + editDialogComponent?: PolicyDialogComponent; /** * If true, the {@link description} will be reused in the policy edit modal. Set this to false if you diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts index 95c00f74f1c..7bab6f262a6 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts @@ -1,17 +1,18 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; import { combineLatest, firstValueFrom, - lastValueFrom, Observable, of, switchMap, first, map, withLatestFrom, + tap, } from "rxjs"; import { @@ -19,9 +20,11 @@ import { OrganizationService, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { DialogService } from "@bitwarden/components"; import { safeProvider } from "@bitwarden/ui-common"; @@ -29,7 +32,7 @@ import { safeProvider } from "@bitwarden/ui-common"; import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; -import { BasePolicyEditDefinition } from "./base-policy-edit.component"; +import { BasePolicyEditDefinition, PolicyDialogComponent } from "./base-policy-edit.component"; import { PolicyEditDialogComponent } from "./policy-edit-dialog.component"; import { PolicyListService } from "./policy-list.service"; import { POLICY_EDIT_REGISTER } from "./policy-register-token"; @@ -59,8 +62,18 @@ export class PoliciesComponent implements OnInit { private policyApiService: PolicyApiServiceAbstraction, private policyListService: PolicyListService, private dialogService: DialogService, + private policyService: PolicyService, protected configService: ConfigService, - ) {} + ) { + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => this.policyService.policies$(userId)), + tap(async () => await this.load()), + takeUntilDestroyed(), + ) + .subscribe(); + } async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe @@ -127,17 +140,13 @@ export class PoliciesComponent implements OnInit { } async edit(policy: BasePolicyEditDefinition) { - const dialogComponent = policy.editDialogComponent ?? PolicyEditDialogComponent; - const dialogRef = dialogComponent.open(this.dialogService, { + const dialogComponent: PolicyDialogComponent = + policy.editDialogComponent ?? PolicyEditDialogComponent; + dialogComponent.open(this.dialogService, { data: { policy: policy, organizationId: this.organizationId, }, }); - - const result = await lastValueFrom(dialogRef.closed); - if (result == "saved") { - await this.load(); - } } } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html new file mode 100644 index 00000000000..8334b451d22 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html @@ -0,0 +1,59 @@ + + + +

+ {{ "autoConfirmPolicyEditDescription" | i18n }} +

+ +
    +
  • + + {{ "autoConfirmAcceptSecurityRiskTitle" | i18n }} + + {{ "autoConfirmAcceptSecurityRiskDescription" | i18n }} + + {{ "autoConfirmAcceptSecurityRiskLearnMore" | i18n }} + + +
  • + +
  • + @if (singleOrgEnabled$ | async) { + + {{ "autoConfirmSingleOrgExemption" | i18n }} + + } @else { + + {{ "autoConfirmSingleOrgRequired" | i18n }} + + } + {{ "autoConfirmSingleOrgRequiredDescription" | i18n }} +
  • + +
  • + + {{ "autoConfirmNoEmergencyAccess" | i18n }} + + {{ "autoConfirmNoEmergencyAccessDescription" | i18n }} +
  • +
+ + + {{ "autoConfirmCheckBoxLabel" | i18n }} +
+ + +
+ +
+
    +
  1. 1. {{ "autoConfirmStep1" | i18n }}
  2. + +
  3. + 2. {{ "autoConfirmStep2a" | i18n }} + + {{ "autoConfirmStep2b" | i18n }} + +
  4. +
+
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts new file mode 100644 index 00000000000..7948bf36af4 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts @@ -0,0 +1,50 @@ +import { Component, OnInit, Signal, TemplateRef, viewChild } from "@angular/core"; +import { BehaviorSubject, map, Observable } from "rxjs"; + +import { AutoConfirmSvg } from "@bitwarden/assets/svg"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +import { SharedModule } from "../../../../shared"; +import { AutoConfirmPolicyDialogComponent } from "../auto-confirm-edit-policy-dialog.component"; +import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component"; + +export class AutoConfirmPolicy extends BasePolicyEditDefinition { + name = "autoConfirm"; + description = "autoConfirmDescription"; + type = PolicyType.AutoConfirm; + component = AutoConfirmPolicyEditComponent; + showDescription = false; + editDialogComponent = AutoConfirmPolicyDialogComponent; + + override display$(organization: Organization, configService: ConfigService): Observable { + return configService + .getFeatureFlag$(FeatureFlag.AutoConfirm) + .pipe(map((enabled) => enabled && organization.useAutomaticUserConfirmation)); + } +} + +@Component({ + templateUrl: "auto-confirm-policy.component.html", + imports: [SharedModule], +}) +export class AutoConfirmPolicyEditComponent extends BasePolicyEditComponent implements OnInit { + protected readonly autoConfirmSvg = AutoConfirmSvg; + private readonly policyForm: Signal | undefined> = viewChild("step0"); + private readonly extensionButton: Signal | undefined> = viewChild("step1"); + + protected step: number = 0; + protected steps = [this.policyForm, this.extensionButton]; + + protected singleOrgEnabled$: BehaviorSubject = new BehaviorSubject(false); + + setSingleOrgEnabled(enabled: boolean) { + this.singleOrgEnabled$.next(enabled); + } + + setStep(step: number) { + this.step = step; + } +} diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts index bb2c40b7a76..7373e1ff888 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts @@ -14,3 +14,4 @@ export { vNextOrganizationDataOwnershipPolicy, vNextOrganizationDataOwnershipPolicyComponent, } from "./vnext-organization-data-ownership.component"; +export { AutoConfirmPolicy } from "./auto-confirm-policy.component"; diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts index f0672d0f861..d98b5d4809b 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts @@ -30,7 +30,7 @@ import { KeyService } from "@bitwarden/key-management"; import { SharedModule } from "../../../shared"; import { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component"; -import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions"; +import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions/vnext-organization-data-ownership.component"; export type PolicyEditDialogData = { /** @@ -64,13 +64,13 @@ export class PolicyEditDialogComponent implements AfterViewInit { }); constructor( @Inject(DIALOG_DATA) protected data: PolicyEditDialogData, - private accountService: AccountService, - private policyApiService: PolicyApiServiceAbstraction, - private i18nService: I18nService, + protected accountService: AccountService, + protected policyApiService: PolicyApiServiceAbstraction, + protected i18nService: I18nService, private cdr: ChangeDetectorRef, private formBuilder: FormBuilder, - private dialogRef: DialogRef, - private toastService: ToastService, + protected dialogRef: DialogRef, + protected toastService: ToastService, private configService: ConfigService, private keyService: KeyService, ) {} diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts index 5e63ba1358a..ca44818764c 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts @@ -1,5 +1,6 @@ import { BasePolicyEditDefinition } from "./base-policy-edit.component"; import { + AutoConfirmPolicy, DesktopAutotypeDefaultSettingPolicy, DisableSendPolicy, MasterPasswordPolicy, @@ -33,4 +34,5 @@ export const ossPolicyEditRegister: BasePolicyEditDefinition[] = [ new SendOptionsPolicy(), new RestrictedItemTypesPolicy(), new DesktopAutotypeDefaultSettingPolicy(), + new AutoConfirmPolicy(), ]; diff --git a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts index e1b7329504c..d1491e6d782 100644 --- a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts +++ b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts @@ -12,6 +12,8 @@ import { SharedModule } from "../../../shared"; import { EmergencyAccessModule } from "../emergency-access.module"; import { EmergencyAccessService } from "../services/emergency-access.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({ imports: [SharedModule, EmergencyAccessModule], templateUrl: "accept-emergency.component.html", diff --git a/apps/web/src/app/auth/guards/deep-link/deep-link.guard.spec.ts b/apps/web/src/app/auth/guards/deep-link/deep-link.guard.spec.ts index dba4dbd8357..31033b29154 100644 --- a/apps/web/src/app/auth/guards/deep-link/deep-link.guard.spec.ts +++ b/apps/web/src/app/auth/guards/deep-link/deep-link.guard.spec.ts @@ -11,18 +11,24 @@ import { RouterService } from "../../../core/router.service"; import { deepLinkGuard } from "./deep-link.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, }) export class GuardedRouteTestComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, }) export class LockTestComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.component.ts b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts index 09d4fc3e9ef..f98a62f91ea 100644 --- a/apps/web/src/app/auth/organization-invite/accept-organization.component.ts +++ b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts @@ -16,6 +16,8 @@ import { BaseAcceptComponent } from "../../common/base.accept.component"; import { AcceptOrganizationInviteService } from "./accept-organization.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: "accept-organization.component.html", standalone: false, diff --git a/apps/web/src/app/auth/recover-delete.component.ts b/apps/web/src/app/auth/recover-delete.component.ts index 7381d526879..00b14f9a402 100644 --- a/apps/web/src/app/auth/recover-delete.component.ts +++ b/apps/web/src/app/auth/recover-delete.component.ts @@ -10,6 +10,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { 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-recover-delete", templateUrl: "recover-delete.component.html", diff --git a/apps/web/src/app/auth/recover-two-factor.component.ts b/apps/web/src/app/auth/recover-two-factor.component.ts index f1b23f6fb14..9c064e16802 100644 --- a/apps/web/src/app/auth/recover-two-factor.component.ts +++ b/apps/web/src/app/auth/recover-two-factor.component.ts @@ -16,6 +16,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { 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-recover-two-factor", templateUrl: "recover-two-factor.component.html", diff --git a/apps/web/src/app/auth/settings/account/account.component.ts b/apps/web/src/app/auth/settings/account/account.component.ts index 921db19bc49..8bae8cd2c1f 100644 --- a/apps/web/src/app/auth/settings/account/account.component.ts +++ b/apps/web/src/app/auth/settings/account/account.component.ts @@ -19,6 +19,8 @@ import { DeleteAccountDialogComponent } from "./delete-account-dialog.component" import { ProfileComponent } from "./profile.component"; import { SetAccountVerifyDevicesDialogComponent } from "./set-account-verify-devices-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({ templateUrl: "account.component.html", imports: [ diff --git a/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts index 6bb785fb8f5..6e6fac1404e 100644 --- a/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts +++ b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts @@ -32,6 +32,8 @@ type ChangeAvatarDialogData = { profile: ProfileResponse; }; +// 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: "change-avatar-dialog.component.html", encapsulation: ViewEncapsulation.None, @@ -40,6 +42,8 @@ type ChangeAvatarDialogData = { export class ChangeAvatarDialogComponent implements OnInit, OnDestroy { profile: ProfileResponse; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("colorPicker") colorPickerElement: ElementRef; loading = false; diff --git a/apps/web/src/app/auth/settings/account/change-email.component.ts b/apps/web/src/app/auth/settings/account/change-email.component.ts index b6ca39c6413..ee29e0c8a9c 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.ts +++ b/apps/web/src/app/auth/settings/account/change-email.component.ts @@ -17,6 +17,8 @@ import { KdfConfigService, KeyService } from "@bitwarden/key-management"; 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-change-email", templateUrl: "change-email.component.html", diff --git a/apps/web/src/app/auth/settings/account/danger-zone.component.ts b/apps/web/src/app/auth/settings/account/danger-zone.component.ts index 05fd22d087d..e60ff6ec03d 100644 --- a/apps/web/src/app/auth/settings/account/danger-zone.component.ts +++ b/apps/web/src/app/auth/settings/account/danger-zone.component.ts @@ -9,6 +9,8 @@ import { I18nPipe } from "@bitwarden/ui-common"; /** * Component for the Danger Zone section of the Account/Organization Settings page. */ +// 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-danger-zone", templateUrl: "danger-zone.component.html", diff --git a/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts b/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts index f75320e8335..b792963ae9b 100644 --- a/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts +++ b/apps/web/src/app/auth/settings/account/deauthorize-sessions.component.ts @@ -12,6 +12,8 @@ import { DialogService, 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({ templateUrl: "deauthorize-sessions.component.html", imports: [SharedModule, UserVerificationFormInputComponent], diff --git a/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts b/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts index 7e8f169994f..76eb067fdd2 100644 --- a/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts +++ b/apps/web/src/app/auth/settings/account/delete-account-dialog.component.ts @@ -12,6 +12,8 @@ import { DialogRef, DialogService, 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({ templateUrl: "delete-account-dialog.component.html", imports: [SharedModule, UserVerificationFormInputComponent], diff --git a/apps/web/src/app/auth/settings/account/profile.component.ts b/apps/web/src/app/auth/settings/account/profile.component.ts index 1f4fa578491..fd96f343b3a 100644 --- a/apps/web/src/app/auth/settings/account/profile.component.ts +++ b/apps/web/src/app/auth/settings/account/profile.component.ts @@ -23,6 +23,8 @@ import { AccountFingerprintComponent } from "../../../shared/components/account- import { ChangeAvatarDialogComponent } from "./change-avatar-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: "app-profile", templateUrl: "profile.component.html", diff --git a/apps/web/src/app/auth/settings/account/selectable-avatar.component.ts b/apps/web/src/app/auth/settings/account/selectable-avatar.component.ts index 630c0e949ad..b74cf8beb59 100644 --- a/apps/web/src/app/auth/settings/account/selectable-avatar.component.ts +++ b/apps/web/src/app/auth/settings/account/selectable-avatar.component.ts @@ -5,6 +5,8 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { AvatarModule } 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: "selectable-avatar", template: `(); onFire() { diff --git a/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts index c66f31f6c3b..01be46c45b3 100644 --- a/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts +++ b/apps/web/src/app/auth/settings/account/set-account-verify-devices-dialog.component.ts @@ -27,6 +27,8 @@ import { 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({ templateUrl: "./set-account-verify-devices-dialog.component.html", imports: [ diff --git a/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.ts b/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.ts index 641dde66cc4..a5fdd5212fa 100644 --- a/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.ts @@ -25,6 +25,8 @@ type EmergencyAccessConfirmDialogData = { /** 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({ templateUrl: "emergency-access-confirm.component.html", imports: [SharedModule], diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.ts b/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.ts index 04b549e7f05..2e8d02a0c4f 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-access-add-edit.component.ts @@ -35,6 +35,8 @@ export enum EmergencyAccessAddEditDialogResult { Canceled = "canceled", Deleted = "deleted", } +// 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: "emergency-access-add-edit.component.html", imports: [SharedModule, PremiumBadgeComponent], diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts index f6594f4b11a..c2b8127ec34 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts @@ -42,6 +42,8 @@ import { EmergencyAccessTakeoverDialogResultType, } from "./takeover/emergency-access-takeover-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({ templateUrl: "emergency-access.component.html", imports: [SharedModule, HeaderModule, PremiumBadgeComponent], diff --git a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts index e5c21fb82b9..743f41537e9 100644 --- a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts @@ -48,6 +48,8 @@ export type EmergencyAccessTakeoverDialogResultType = * * @link https://bitwarden.com/help/emergency-access/ */ +// 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: "auth-emergency-access-takeover-dialog", templateUrl: "./emergency-access-takeover-dialog.component.html", @@ -61,6 +63,8 @@ export type EmergencyAccessTakeoverDialogResultType = ], }) export class EmergencyAccessTakeoverDialogComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(InputPasswordComponent) inputPasswordComponent: InputPasswordComponent | undefined = undefined; diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts index 250261fb0e7..1d96a19ca74 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts @@ -14,6 +14,8 @@ import { EmergencyAccessService } from "../../../emergency-access"; import { EmergencyViewDialogComponent } from "./emergency-view-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({ templateUrl: "emergency-access-view.component.html", providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }], diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts index 656ec894f27..62cfd95ecfa 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts @@ -35,6 +35,8 @@ class PremiumUpgradePromptNoop implements PremiumUpgradePromptService { } } +// 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-emergency-view-dialog", templateUrl: "emergency-view-dialog.component.html", diff --git a/apps/web/src/app/auth/settings/security/api-key.component.ts b/apps/web/src/app/auth/settings/security/api-key.component.ts index 82d1010f020..af49ca556ab 100644 --- a/apps/web/src/app/auth/settings/security/api-key.component.ts +++ b/apps/web/src/app/auth/settings/security/api-key.component.ts @@ -23,6 +23,8 @@ export type ApiKeyDialogData = { apiKeyWarning: string; apiKeyDescription: 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: "api-key.component.html", imports: [SharedModule, UserVerificationFormInputComponent], diff --git a/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts index 0698ffe1f8d..0e37c856935 100644 --- a/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts +++ b/apps/web/src/app/auth/settings/security/password-settings/password-settings.component.ts @@ -10,6 +10,8 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { WebauthnLoginSettingsModule } from "../../webauthn-login-settings"; +// 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-password-settings", templateUrl: "password-settings.component.html", diff --git a/apps/web/src/app/auth/settings/security/security-keys.component.ts b/apps/web/src/app/auth/settings/security/security-keys.component.ts index 9d16d4380eb..27a555ff343 100644 --- a/apps/web/src/app/auth/settings/security/security-keys.component.ts +++ b/apps/web/src/app/auth/settings/security/security-keys.component.ts @@ -13,6 +13,8 @@ import { SharedModule } from "../../../shared"; import { ApiKeyComponent } from "./api-key.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: "security-keys.component.html", imports: [SharedModule, ChangeKdfModule], diff --git a/apps/web/src/app/auth/settings/security/security.component.ts b/apps/web/src/app/auth/settings/security/security.component.ts index 2a237bf6d01..ff13515eec0 100644 --- a/apps/web/src/app/auth/settings/security/security.component.ts +++ b/apps/web/src/app/auth/settings/security/security.component.ts @@ -5,6 +5,8 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use 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: "security.component.html", imports: [SharedModule, HeaderModule], diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.ts index 37d94bfae0e..543c4236d89 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.ts @@ -15,6 +15,8 @@ import { } 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: "app-two-factor-recovery", templateUrl: "two-factor-recovery.component.html", diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts index d57d6eca894..20c3c742db6 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts @@ -53,6 +53,8 @@ declare global { } } +// 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-two-factor-setup-authenticator", templateUrl: "two-factor-setup-authenticator.component.html", @@ -76,6 +78,8 @@ export class TwoFactorSetupAuthenticatorComponent extends TwoFactorSetupMethodBaseComponent 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() onChangeStatus = new EventEmitter(); type = TwoFactorProviderType.Authenticator; key: string; diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts index bf820e32917..1a476f2206d 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts @@ -30,6 +30,8 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.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-two-factor-setup-duo", templateUrl: "two-factor-setup-duo.component.html", @@ -51,6 +53,8 @@ export class TwoFactorSetupDuoComponent extends TwoFactorSetupMethodBaseComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onChangeStatus: EventEmitter = new EventEmitter(); type = TwoFactorProviderType.Duo; diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts index 138d541d551..4219fb0b687 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts @@ -33,6 +33,8 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.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-two-factor-setup-email", templateUrl: "two-factor-setup-email.component.html", @@ -54,6 +56,8 @@ export class TwoFactorSetupEmailComponent extends TwoFactorSetupMethodBaseComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onChangeStatus: EventEmitter = new EventEmitter(); type = TwoFactorProviderType.Email; sentEmail: string = ""; diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts index aa3b9e1def3..c614e45e577 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts @@ -17,6 +17,8 @@ import { DialogService, ToastService } from "@bitwarden/components"; */ @Directive({}) export abstract class TwoFactorSetupMethodBaseComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onUpdated = new EventEmitter(); type: TwoFactorProviderType | undefined; diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts index ff0e971461e..acf83ab278e 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts @@ -43,6 +43,8 @@ interface Key { removePromise: Promise | null; } +// 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-two-factor-setup-webauthn", templateUrl: "two-factor-setup-webauthn.component.html", diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts index 4e4691a5f60..09fb1ad308f 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts @@ -44,6 +44,8 @@ interface Key { existingKey: 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: "app-two-factor-setup-yubikey", templateUrl: "two-factor-setup-yubikey.component.html", diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts index c3a55ad661e..ef4d647a7d0 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts @@ -45,6 +45,8 @@ import { TwoFactorSetupWebAuthnComponent } from "./two-factor-setup-webauthn.com import { TwoFactorSetupYubiKeyComponent } from "./two-factor-setup-yubikey.component"; import { TwoFactorVerifyComponent } from "./two-factor-verify.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-two-factor-setup", templateUrl: "two-factor-setup.component.html", diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts index a2c734ed2d5..9baa93d38c0 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts @@ -28,6 +28,8 @@ type TwoFactorVerifyDialogData = { organizationId: 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: "app-two-factor-verify", templateUrl: "two-factor-verify.component.html", @@ -43,6 +45,8 @@ type TwoFactorVerifyDialogData = { export class TwoFactorVerifyComponent { type: TwoFactorProviderType; organizationId: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAuthed = new EventEmitter>(); formPromise: Promise | undefined; diff --git a/apps/web/src/app/auth/settings/verify-email.component.ts b/apps/web/src/app/auth/settings/verify-email.component.ts index 7088dae8d0f..a63d0b18b36 100644 --- a/apps/web/src/app/auth/settings/verify-email.component.ts +++ b/apps/web/src/app/auth/settings/verify-email.component.ts @@ -16,6 +16,8 @@ import { 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-verify-email", templateUrl: "verify-email.component.html", @@ -24,7 +26,11 @@ import { export class VerifyEmailComponent { actionPromise: Promise; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onVerified = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onDismiss = new EventEmitter(); constructor( diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts index 04b148e8a0a..89b7410baba 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts @@ -32,6 +32,8 @@ type Step = | "credentialCreationFailed" | "credentialNaming"; +// 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: "create-credential-dialog.component.html", standalone: false, diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts index ea766a302ca..af4b7c497fb 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts @@ -24,6 +24,8 @@ export interface DeleteCredentialDialogParams { credentialId: 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: "delete-credential-dialog.component.html", standalone: false, diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/enable-encryption-dialog/enable-encryption-dialog.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/enable-encryption-dialog/enable-encryption-dialog.component.ts index dd1ac45a9b6..24a711cb5b4 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/enable-encryption-dialog/enable-encryption-dialog.component.ts +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/enable-encryption-dialog/enable-encryption-dialog.component.ts @@ -21,6 +21,8 @@ export interface EnableEncryptionDialogParams { credentialId: 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: "enable-encryption-dialog.component.html", standalone: false, diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts index 94e926ac138..e8a278d8dd7 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts @@ -17,6 +17,8 @@ import { openCreateCredentialDialog } from "./create-credential-dialog/create-cr import { openDeleteCredentialDialogComponent } from "./delete-credential-dialog/delete-credential-dialog.component"; import { openEnableCredentialDialogComponent } from "./enable-encryption-dialog/enable-encryption-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: "app-webauthn-login-settings", templateUrl: "webauthn-login-settings.component.html", diff --git a/apps/web/src/app/auth/shared/components/user-verification/user-verification-prompt.component.ts b/apps/web/src/app/auth/shared/components/user-verification/user-verification-prompt.component.ts index 77df374f3ed..beafa48bb8e 100644 --- a/apps/web/src/app/auth/shared/components/user-verification/user-verification-prompt.component.ts +++ b/apps/web/src/app/auth/shared/components/user-verification/user-verification-prompt.component.ts @@ -21,6 +21,8 @@ import { /** * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent instead. */ +// 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: "user-verification-prompt.component.html", standalone: false, diff --git a/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.ts b/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.ts index 42f4b26fb36..7ea5014254b 100644 --- a/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.ts +++ b/apps/web/src/app/auth/shared/components/user-verification/user-verification.component.ts @@ -8,6 +8,8 @@ import { UserVerificationComponent as BaseComponent } from "@bitwarden/angular/a * @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. */ +// 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-user-verification", templateUrl: "user-verification.component.html", diff --git a/apps/web/src/app/auth/verify-email-token.component.ts b/apps/web/src/app/auth/verify-email-token.component.ts index 2c4fa7f447c..30bfcf95bbf 100644 --- a/apps/web/src/app/auth/verify-email-token.component.ts +++ b/apps/web/src/app/auth/verify-email-token.component.ts @@ -13,6 +13,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { 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-verify-email-token", templateUrl: "verify-email-token.component.html", diff --git a/apps/web/src/app/auth/verify-recover-delete.component.ts b/apps/web/src/app/auth/verify-recover-delete.component.ts index a475fdfd3e5..06d6096c3de 100644 --- a/apps/web/src/app/auth/verify-recover-delete.component.ts +++ b/apps/web/src/app/auth/verify-recover-delete.component.ts @@ -11,6 +11,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { 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-verify-recover-delete", templateUrl: "verify-recover-delete.component.html", diff --git a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts index 9de9c22d3c3..61994fdb61d 100644 --- a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts @@ -1,21 +1,29 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs"; +import { ActivatedRoute, Router } from "@angular/router"; +import { + combineLatest, + firstValueFrom, + from, + map, + Observable, + of, + shareReplay, + switchMap, +} from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { - DialogService, - ToastService, - SectionComponent, BadgeModule, - TypographyModule, + DialogService, LinkModule, + SectionComponent, + TypographyModule, } from "@bitwarden/components"; import { PricingCardComponent } from "@bitwarden/pricing"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -69,14 +77,14 @@ export class PremiumVNextComponent { constructor( private accountService: AccountService, - private i18nService: I18nService, private apiService: ApiService, private dialogService: DialogService, private platformUtilsService: PlatformUtilsService, private syncService: SyncService, - private toastService: ToastService, private billingAccountProfileStateService: BillingAccountProfileStateService, private subscriptionPricingService: SubscriptionPricingService, + private router: Router, + private activatedRoute: ActivatedRoute, ) { this.isSelfHost = this.platformUtilsService.isSelfHost(); @@ -107,6 +115,23 @@ export class PremiumVNextComponent { this.hasPremiumPersonally$, ]).pipe(map(([hasOrgPremium, hasPersonalPremium]) => !hasOrgPremium && !hasPersonalPremium)); + // redirect to user subscription page if they already have premium personally + // redirect to individual vault if they already have premium from an org + combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$]) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap(([hasPremiumFromOrg, hasPremiumPersonally]) => { + if (hasPremiumPersonally) { + return from(this.navigateToSubscriptionPage()); + } + if (hasPremiumFromOrg) { + return from(this.navigateToIndividualVault()); + } + return of(true); + }), + ) + .subscribe(); + this.personalPricingTiers$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(); @@ -141,6 +166,11 @@ export class PremiumVNextComponent { ); } + private navigateToSubscriptionPage = (): Promise => + this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute }); + + private navigateToIndividualVault = (): Promise => this.router.navigate(["/vault"]); + finalizeUpgrade = async () => { await this.apiService.refreshIdentityToken(); await this.syncService.fullSync(true); diff --git a/apps/web/src/app/billing/individual/premium/premium.component.html b/apps/web/src/app/billing/individual/premium/premium.component.html index d08b942ff8b..39b32be0853 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.html +++ b/apps/web/src/app/billing/individual/premium/premium.component.html @@ -1,132 +1,153 @@ - - -

{{ "goPremium" | i18n }}

- - {{ "alreadyPremiumFromOrg" | i18n }} - - -

{{ "premiumUpgradeUnlockFeatures" | i18n }}

-
    -
  • - - {{ "premiumSignUpStorage" | i18n }} -
  • -
  • - - {{ "premiumSignUpTwoStepOptions" | i18n }} -
  • -
  • - - {{ "premiumSignUpEmergency" | i18n }} -
  • -
  • - - {{ "premiumSignUpReports" | i18n }} -
  • -
  • - - {{ "premiumSignUpTotp" | i18n }} -
  • -
  • - - {{ "premiumSignUpSupport" | i18n }} -
  • -
  • - - {{ "premiumSignUpFuture" | i18n }} -
  • -
-

- {{ - "premiumPriceWithFamilyPlan" - | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount - }} - - {{ "bitwardenFamiliesPlan" | i18n }} - -

- + + {{ "loading" | i18n }} + +} @else { + + +

{{ "goPremium" | i18n }}

+ - {{ "purchasePremium" | i18n }} -
-
-
- - - -
- -

{{ "addons" | i18n }}

-
- - {{ "additionalStorageGb" | i18n }} - - {{ - "additionalStorageIntervalDesc" - | i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n) - }} - -
-
- -

{{ "summary" | i18n }}

- {{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }}
- {{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB × - {{ storageGBPrice | currency: "$" }} = - {{ additionalStorageCost | currency: "$" }} -
-
- -

{{ "paymentInformation" | i18n }}

-
- + +

{{ "premiumUpgradeUnlockFeatures" | i18n }}

+
    +
  • + + {{ "premiumSignUpStorage" | i18n }} +
  • +
  • + + {{ "premiumSignUpTwoStepOptions" | i18n }} +
  • +
  • + + {{ "premiumSignUpEmergency" | i18n }} +
  • +
  • + + {{ "premiumSignUpReports" | i18n }} +
  • +
  • + + {{ "premiumSignUpTotp" | i18n }} +
  • +
  • + + {{ "premiumSignUpSupport" | i18n }} +
  • +
  • + + {{ "premiumSignUpFuture" | i18n }} +
  • +
+

+ {{ + "premiumPriceWithFamilyPlan" + | i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount + }} + + {{ "bitwardenFamiliesPlan" | i18n }} + +

+ -
- - -
-
-
- {{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }} - {{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }} + {{ "purchasePremium" | i18n }} + + + + + + + + +

{{ "addons" | i18n }}

+
+ + {{ "additionalStorageGb" | i18n }} + + {{ + "additionalStorageIntervalDesc" + | i18n: "1 GB" : (storagePrice$ | async | currency: "$") : ("year" | i18n) + }} +
-
-
-

- {{ "total" | i18n }}: {{ total | currency: "USD $" }}/{{ "year" | i18n }} -

- - - - + + +

{{ "summary" | i18n }}

+ {{ "premiumMembership" | i18n }}: {{ premiumPrice$ | async | currency: "$" }}
+ {{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB × + {{ storagePrice$ | async | currency: "$" }} = + {{ storageCost$ | async | currency: "$" }} +
+
+ +

{{ "paymentInformation" | i18n }}

+
+ + + + +
+
+
+ {{ "planPrice" | i18n }}: {{ subtotal$ | async | currency: "USD $" }} + {{ "estimatedTax" | i18n }}: {{ tax$ | async | currency: "USD $" }} +
+
+
+

+ {{ "total" | i18n }}: {{ total$ | async | currency: "USD $" }}/{{ + "year" | i18n + }} +

+ +
+ + +} diff --git a/apps/web/src/app/billing/individual/premium/premium.component.ts b/apps/web/src/app/billing/individual/premium/premium.component.ts index d541ab95b95..526b020a9e3 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -4,7 +4,19 @@ import { Component, ViewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { combineLatest, concatMap, from, map, Observable, of, startWith, switchMap } from "rxjs"; +import { + combineLatest, + concatMap, + filter, + from, + map, + Observable, + of, + startWith, + switchMap, + catchError, + shareReplay, +} from "rxjs"; import { debounceTime } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -26,7 +38,9 @@ import { tokenizablePaymentMethodToLegacyEnum, NonTokenizablePaymentMethods, } from "@bitwarden/web-vault/app/billing/payment/types"; +import { SubscriptionPricingService } from "@bitwarden/web-vault/app/billing/services/subscription-pricing.service"; import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types"; +import { PersonalSubscriptionPricingTierIds } from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier"; @Component({ templateUrl: "./premium.component.html", @@ -37,7 +51,6 @@ export class PremiumComponent { @ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent; protected hasPremiumFromAnyOrganization$: Observable; - protected accountCredit$: Observable; protected hasEnoughAccountCredit$: Observable; protected formGroup = new FormGroup({ @@ -46,13 +59,66 @@ export class PremiumComponent { billingAddress: EnterBillingAddressComponent.getFormGroup(), }); + premiumPrices$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe( + map((tiers) => { + const premiumPlan = tiers.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium, + ); + + if (!premiumPlan) { + throw new Error("Could not find Premium plan"); + } + + return { + seat: premiumPlan.passwordManager.annualPrice, + storage: premiumPlan.passwordManager.annualPricePerAdditionalStorageGB, + }; + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + premiumPrice$ = this.premiumPrices$.pipe(map((prices) => prices.seat)); + + storagePrice$ = this.premiumPrices$.pipe(map((prices) => prices.storage)); + + protected isLoadingPrices$ = this.premiumPrices$.pipe( + map(() => false), + startWith(true), + catchError(() => of(false)), + ); + + storageCost$ = combineLatest([ + this.storagePrice$, + this.formGroup.controls.additionalStorage.valueChanges.pipe( + startWith(this.formGroup.value.additionalStorage), + ), + ]).pipe(map(([storagePrice, additionalStorage]) => storagePrice * additionalStorage)); + + subtotal$ = combineLatest([this.premiumPrice$, this.storageCost$]).pipe( + map(([premiumPrice, storageCost]) => premiumPrice + storageCost), + ); + + tax$ = this.formGroup.valueChanges.pipe( + filter(() => this.formGroup.valid), + debounceTime(1000), + switchMap(async () => { + const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); + const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase( + this.formGroup.value.additionalStorage, + billingAddress, + ); + return taxAmounts.tax; + }), + startWith(0), + ); + + total$ = combineLatest([this.subtotal$, this.tax$]).pipe( + map(([subtotal, tax]) => subtotal + tax), + ); + protected cloudWebVaultURL: string; protected isSelfHost = false; - - protected estimatedTax: number = 0; protected readonly familyPlanMaxUserCount = 6; - protected readonly premiumPrice = 10; - protected readonly storageGBPrice = 4; constructor( private activatedRoute: ActivatedRoute, @@ -67,6 +133,7 @@ export class PremiumComponent { private accountService: AccountService, private subscriberBillingClient: SubscriberBillingClient, private taxClient: TaxClient, + private subscriptionPricingService: SubscriptionPricingService, ) { this.isSelfHost = this.platformUtilsService.isSelfHost(); @@ -76,23 +143,23 @@ export class PremiumComponent { ), ); - // Fetch account credit - this.accountCredit$ = this.accountService.activeAccount$.pipe( + const accountCredit$ = this.accountService.activeAccount$.pipe( mapAccountToSubscriber, switchMap((account) => this.subscriberBillingClient.getCredit(account)), ); - // Check if user has enough account credit for the purchase this.hasEnoughAccountCredit$ = combineLatest([ - this.accountCredit$, - this.formGroup.valueChanges.pipe(startWith(this.formGroup.value)), + accountCredit$, + this.total$, + this.formGroup.controls.paymentMethod.controls.type.valueChanges.pipe( + startWith(this.formGroup.value.paymentMethod.type), + ), ]).pipe( - map(([credit, formValue]) => { - const selectedPaymentType = formValue.paymentMethod?.type; - if (selectedPaymentType !== NonTokenizablePaymentMethods.accountCredit) { - return true; // Not using account credit, so this check doesn't apply + map(([credit, total, paymentMethod]) => { + if (paymentMethod !== NonTokenizablePaymentMethods.accountCredit) { + return true; } - return credit >= this.total; + return credit >= total; }), ); @@ -116,14 +183,6 @@ export class PremiumComponent { }), ) .subscribe(); - - this.formGroup.valueChanges - .pipe( - debounceTime(1000), - switchMap(async () => await this.refreshSalesTax()), - takeUntilDestroyed(), - ) - .subscribe(); } finalizeUpgrade = async () => { @@ -177,38 +236,11 @@ export class PremiumComponent { await this.postFinalizeUpgrade(); }; - protected get additionalStorageCost(): number { - return this.storageGBPrice * this.formGroup.value.additionalStorage; - } - protected get premiumURL(): string { return `${this.cloudWebVaultURL}/#/settings/subscription/premium`; } - protected get subtotal(): number { - return this.premiumPrice + this.additionalStorageCost; - } - - protected get total(): number { - return this.subtotal + this.estimatedTax; - } - protected async onLicenseFileSelectedChanged(): Promise { await this.postFinalizeUpgrade(); } - - private async refreshSalesTax(): Promise { - if (this.formGroup.invalid) { - return; - } - - const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress); - - const taxAmounts = await this.taxClient.previewTaxForPremiumSubscriptionPurchase( - this.formGroup.value.additionalStorage, - billingAddress, - ); - - this.estimatedTax = taxAmounts.tax; - } } diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts b/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts index 0fb33020bc3..de80cdcbdbf 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts +++ b/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts @@ -1,9 +1,12 @@ import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -18,7 +21,8 @@ import { SubscriptionPricingService } from "./subscription-pricing.service"; describe("SubscriptionPricingService", () => { let service: SubscriptionPricingService; - let apiService: MockProxy; + let billingApiService: MockProxy; + let configService: MockProxy; let i18nService: MockProxy; let logService: MockProxy; let toastService: MockProxy; @@ -217,6 +221,15 @@ describe("SubscriptionPricingService", () => { continuationToken: null, }; + const mockPremiumPlanResponse: PremiumPlanResponse = { + seat: { + price: 10, + }, + storage: { + price: 4, + }, + } as PremiumPlanResponse; + beforeAll(() => { i18nService = mock(); logService = mock(); @@ -320,14 +333,18 @@ describe("SubscriptionPricingService", () => { }); beforeEach(() => { - apiService = mock(); + billingApiService = mock(); + configService = mock(); - apiService.getPlans.mockResolvedValue(mockPlansResponse); + billingApiService.getPlans.mockResolvedValue(mockPlansResponse); + billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); + configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value) TestBed.configureTestingModule({ providers: [ SubscriptionPricingService, - { provide: ApiService, useValue: apiService }, + { provide: BillingApiServiceAbstraction, useValue: billingApiService }, + { provide: ConfigService, useValue: configService }, { provide: I18nService, useValue: i18nService }, { provide: LogService, useValue: logService }, { provide: ToastService, useValue: toastService }, @@ -406,13 +423,16 @@ describe("SubscriptionPricingService", () => { }); it("should handle API errors by logging and showing toast", (done) => { - const errorApiService = mock(); + const errorBillingApiService = mock(); + const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); const errorToastService = mock(); const testError = new Error("API error"); - errorApiService.getPlans.mockRejectedValue(testError); + errorBillingApiService.getPlans.mockRejectedValue(testError); + errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); + errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); errorI18nService.t.mockImplementation((key: string) => { if (key === "unexpectedError") { @@ -422,7 +442,8 @@ describe("SubscriptionPricingService", () => { }); const errorService = new SubscriptionPricingService( - errorApiService, + errorBillingApiService, + errorConfigService, errorI18nService, errorLogService, errorToastService, @@ -591,13 +612,16 @@ describe("SubscriptionPricingService", () => { }); it("should handle API errors by logging and showing toast", (done) => { - const errorApiService = mock(); + const errorBillingApiService = mock(); + const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); const errorToastService = mock(); const testError = new Error("API error"); - errorApiService.getPlans.mockRejectedValue(testError); + errorBillingApiService.getPlans.mockRejectedValue(testError); + errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); + errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); errorI18nService.t.mockImplementation((key: string) => { if (key === "unexpectedError") { @@ -607,7 +631,8 @@ describe("SubscriptionPricingService", () => { }); const errorService = new SubscriptionPricingService( - errorApiService, + errorBillingApiService, + errorConfigService, errorI18nService, errorLogService, errorToastService, @@ -831,13 +856,16 @@ describe("SubscriptionPricingService", () => { }); it("should handle API errors by logging and showing toast", (done) => { - const errorApiService = mock(); + const errorBillingApiService = mock(); + const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); const errorToastService = mock(); const testError = new Error("API error"); - errorApiService.getPlans.mockRejectedValue(testError); + errorBillingApiService.getPlans.mockRejectedValue(testError); + errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); + errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); errorI18nService.t.mockImplementation((key: string) => { if (key === "unexpectedError") { @@ -847,7 +875,8 @@ describe("SubscriptionPricingService", () => { }); const errorService = new SubscriptionPricingService( - errorApiService, + errorBillingApiService, + errorConfigService, errorI18nService, errorLogService, errorToastService, @@ -871,9 +900,137 @@ describe("SubscriptionPricingService", () => { }); }); + describe("Edge case handling", () => { + it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => { + const errorBillingApiService = mock(); + const errorConfigService = mock(); + + const testError = new Error("Premium plan API error"); + errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); + errorBillingApiService.getPremiumPlan.mockRejectedValue(testError); + errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API + + const errorService = new SubscriptionPricingService( + errorBillingApiService, + errorConfigService, + i18nService, + logService, + toastService, + ); + + errorService.getPersonalSubscriptionPricingTiers$().subscribe({ + next: (tiers) => { + // Should return empty array due to error in premium plan fetch + expect(tiers).toEqual([]); + expect(logService.error).toHaveBeenCalledWith( + "Failed to fetch premium plan from API", + testError, + ); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: "An unexpected error has occurred.", + }); + done(); + }, + error: () => { + fail("Observable should not error, it should return empty array"); + }, + }); + }); + + it("should handle malformed premium plan API response", (done) => { + const errorBillingApiService = mock(); + const errorConfigService = mock(); + + // Malformed response missing the Seat property + const malformedResponse = { + Storage: { + StripePriceId: "price_storage", + Price: 4, + }, + }; + + errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); + errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any); + errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag + + const errorService = new SubscriptionPricingService( + errorBillingApiService, + errorConfigService, + i18nService, + logService, + toastService, + ); + + errorService.getPersonalSubscriptionPricingTiers$().subscribe({ + next: (tiers) => { + // Should return empty array due to validation error + expect(tiers).toEqual([]); + expect(logService.error).toHaveBeenCalled(); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: "An unexpected error has occurred.", + }); + done(); + }, + error: () => { + fail("Observable should not error, it should return empty array"); + }, + }); + }); + + it("should handle malformed premium plan with invalid price types", (done) => { + const errorBillingApiService = mock(); + const errorConfigService = mock(); + + // Malformed response with price as string instead of number + const malformedResponse = { + Seat: { + StripePriceId: "price_seat", + Price: "10", // Should be a number + }, + Storage: { + StripePriceId: "price_storage", + Price: 4, + }, + }; + + errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); + errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any); + errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag + + const errorService = new SubscriptionPricingService( + errorBillingApiService, + errorConfigService, + i18nService, + logService, + toastService, + ); + + errorService.getPersonalSubscriptionPricingTiers$().subscribe({ + next: (tiers) => { + // Should return empty array due to validation error + expect(tiers).toEqual([]); + expect(logService.error).toHaveBeenCalled(); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "", + message: "An unexpected error has occurred.", + }); + done(); + }, + error: () => { + fail("Observable should not error, it should return empty array"); + }, + }); + }); + }); + describe("Observable behavior and caching", () => { it("should share API response between multiple subscriptions", () => { - const getPlansResponse = jest.spyOn(apiService, "getPlans"); + const getPlansResponse = jest.spyOn(billingApiService, "getPlans"); // Subscribe to multiple observables service.getPersonalSubscriptionPricingTiers$().subscribe(); @@ -883,5 +1040,67 @@ describe("SubscriptionPricingService", () => { // API should only be called once due to shareReplay expect(getPlansResponse).toHaveBeenCalledTimes(1); }); + + it("should share premium plan API response between multiple subscriptions when feature flag is enabled", () => { + // Create a new mock to avoid conflicts with beforeEach setup + const newBillingApiService = mock(); + const newConfigService = mock(); + + newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); + newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); + newConfigService.getFeatureFlag$.mockReturnValue(of(true)); + + const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan"); + + // Create a new service instance with the feature flag enabled + const newService = new SubscriptionPricingService( + newBillingApiService, + newConfigService, + i18nService, + logService, + toastService, + ); + + // Subscribe to the premium pricing tier multiple times + newService.getPersonalSubscriptionPricingTiers$().subscribe(); + newService.getPersonalSubscriptionPricingTiers$().subscribe(); + + // API should only be called once due to shareReplay on premiumPlanResponse$ + expect(getPremiumPlanSpy).toHaveBeenCalledTimes(1); + }); + + it("should use hardcoded premium price when feature flag is disabled", (done) => { + // Create a new mock to test from scratch + const newBillingApiService = mock(); + const newConfigService = mock(); + + newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); + newBillingApiService.getPremiumPlan.mockResolvedValue({ + seat: { price: 999 }, // Different price to verify hardcoded value is used + storage: { price: 999 }, + } as PremiumPlanResponse); + newConfigService.getFeatureFlag$.mockReturnValue(of(false)); + + // Create a new service instance with the feature flag disabled + const newService = new SubscriptionPricingService( + newBillingApiService, + newConfigService, + i18nService, + logService, + toastService, + ); + + // Subscribe with feature flag disabled + newService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => { + const premiumTier = tiers.find( + (tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium, + ); + + // Should use hardcoded value of 10, not the API response value of 999 + expect(premiumTier!.passwordManager.annualPrice).toBe(10); + expect(premiumTier!.passwordManager.annualPricePerAdditionalStorageGB).toBe(4); + done(); + }); + }); }); }); diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.ts b/apps/web/src/app/billing/services/subscription-pricing.service.ts index 82ec9f180b9..71729a42d23 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.ts +++ b/apps/web/src/app/billing/services/subscription-pricing.service.ts @@ -1,11 +1,14 @@ import { Injectable } from "@angular/core"; -import { combineLatest, from, map, Observable, of, shareReplay } from "rxjs"; +import { combineLatest, from, map, Observable, of, shareReplay, switchMap, take } from "rxjs"; import { catchError } from "rxjs/operators"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanType } from "@bitwarden/common/billing/enums"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -20,8 +23,18 @@ import { @Injectable({ providedIn: BillingServicesModule }) export class SubscriptionPricingService { + /** + * Fallback premium pricing used when the feature flag is disabled. + * These values represent the legacy pricing model and will not reflect + * server-side price changes. They are retained for backward compatibility + * during the feature flag rollout period. + */ + private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10; + private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4; + constructor( - private apiService: ApiService, + private billingApiService: BillingApiServiceAbstraction, + private configService: ConfigService, private i18nService: I18nService, private logService: LogService, private toastService: ToastService, @@ -55,34 +68,56 @@ export class SubscriptionPricingService { ); private plansResponse$: Observable> = from( - this.apiService.getPlans(), + this.billingApiService.getPlans(), ).pipe(shareReplay({ bufferSize: 1, refCount: false })); - private premium$: Observable = of({ - // premium plan is not configured server-side so for now, hardcode it - basePrice: 10, - additionalStoragePricePerGb: 4, - }).pipe( - map((details) => ({ - id: PersonalSubscriptionPricingTierIds.Premium, - name: this.i18nService.t("premium"), - description: this.i18nService.t("planDescPremium"), - availableCadences: [SubscriptionCadenceIds.Annually], - passwordManager: { - type: "standalone", - annualPrice: details.basePrice, - annualPricePerAdditionalStorageGB: details.additionalStoragePricePerGb, - features: [ - this.featureTranslations.builtInAuthenticator(), - this.featureTranslations.secureFileStorage(), - this.featureTranslations.emergencyAccess(), - this.featureTranslations.breachMonitoring(), - this.featureTranslations.andMoreFeatures(), - ], - }, - })), + private premiumPlanResponse$: Observable = from( + this.billingApiService.getPremiumPlan(), + ).pipe( + catchError((error: unknown) => { + this.logService.error("Failed to fetch premium plan from API", error); + throw error; // Re-throw to propagate to higher-level error handler + }), + shareReplay({ bufferSize: 1, refCount: false }), ); + private premium$: Observable = this.configService + .getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService) + .pipe( + take(1), // Lock behavior at first subscription to prevent switching data sources mid-stream + switchMap((fetchPremiumFromPricingService) => + fetchPremiumFromPricingService + ? this.premiumPlanResponse$.pipe( + map((premiumPlan) => ({ + seat: premiumPlan.seat.price, + storage: premiumPlan.storage.price, + })), + ) + : of({ + seat: SubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE, + storage: SubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE, + }), + ), + map((premiumPrices) => ({ + id: PersonalSubscriptionPricingTierIds.Premium, + name: this.i18nService.t("premium"), + description: this.i18nService.t("planDescPremium"), + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "standalone", + annualPrice: premiumPrices.seat, + annualPricePerAdditionalStorageGB: premiumPrices.storage, + features: [ + this.featureTranslations.builtInAuthenticator(), + this.featureTranslations.secureFileStorage(), + this.featureTranslations.emergencyAccess(), + this.featureTranslations.breachMonitoring(), + this.featureTranslations.andMoreFeatures(), + ], + }, + })), + ); + private families$: Observable = this.plansResponse$.pipe( map((plans) => { const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!; diff --git a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.html b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.html index 9f21b28f190..88c6c8b9aca 100644 --- a/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.html +++ b/apps/web/src/app/key-management/change-kdf/change-kdf-confirmation.component.html @@ -1,12 +1,14 @@
- {{ "changeKdf" | i18n }} + {{ "updateYourEncryptionSettings" | i18n }} - {{ "kdfSettingsChangeLogoutWarning" | i18n }} - + @if (!(noLogoutOnKdfChangeFeatureFlag$ | async)) { + {{ "kdfSettingsChangeLogoutWarning" | i18n }} + } + {{ "masterPass" | i18n }} {{ "confirmIdentity" | i18n }} - + + - - {{ "kdfMemory" | i18n }} - -
-
- + @if (isPBKDF2(kdfConfig)) { + {{ "kdfIterations" | i18n }} - - - {{ "kdfIterationRecommends" | i18n }} - - - - {{ "kdfIterations" | i18n }} - - - - - - {{ "kdfParallelism" | i18n }} - - - - -
+ } @else if (isArgon2(kdfConfig)) { + + {{ "kdfMemory" | i18n }} + + + }
+ @if (isArgon2(kdfConfig)) { +
+ + + {{ "kdfIterations" | i18n }} + + + +
+
+ + + {{ "kdfParallelism" | i18n }} + + + +
+ } + + +
    +
  • {{ "encryptionKeySettingsAlgorithmPopoverPBKDF2" | i18n }}
  • +
  • {{ "encryptionKeySettingsAlgorithmPopoverArgon2Id" | i18n }}
  • +
+ +
diff --git a/apps/web/src/app/key-management/change-kdf/change-kdf.component.spec.ts b/apps/web/src/app/key-management/change-kdf/change-kdf.component.spec.ts new file mode 100644 index 00000000000..c5144223ba0 --- /dev/null +++ b/apps/web/src/app/key-management/change-kdf/change-kdf.component.spec.ts @@ -0,0 +1,365 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, FormControl } from "@angular/forms"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogService, PopoverModule, CalloutModule } from "@bitwarden/components"; +import { + KdfConfigService, + Argon2KdfConfig, + PBKDF2KdfConfig, + KdfType, +} from "@bitwarden/key-management"; + +import { SharedModule } from "../../shared"; + +import { ChangeKdfComponent } from "./change-kdf.component"; + +describe("ChangeKdfComponent", () => { + let component: ChangeKdfComponent; + let fixture: ComponentFixture; + + // Mock Services + let mockDialogService: MockProxy; + let mockKdfConfigService: MockProxy; + let mockConfigService: MockProxy; + let mockI18nService: MockProxy; + let accountService: FakeAccountService; + let formBuilder: FormBuilder; + + const mockUserId = "user-id" as UserId; + + // Helper functions for validation testing + function expectPBKDF2Validation( + iterationsControl: FormControl, + memoryControl: FormControl, + parallelismControl: FormControl, + ) { + // Assert current validators state + expect(iterationsControl.hasError("required")).toBe(false); + expect(iterationsControl.hasError("min")).toBe(false); + expect(iterationsControl.hasError("max")).toBe(false); + expect(memoryControl.validator).toBeNull(); + expect(parallelismControl.validator).toBeNull(); + + // Test validation boundaries + iterationsControl.setValue(PBKDF2KdfConfig.ITERATIONS.min - 1); + expect(iterationsControl.hasError("min")).toBe(true); + + iterationsControl.setValue(PBKDF2KdfConfig.ITERATIONS.max + 1); + expect(iterationsControl.hasError("max")).toBe(true); + } + + function expectArgon2Validation( + iterationsControl: FormControl, + memoryControl: FormControl, + parallelismControl: FormControl, + ) { + // Assert current validators state + expect(iterationsControl.hasError("required")).toBe(false); + expect(memoryControl.hasError("required")).toBe(false); + expect(parallelismControl.hasError("required")).toBe(false); + + // Test validation boundaries - min values + iterationsControl.setValue(Argon2KdfConfig.ITERATIONS.min - 1); + expect(iterationsControl.hasError("min")).toBe(true); + + memoryControl.setValue(Argon2KdfConfig.MEMORY.min - 1); + expect(memoryControl.hasError("min")).toBe(true); + + parallelismControl.setValue(Argon2KdfConfig.PARALLELISM.min - 1); + expect(parallelismControl.hasError("min")).toBe(true); + + // Test validation boundaries - max values + iterationsControl.setValue(Argon2KdfConfig.ITERATIONS.max + 1); + expect(iterationsControl.hasError("max")).toBe(true); + + memoryControl.setValue(Argon2KdfConfig.MEMORY.max + 1); + expect(memoryControl.hasError("max")).toBe(true); + + parallelismControl.setValue(Argon2KdfConfig.PARALLELISM.max + 1); + expect(parallelismControl.hasError("max")).toBe(true); + } + + beforeEach(() => { + mockDialogService = mock(); + mockKdfConfigService = mock(); + mockConfigService = mock(); + mockI18nService = mock(); + accountService = mockAccountServiceWith(mockUserId); + formBuilder = new FormBuilder(); + + mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + + mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`); + + TestBed.configureTestingModule({ + declarations: [ChangeKdfComponent], + imports: [SharedModule, PopoverModule, CalloutModule], + providers: [ + { provide: DialogService, useValue: mockDialogService }, + { provide: KdfConfigService, useValue: mockKdfConfigService }, + { provide: AccountService, useValue: accountService }, + { provide: FormBuilder, useValue: formBuilder }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: I18nService, useValue: mockI18nService }, + ], + }); + }); + + describe("Component Initialization", () => { + describe("given PBKDF2 configuration", () => { + it("should initialize form with PBKDF2 values and validators when component loads", async () => { + // Arrange + const mockPBKDF2Config = new PBKDF2KdfConfig(600_000); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config); + + // Act + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + + // Extract form controls + const formGroup = component["formGroup"]; + + // Assert form values + expect(formGroup.controls.kdf.value).toBe(KdfType.PBKDF2_SHA256); + const kdfConfigFormGroup = formGroup.controls.kdfConfig; + expect(kdfConfigFormGroup.controls.iterations.value).toBe(600_000); + expect(kdfConfigFormGroup.controls.memory.value).toBeNull(); + expect(kdfConfigFormGroup.controls.parallelism.value).toBeNull(); + expect(component.kdfConfig).toEqual(mockPBKDF2Config); + + // Assert validators + expectPBKDF2Validation( + kdfConfigFormGroup.controls.iterations, + kdfConfigFormGroup.controls.memory, + kdfConfigFormGroup.controls.parallelism, + ); + }); + }); + + describe("given Argon2id configuration", () => { + it("should initialize form with Argon2id values and validators when component loads", async () => { + // Arrange + const mockArgon2Config = new Argon2KdfConfig(3, 64, 4); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2Config); + + // Act + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + + // Extract form controls + const formGroup = component["formGroup"]; + + // Assert form values + expect(formGroup.controls.kdf.value).toBe(KdfType.Argon2id); + const kdfConfigFormGroup = formGroup.controls.kdfConfig; + expect(kdfConfigFormGroup.controls.iterations.value).toBe(3); + expect(kdfConfigFormGroup.controls.memory.value).toBe(64); + expect(kdfConfigFormGroup.controls.parallelism.value).toBe(4); + expect(component.kdfConfig).toEqual(mockArgon2Config); + + // Assert validators + expectArgon2Validation( + kdfConfigFormGroup.controls.iterations, + kdfConfigFormGroup.controls.memory, + kdfConfigFormGroup.controls.parallelism, + ); + }); + }); + + it.each([ + [true, false], + [false, true], + ])( + "should show log out banner = %s when feature flag observable is %s", + async (showLogOutBanner, forceUpgradeKdfFeatureFlag) => { + // Arrange + const mockPBKDF2Config = new PBKDF2KdfConfig(600_000); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config); + mockConfigService.getFeatureFlag$.mockReturnValue(of(forceUpgradeKdfFeatureFlag)); + + // Act + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + fixture.detectChanges(); + + // Assert + const calloutElement = fixture.debugElement.query((el) => + el.nativeElement.textContent?.includes("kdfSettingsChangeLogoutWarning"), + ); + + if (showLogOutBanner) { + expect(calloutElement).not.toBeNull(); + expect(calloutElement.nativeElement.textContent).toContain( + "kdfSettingsChangeLogoutWarning-used-i18n", + ); + } else { + expect(calloutElement).toBeNull(); + } + }, + ); + }); + + describe("KDF Type Switching", () => { + describe("switching from PBKDF2 to Argon2id", () => { + beforeEach(async () => { + // Setup component with initial PBKDF2 configuration + const mockPBKDF2Config = new PBKDF2KdfConfig(600_001); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config); + + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + }); + + it("should update form structure and default values when KDF type changes to Argon2id", () => { + // Arrange + const formGroup = component["formGroup"]; + + // Act - change KDF type to Argon2id + formGroup.controls.kdf.setValue(KdfType.Argon2id); + + // Assert form values update to Argon2id defaults + expect(formGroup.controls.kdf.value).toBe(KdfType.Argon2id); + const kdfConfigFormGroup = formGroup.controls.kdfConfig; + expect(kdfConfigFormGroup.controls.iterations.value).toBe(3); // Argon2id default + expect(kdfConfigFormGroup.controls.memory.value).toBe(64); // Argon2id default + expect(kdfConfigFormGroup.controls.parallelism.value).toBe(4); // Argon2id default + }); + + it("should update validators when KDF type changes to Argon2id", () => { + // Arrange + const formGroup = component["formGroup"]; + + // Act - change KDF type to Argon2id + formGroup.controls.kdf.setValue(KdfType.Argon2id); + + // Assert validators update to Argon2id validation rules + const kdfConfigFormGroup = formGroup.controls.kdfConfig; + expectArgon2Validation( + kdfConfigFormGroup.controls.iterations, + kdfConfigFormGroup.controls.memory, + kdfConfigFormGroup.controls.parallelism, + ); + }); + }); + + describe("switching from Argon2id to PBKDF2", () => { + beforeEach(async () => { + // Setup component with initial Argon2id configuration + const mockArgon2IdConfig = new Argon2KdfConfig(4, 65, 5); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2IdConfig); + + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + }); + + it("should update form structure and default values when KDF type changes to PBKDF2", () => { + // Arrange + const formGroup = component["formGroup"]; + + // Act - change KDF type back to PBKDF2 + formGroup.controls.kdf.setValue(KdfType.PBKDF2_SHA256); + + // Assert form values update to PBKDF2 defaults + expect(formGroup.controls.kdf.value).toBe(KdfType.PBKDF2_SHA256); + const kdfConfigFormGroup = formGroup.controls.kdfConfig; + expect(kdfConfigFormGroup.controls.iterations.value).toBe(600_000); // PBKDF2 default + expect(kdfConfigFormGroup.controls.memory.value).toBeNull(); // PBKDF2 doesn't use memory + expect(kdfConfigFormGroup.controls.parallelism.value).toBeNull(); // PBKDF2 doesn't use parallelism + }); + + it("should update validators when KDF type changes to PBKDF2", () => { + // Arrange + const formGroup = component["formGroup"]; + + // Act - change KDF type back to PBKDF2 + formGroup.controls.kdf.setValue(KdfType.PBKDF2_SHA256); + + // Assert validators update to PBKDF2 validation rules + const kdfConfigFormGroup = formGroup.controls.kdfConfig; + expectPBKDF2Validation( + kdfConfigFormGroup.controls.iterations, + kdfConfigFormGroup.controls.memory, + kdfConfigFormGroup.controls.parallelism, + ); + }); + }); + }); + + describe("openConfirmationModal", () => { + describe("when form is valid", () => { + it("should open confirmation modal with PBKDF2 config when form is submitted", async () => { + // Arrange + const mockPBKDF2Config = new PBKDF2KdfConfig(600_001); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config); + + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + + // Act + await component.openConfirmationModal(); + + // Assert + expect(mockDialogService.open).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + data: expect.objectContaining({ + kdfConfig: mockPBKDF2Config, + }), + }), + ); + }); + + it("should open confirmation modal with Argon2id config when form is submitted", async () => { + // Arrange + const mockArgon2Config = new Argon2KdfConfig(4, 65, 5); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2Config); + + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + + // Act + await component.openConfirmationModal(); + + // Assert + expect(mockDialogService.open).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + data: expect.objectContaining({ + kdfConfig: mockArgon2Config, + }), + }), + ); + }); + + it("should not open modal when form is invalid", async () => { + // Arrange + const mockPBKDF2Config = new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.min - 1); + mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config); + + fixture = TestBed.createComponent(ChangeKdfComponent); + component = fixture.componentInstance; + await component.ngOnInit(); + + // Act + await component.openConfirmationModal(); + + // Assert + expect(mockDialogService.open).not.toHaveBeenCalled(); + }); + }); + }); +}); 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 0463c6d4afc..f128aefdd9b 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 @@ -1,11 +1,11 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, OnDestroy, OnInit } from "@angular/core"; -import { FormBuilder, FormControl, ValidatorFn, Validators } from "@angular/forms"; -import { Subject, firstValueFrom, takeUntil } from "rxjs"; +import { FormBuilder, FormControl, Validators } from "@angular/forms"; +import { Subject, firstValueFrom, takeUntil, Observable } from "rxjs"; 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 { DialogService } from "@bitwarden/components"; import { KdfConfigService, @@ -31,11 +31,11 @@ export class ChangeKdfComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); protected formGroup = this.formBuilder.group({ - kdf: new FormControl(KdfType.PBKDF2_SHA256, [Validators.required]), + kdf: new FormControl(KdfType.PBKDF2_SHA256, [Validators.required]), kdfConfig: this.formBuilder.group({ - iterations: [this.kdfConfig.iterations], - memory: [null as number], - parallelism: [null as number], + iterations: new FormControl(null), + memory: new FormControl(null), + parallelism: new FormControl(null), }), }); @@ -45,95 +45,102 @@ export class ChangeKdfComponent implements OnInit, OnDestroy { protected ARGON2_MEMORY = Argon2KdfConfig.MEMORY; protected ARGON2_PARALLELISM = Argon2KdfConfig.PARALLELISM; + noLogoutOnKdfChangeFeatureFlag$: Observable; + constructor( private dialogService: DialogService, private kdfConfigService: KdfConfigService, private accountService: AccountService, private formBuilder: FormBuilder, + configService: ConfigService, ) { this.kdfOptions = [ { name: "PBKDF2 SHA-256", value: KdfType.PBKDF2_SHA256 }, { name: "Argon2id", value: KdfType.Argon2id }, ]; + this.noLogoutOnKdfChangeFeatureFlag$ = configService.getFeatureFlag$( + FeatureFlag.NoLogoutOnKdfChange, + ); } async ngOnInit() { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId); - this.formGroup.get("kdf").setValue(this.kdfConfig.kdfType); + this.formGroup.controls.kdf.setValue(this.kdfConfig.kdfType); this.setFormControlValues(this.kdfConfig); + this.setFormValidators(this.kdfConfig.kdfType); - this.formGroup - .get("kdf") - .valueChanges.pipe(takeUntil(this.destroy$)) + this.formGroup.controls.kdf.valueChanges + .pipe(takeUntil(this.destroy$)) .subscribe((newValue) => { - this.updateKdfConfig(newValue); + this.updateKdfConfig(newValue!); }); } private updateKdfConfig(newValue: KdfType) { let config: KdfConfig; - const validators: { [key: string]: ValidatorFn[] } = { - iterations: [], - memory: [], - parallelism: [], - }; switch (newValue) { case KdfType.PBKDF2_SHA256: config = new PBKDF2KdfConfig(); - validators.iterations = [ - Validators.required, - Validators.min(PBKDF2KdfConfig.ITERATIONS.min), - Validators.max(PBKDF2KdfConfig.ITERATIONS.max), - ]; break; case KdfType.Argon2id: config = new Argon2KdfConfig(); - validators.iterations = [ - Validators.required, - Validators.min(Argon2KdfConfig.ITERATIONS.min), - Validators.max(Argon2KdfConfig.ITERATIONS.max), - ]; - validators.memory = [ - Validators.required, - Validators.min(Argon2KdfConfig.MEMORY.min), - Validators.max(Argon2KdfConfig.MEMORY.max), - ]; - validators.parallelism = [ - Validators.required, - Validators.min(Argon2KdfConfig.PARALLELISM.min), - Validators.max(Argon2KdfConfig.PARALLELISM.max), - ]; break; default: throw new Error("Unknown KDF type."); } this.kdfConfig = config; - this.setFormValidators(validators); + this.setFormValidators(newValue); this.setFormControlValues(this.kdfConfig); } - private setFormValidators(validators: { [key: string]: ValidatorFn[] }) { - this.setValidators("kdfConfig.iterations", validators.iterations); - this.setValidators("kdfConfig.memory", validators.memory); - this.setValidators("kdfConfig.parallelism", validators.parallelism); - } - private setValidators(controlName: string, validators: ValidatorFn[]) { - const control = this.formGroup.get(controlName); - if (control) { - control.setValidators(validators); - control.updateValueAndValidity(); + private setFormValidators(kdfType: KdfType) { + const kdfConfigFormGroup = this.formGroup.controls.kdfConfig; + switch (kdfType) { + case KdfType.PBKDF2_SHA256: + kdfConfigFormGroup.controls.iterations.setValidators([ + Validators.required, + Validators.min(PBKDF2KdfConfig.ITERATIONS.min), + Validators.max(PBKDF2KdfConfig.ITERATIONS.max), + ]); + kdfConfigFormGroup.controls.memory.setValidators([]); + kdfConfigFormGroup.controls.parallelism.setValidators([]); + break; + case KdfType.Argon2id: + kdfConfigFormGroup.controls.iterations.setValidators([ + Validators.required, + Validators.min(Argon2KdfConfig.ITERATIONS.min), + Validators.max(Argon2KdfConfig.ITERATIONS.max), + ]); + kdfConfigFormGroup.controls.memory.setValidators([ + Validators.required, + Validators.min(Argon2KdfConfig.MEMORY.min), + Validators.max(Argon2KdfConfig.MEMORY.max), + ]); + kdfConfigFormGroup.controls.parallelism.setValidators([ + Validators.required, + Validators.min(Argon2KdfConfig.PARALLELISM.min), + Validators.max(Argon2KdfConfig.PARALLELISM.max), + ]); + break; + default: + throw new Error("Unknown KDF type."); } + kdfConfigFormGroup.controls.iterations.updateValueAndValidity(); + kdfConfigFormGroup.controls.memory.updateValueAndValidity(); + kdfConfigFormGroup.controls.parallelism.updateValueAndValidity(); } + private setFormControlValues(kdfConfig: KdfConfig) { - this.formGroup.get("kdfConfig").reset(); + const kdfConfigFormGroup = this.formGroup.controls.kdfConfig; + kdfConfigFormGroup.reset(); if (kdfConfig.kdfType === KdfType.PBKDF2_SHA256) { - this.formGroup.get("kdfConfig.iterations").setValue(kdfConfig.iterations); + kdfConfigFormGroup.controls.iterations.setValue(kdfConfig.iterations); } else if (kdfConfig.kdfType === KdfType.Argon2id) { - this.formGroup.get("kdfConfig.iterations").setValue(kdfConfig.iterations); - this.formGroup.get("kdfConfig.memory").setValue(kdfConfig.memory); - this.formGroup.get("kdfConfig.parallelism").setValue(kdfConfig.parallelism); + kdfConfigFormGroup.controls.iterations.setValue(kdfConfig.iterations); + kdfConfigFormGroup.controls.memory.setValue(kdfConfig.memory); + kdfConfigFormGroup.controls.parallelism.setValue(kdfConfig.parallelism); } } @@ -155,12 +162,14 @@ export class ChangeKdfComponent implements OnInit, OnDestroy { if (this.formGroup.invalid) { return; } + + const kdfConfigFormGroup = this.formGroup.controls.kdfConfig; if (this.kdfConfig.kdfType === KdfType.PBKDF2_SHA256) { - this.kdfConfig.iterations = this.formGroup.get("kdfConfig.iterations").value; + this.kdfConfig.iterations = kdfConfigFormGroup.controls.iterations.value!; } else if (this.kdfConfig.kdfType === KdfType.Argon2id) { - this.kdfConfig.iterations = this.formGroup.get("kdfConfig.iterations").value; - this.kdfConfig.memory = this.formGroup.get("kdfConfig.memory").value; - this.kdfConfig.parallelism = this.formGroup.get("kdfConfig.parallelism").value; + this.kdfConfig.iterations = kdfConfigFormGroup.controls.iterations.value!; + this.kdfConfig.memory = kdfConfigFormGroup.controls.memory.value!; + this.kdfConfig.parallelism = kdfConfigFormGroup.controls.parallelism.value!; } this.dialogService.open(ChangeKdfConfirmationComponent, { data: { diff --git a/apps/web/src/app/key-management/change-kdf/change-kdf.module.ts b/apps/web/src/app/key-management/change-kdf/change-kdf.module.ts index 342ad43e368..4c9cd00e79d 100644 --- a/apps/web/src/app/key-management/change-kdf/change-kdf.module.ts +++ b/apps/web/src/app/key-management/change-kdf/change-kdf.module.ts @@ -1,13 +1,15 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; +import { PopoverModule } from "@bitwarden/components"; + import { SharedModule } from "../../shared"; import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.component"; import { ChangeKdfComponent } from "./change-kdf.component"; @NgModule({ - imports: [CommonModule, SharedModule], + imports: [CommonModule, SharedModule, PopoverModule], declarations: [ChangeKdfComponent, ChangeKdfConfirmationComponent], exports: [ChangeKdfComponent, ChangeKdfConfirmationComponent], }) diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html index 56332cc424b..aff549a84f4 100644 --- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.html @@ -4,13 +4,14 @@

{{ "openingExtension" | i18n }}

+ @let page = extensionPage$ | async;

{{ "openingExtensionError" | i18n }}

diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index 308cc351dc3..e1264b009b8 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -1,13 +1,15 @@ import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, OnInit, inject } from "@angular/core"; +import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { EMPTY } from "rxjs"; -import { map, switchMap } from "rxjs/operators"; +import { map, tap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; -import { DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; +import { + DrawerType, + RiskInsightsDataService, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -21,18 +23,10 @@ import { } from "@bitwarden/components"; import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; -import { AllActivityComponent } from "./all-activity.component"; -import { AllApplicationsComponent } from "./all-applications.component"; -import { CriticalApplicationsComponent } from "./critical-applications.component"; - -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum RiskInsightsTabType { - AllActivity = 0, - AllApps = 1, - CriticalApps = 2, - NotifiedMembers = 3, -} +import { AllActivityComponent } from "./activity/all-activity.component"; +import { AllApplicationsComponent } from "./all-applications/all-applications.component"; +import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component"; +import { RiskInsightsTabType } from "./models/risk-insights.models"; @Component({ templateUrl: "./risk-insights.component.html", @@ -51,7 +45,7 @@ export enum RiskInsightsTabType { AllActivityComponent, ], }) -export class RiskInsightsComponent implements OnInit { +export class RiskInsightsComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); private _isDrawerOpen: boolean = false; @@ -65,7 +59,6 @@ export class RiskInsightsComponent implements OnInit { private organizationId: OrganizationId = "" as OrganizationId; dataLastUpdated: Date | null = null; - refetching: boolean = false; constructor( private route: ActivatedRoute, @@ -91,11 +84,10 @@ export class RiskInsightsComponent implements OnInit { .pipe( takeUntilDestroyed(this.destroyRef), map((params) => params.get("organizationId")), - switchMap(async (orgId) => { + tap((orgId) => { if (orgId) { // Initialize Data Service - await this.dataService.initializeForOrganization(orgId as OrganizationId); - + this.dataService.initializeForOrganization(orgId as OrganizationId); this.organizationId = orgId as OrganizationId; } else { return EMPTY; @@ -105,7 +97,7 @@ export class RiskInsightsComponent implements OnInit { .subscribe(); // Subscribe to report result details - this.dataService.reportResults$ + this.dataService.enrichedReportData$ .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((report) => { this.appsCount = report?.reportData.length ?? 0; @@ -119,15 +111,16 @@ export class RiskInsightsComponent implements OnInit { this._isDrawerOpen = details.open; }); } - runReport = () => { - this.dataService.triggerReport(); - }; + + ngOnDestroy(): void { + this.dataService.destroy(); + } /** * Refreshes the data by re-fetching the applications report. * This will automatically notify child components subscribed to the RiskInsightsDataService observables. */ - refreshData(): void { + generateReport(): void { if (this.organizationId) { this.dataService.triggerReport(); } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/app-table-row-scrollable.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.ts diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.html rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.html diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights-loading.component.ts rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-loading.component.ts 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 a57bdfc279c..22f8ea55f51 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 @@ -3,7 +3,7 @@ import { mock } from "jest-mock-extended"; import { AllActivitiesService, - LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ApplicationHealthReportDetailEnriched, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -43,7 +43,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: true, atRiskPasswordCount: 1, atRiskCipherIds: ["cid1"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; const spy = jest.spyOn(service, "requestPasswordChange").mockResolvedValue(2); await service.assignTasks(organizationId, apps); @@ -60,12 +60,12 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: true, atRiskPasswordCount: 2, atRiskCipherIds: ["cid1", "cid2"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, { isMarkedAsCritical: true, atRiskPasswordCount: 1, atRiskCipherIds: ["cid2"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; defaultAdminTaskServiceSpy.bulkCreateTasks.mockResolvedValue(undefined); i18nServiceSpy.t.mockImplementation((key) => key); @@ -91,7 +91,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: true, atRiskPasswordCount: 1, atRiskCipherIds: ["cid3"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; defaultAdminTaskServiceSpy.bulkCreateTasks.mockRejectedValue(new Error("fail")); i18nServiceSpy.t.mockImplementation((key) => key); @@ -113,7 +113,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: true, atRiskPasswordCount: 0, atRiskCipherIds: ["cid4"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; const result = await service.requestPasswordChange(organizationId, apps); @@ -128,7 +128,7 @@ describe("AccessIntelligenceSecurityTasksService", () => { isMarkedAsCritical: false, atRiskPasswordCount: 2, atRiskCipherIds: ["cid5", "cid6"], - } as LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + } as ApplicationHealthReportDetailEnriched, ]; const result = await service.requestPasswordChange(organizationId, apps); 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 34fd6daa2b0..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 @@ -2,7 +2,7 @@ import { Injectable } from "@angular/core"; import { AllActivitiesService, - LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher, + ApplicationHealthReportDetailEnriched, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; @@ -20,10 +20,7 @@ export class AccessIntelligenceSecurityTasksService { private toastService: ToastService, private i18nService: I18nService, ) {} - async assignTasks( - organizationId: OrganizationId, - apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[], - ) { + async assignTasks(organizationId: OrganizationId, apps: ApplicationHealthReportDetailEnriched[]) { const taskCount = await this.requestPasswordChange(organizationId, apps); this.allActivitiesService.setTaskCreatedCount(taskCount); } @@ -31,7 +28,7 @@ export class AccessIntelligenceSecurityTasksService { // TODO: this method is shared between here and critical-applications.component.ts async requestPasswordChange( organizationId: OrganizationId, - apps: LEGACY_ApplicationHealthReportDetailWithCriticalFlagAndCipher[], + apps: ApplicationHealthReportDetailEnriched[], ): Promise { // Only create tasks for CRITICAL applications with at-risk passwords const cipherIds = apps 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 1a684d4094b..f0292ef90e7 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 @@ -29,7 +29,7 @@ import { FilterIntegrationsPipe } from "./integrations.pipe"; export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { tabIndex: number = 0; organization$: Observable = new Observable(); - isEventBasedIntegrationsEnabled: boolean = false; + isEventManagementForDataDogAndCrowdStrikeEnabled: boolean = false; private destroy$ = new Subject(); // initialize the integrations list with default integrations @@ -230,24 +230,6 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.hecOrganizationIntegrationService.setOrganizationIntegrations(org.id); this.datadogOrganizationIntegrationService.setOrganizationIntegrations(org.id); }); - - // For all existing event based configurations loop through and assign the - // organizationIntegration for the correct services. - this.hecOrganizationIntegrationService.integrations$ - .pipe(takeUntil(this.destroy$)) - .subscribe((integrations) => { - // reset all integrations to null first - in case one was deleted - this.integrationsList.forEach((i) => { - i.organizationIntegration = null; - }); - - integrations.map((integration) => { - const item = this.integrationsList.find((i) => i.name === integration.serviceType); - if (item) { - item.organizationIntegration = integration; - } - }); - }); } constructor( @@ -259,14 +241,14 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { private datadogOrganizationIntegrationService: DatadogOrganizationIntegrationService, ) { this.configService - .getFeatureFlag$(FeatureFlag.EventBasedOrganizationIntegrations) + .getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike) .pipe(takeUntil(this.destroy$)) .subscribe((isEnabled) => { - this.isEventBasedIntegrationsEnabled = isEnabled; + this.isEventManagementForDataDogAndCrowdStrikeEnabled = isEnabled; }); // Add the new event based items to the list - if (this.isEventBasedIntegrationsEnabled) { + if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) { const crowdstrikeIntegration: Integration = { name: OrganizationIntegrationServiceType.CrowdStrike, linkURL: "https://bitwarden.com/help/crowdstrike-siem/", diff --git a/libs/angular/src/auth/components/authentication-timeout.component.ts b/libs/angular/src/auth/components/authentication-timeout.component.ts index 940798de9e7..ed1ff9d29fa 100644 --- a/libs/angular/src/auth/components/authentication-timeout.component.ts +++ b/libs/angular/src/auth/components/authentication-timeout.component.ts @@ -9,6 +9,8 @@ import { ButtonModule } from "@bitwarden/components"; * This component is used to display a message to the user that their authentication session has expired. * It provides a button to navigate to the login page. */ +// 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-authentication-timeout", imports: [CommonModule, JslibModule, ButtonModule, RouterModule], diff --git a/libs/angular/src/auth/components/two-factor-icon.component.ts b/libs/angular/src/auth/components/two-factor-icon.component.ts index c9c7e43b61f..85db7975f87 100644 --- a/libs/angular/src/auth/components/two-factor-icon.component.ts +++ b/libs/angular/src/auth/components/two-factor-icon.component.ts @@ -9,13 +9,19 @@ import { TwoFactorAuthWebAuthnIcon, } from "@bitwarden/assets/svg"; +// 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: "auth-two-factor-icon", templateUrl: "./two-factor-icon.component.html", standalone: false, }) export class TwoFactorIconComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() provider: any; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() name: string; protected readonly IconProviderMap: { [key: number | string]: Icon } = { diff --git a/libs/angular/src/auth/components/user-verification.component.ts b/libs/angular/src/auth/components/user-verification.component.ts index 6f5021340c7..1f0659a92ff 100644 --- a/libs/angular/src/auth/components/user-verification.component.ts +++ b/libs/angular/src/auth/components/user-verification.component.ts @@ -26,6 +26,8 @@ import { KeyService } from "@bitwarden/key-management"; }) export class UserVerificationComponent implements ControlValueAccessor, OnInit, OnDestroy { private _invalidSecret = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get invalidSecret() { return this._invalidSecret; @@ -43,6 +45,8 @@ export class UserVerificationComponent implements ControlValueAccessor, OnInit, } this.secret.updateValueAndValidity({ emitEvent: false }); } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() invalidSecretChange = new EventEmitter(); hasMasterPassword = true; diff --git a/libs/angular/src/auth/device-management/device-management-item-group.component.ts b/libs/angular/src/auth/device-management/device-management-item-group.component.ts index 71e343e734f..71fe80928f9 100644 --- a/libs/angular/src/auth/device-management/device-management-item-group.component.ts +++ b/libs/angular/src/auth/device-management/device-management-item-group.component.ts @@ -8,6 +8,8 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { DeviceDisplayData } from "./device-management.component"; /** Displays user devices in an item list view */ +// 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, selector: "auth-device-management-item-group", @@ -15,7 +17,11 @@ import { DeviceDisplayData } from "./device-management.component"; imports: [BadgeModule, CommonModule, ItemModule, I18nPipe], }) export class DeviceManagementItemGroupComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() devices: DeviceDisplayData[] = []; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAuthRequestAnswered = new EventEmitter(); protected answerAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) { diff --git a/libs/angular/src/auth/device-management/device-management-table.component.ts b/libs/angular/src/auth/device-management/device-management-table.component.ts index d663e28b9e4..36edf6dd336 100644 --- a/libs/angular/src/auth/device-management/device-management-table.component.ts +++ b/libs/angular/src/auth/device-management/device-management-table.component.ts @@ -15,6 +15,8 @@ import { import { DeviceDisplayData } from "./device-management.component"; /** Displays user devices in a sortable table view */ +// 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, selector: "auth-device-management-table", @@ -22,7 +24,11 @@ import { DeviceDisplayData } from "./device-management.component"; imports: [BadgeModule, ButtonModule, CommonModule, JslibModule, LinkModule, TableModule], }) export class DeviceManagementTableComponent implements OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() devices: DeviceDisplayData[] = []; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onAuthRequestAnswered = new EventEmitter(); protected tableDataSource = new TableDataSource(); diff --git a/libs/angular/src/auth/device-management/device-management.component.ts b/libs/angular/src/auth/device-management/device-management.component.ts index 2c67812b586..d8f8cc10df4 100644 --- a/libs/angular/src/auth/device-management/device-management.component.ts +++ b/libs/angular/src/auth/device-management/device-management.component.ts @@ -50,6 +50,8 @@ export interface DeviceDisplayData { * - Medium to Large screens = `bit-table` view * - Small screens = `bit-item-group` view */ +// 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, selector: "auth-device-management", diff --git a/libs/angular/src/auth/environment-selector/environment-selector.component.ts b/libs/angular/src/auth/environment-selector/environment-selector.component.ts index 6fe3eaa92a0..89366f47b70 100644 --- a/libs/angular/src/auth/environment-selector/environment-selector.component.ts +++ b/libs/angular/src/auth/environment-selector/environment-selector.component.ts @@ -20,6 +20,8 @@ import { } 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: "environment-selector", templateUrl: "environment-selector.component.html", diff --git a/libs/angular/src/auth/guards/active-auth.guard.spec.ts b/libs/angular/src/auth/guards/active-auth.guard.spec.ts index de1bf40be11..d4d27626c11 100644 --- a/libs/angular/src/auth/guards/active-auth.guard.spec.ts +++ b/libs/angular/src/auth/guards/active-auth.guard.spec.ts @@ -13,6 +13,8 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { activeAuthGuard } from "./active-auth.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false }) class EmptyComponent {} diff --git a/libs/angular/src/auth/login-approval/login-approval-dialog.component.ts b/libs/angular/src/auth/login-approval/login-approval-dialog.component.ts index 19dc3f519c6..35333c43536 100644 --- a/libs/angular/src/auth/login-approval/login-approval-dialog.component.ts +++ b/libs/angular/src/auth/login-approval/login-approval-dialog.component.ts @@ -33,6 +33,8 @@ export interface LoginApprovalDialogParams { notificationId: 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: "login-approval-dialog.component.html", imports: [AsyncActionsModule, ButtonModule, CommonModule, DialogModule, JslibModule], diff --git a/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts b/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts index 7ffeceb8415..764d9fe7733 100644 --- a/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts +++ b/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts @@ -31,6 +31,8 @@ import { import { KeyService } from "@bitwarden/key-management"; export type State = "assert" | "assertFailed"; +// 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-login-via-webauthn", templateUrl: "login-via-webauthn.component.html", diff --git a/libs/angular/src/auth/password-management/change-password/change-password.component.ts b/libs/angular/src/auth/password-management/change-password/change-password.component.ts index 1512f348133..7f46ebfc9d4 100644 --- a/libs/angular/src/auth/password-management/change-password/change-password.component.ts +++ b/libs/angular/src/auth/password-management/change-password/change-password.component.ts @@ -39,12 +39,16 @@ import { ChangePasswordService } from "./change-password.service.abstraction"; * and by design to maintain a strong security posture as some flows could have the user * end up at a change password without having one before. */ +// 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: "auth-change-password", templateUrl: "change-password.component.html", imports: [InputPasswordComponent, I18nPipe, CalloutComponent, CommonModule], }) export class ChangePasswordComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() inputPasswordFlow: InputPasswordFlow = InputPasswordFlow.ChangePassword; activeAccount: Account | null = null; diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts index ff78952c562..805fe3c0173 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts @@ -45,6 +45,8 @@ import { SetInitialPasswordUserType, } from "./set-initial-password.service.abstraction"; +// 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, templateUrl: "set-initial-password.component.html", diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c6b155fc317..fe19b8a9220 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -869,6 +869,7 @@ const safeProviders: SafeProvider[] = [ AuthServiceAbstraction, StateProvider, SecurityStateService, + KdfConfigService, ], }), safeProvider({ diff --git a/libs/assets/src/svg/svgs/auto-confirmation.ts b/libs/assets/src/svg/svgs/auto-confirmation.ts new file mode 100644 index 00000000000..2a1416a5d25 --- /dev/null +++ b/libs/assets/src/svg/svgs/auto-confirmation.ts @@ -0,0 +1,5 @@ +import { svgIcon } from "../icon-service"; + +export const AutoConfirmSvg = svgIcon` + +`; diff --git a/libs/assets/src/svg/svgs/index.ts b/libs/assets/src/svg/svgs/index.ts index 3c6fe4b8306..1e3000c9b4c 100644 --- a/libs/assets/src/svg/svgs/index.ts +++ b/libs/assets/src/svg/svgs/index.ts @@ -1,6 +1,7 @@ export * from "./account-warning.icon"; export * from "./active-send.icon"; export { default as AdminConsoleLogo } from "./admin-console"; +export * from "./auto-confirmation"; export * from "./background-left-illustration"; export * from "./background-right-illustration"; export * from "./bitwarden-icon"; diff --git a/libs/auth/src/angular/fingerprint-dialog/fingerprint-dialog.component.ts b/libs/auth/src/angular/fingerprint-dialog/fingerprint-dialog.component.ts index 1769a57319c..6ef36a32448 100644 --- a/libs/auth/src/angular/fingerprint-dialog/fingerprint-dialog.component.ts +++ b/libs/auth/src/angular/fingerprint-dialog/fingerprint-dialog.component.ts @@ -9,6 +9,8 @@ export type FingerprintDialogData = { fingerprint: 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: "fingerprint-dialog.component.html", imports: [JslibModule, ButtonModule, DialogModule], diff --git a/libs/auth/src/angular/input-password/input-password.component.ts b/libs/auth/src/angular/input-password/input-password.component.ts index dda471c7129..019a9e3975e 100644 --- a/libs/auth/src/angular/input-password/input-password.component.ts +++ b/libs/auth/src/angular/input-password/input-password.component.ts @@ -99,6 +99,8 @@ interface InputPasswordForm { rotateUserKey?: FormControl; } +// 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: "auth-input-password", templateUrl: "./input-password.component.html", @@ -118,24 +120,48 @@ interface InputPasswordForm { ], }) export class InputPasswordComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(PasswordStrengthV2Component) passwordStrengthComponent: | PasswordStrengthV2Component | undefined = undefined; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onPasswordFormSubmit = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSecondaryButtonClick = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() isSubmitting = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) flow!: InputPasswordFlow; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: (val: string) => val?.trim().toLowerCase() }) email?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() userId?: UserId; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() loading = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() masterPasswordPolicyOptions?: MasterPasswordPolicyOptions; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() inlineButtons = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() primaryButtonText?: Translation; protected primaryButtonTextStr: string = ""; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() secondaryButtonText?: Translation; protected secondaryButtonTextStr: string = ""; diff --git a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts index a2018817fed..26293285008 100644 --- a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts +++ b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts @@ -50,6 +50,8 @@ enum State { ExistingUserUntrustedDevice, } +// 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: "./login-decryption-options.component.html", imports: [ diff --git a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts index 93d02228a51..040d4d3c121 100644 --- a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts +++ b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts @@ -56,6 +56,8 @@ const matchOptions: IsActiveMatchOptions = { matrixParams: "ignored", }; +// 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: "./login-via-auth-request.component.html", imports: [ButtonModule, CommonModule, JslibModule, LinkModule, RouterModule], diff --git a/libs/auth/src/angular/login/login-secondary-content.component.ts b/libs/auth/src/angular/login/login-secondary-content.component.ts index 9cd4cfd2502..29474d5c8c6 100644 --- a/libs/auth/src/angular/login/login-secondary-content.component.ts +++ b/libs/auth/src/angular/login/login-secondary-content.component.ts @@ -8,6 +8,8 @@ import { DefaultServerSettingsService } from "@bitwarden/common/platform/service // eslint-disable-next-line no-restricted-imports import { LinkModule } 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, JslibModule, LinkModule, RouterModule], template: ` diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index 0a4569ea9e7..799b654d5b6 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -67,6 +67,8 @@ export enum LoginUiState { MASTER_PASSWORD_ENTRY = "MasterPasswordEntry", } +// 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: "./login.component.html", imports: [ @@ -83,6 +85,8 @@ export enum LoginUiState { ], }) export class LoginComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("masterPasswordInputRef") masterPasswordInputRef: ElementRef | undefined; private destroy$ = new Subject(); diff --git a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts index 830e4107b31..7d6d75c8a53 100644 --- a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts +++ b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts @@ -30,6 +30,8 @@ import { NewDeviceVerificationComponentService } from "./new-device-verification /** * Component for verifying a new device via a one-time password (OTP). */ +// 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-new-device-verification", templateUrl: "./new-device-verification.component.html", diff --git a/libs/auth/src/angular/password-callout/password-callout.component.ts b/libs/auth/src/angular/password-callout/password-callout.component.ts index 7a28700f109..2d97c33c50b 100644 --- a/libs/auth/src/angular/password-callout/password-callout.component.ts +++ b/libs/auth/src/angular/password-callout/password-callout.component.ts @@ -10,13 +10,19 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic // eslint-disable-next-line no-restricted-imports import { CalloutModule } 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: "auth-password-callout", templateUrl: "password-callout.component.html", imports: [CommonModule, JslibModule, CalloutModule], }) export class PasswordCalloutComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() message = "masterPasswordPolicyInEffect"; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() policy: MasterPasswordPolicyOptions; constructor(private i18nService: I18nService) {} diff --git a/libs/auth/src/angular/password-hint/password-hint.component.ts b/libs/auth/src/angular/password-hint/password-hint.component.ts index 3189bf8f187..50d53e00ad3 100644 --- a/libs/auth/src/angular/password-hint/password-hint.component.ts +++ b/libs/auth/src/angular/password-hint/password-hint.component.ts @@ -22,6 +22,8 @@ import { 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({ templateUrl: "./password-hint.component.html", imports: [ diff --git a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts index 93ddd00fdd6..d2dbd72989d 100644 --- a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts +++ b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts @@ -25,12 +25,16 @@ import { SelfHostedEnvConfigDialogComponent } from "../../self-hosted-env-config * Component for selecting the environment to register with in the email verification registration flow. * Outputs the selected region to the parent component so it can respond as necessary. */ +// 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: "auth-registration-env-selector", templateUrl: "registration-env-selector.component.html", imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule], }) export class RegistrationEnvSelectorComponent 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() selectedRegionChange = new EventEmitter(); ServerEnvironmentType = Region; diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts index 7cd03fa87ff..181b158a362 100644 --- a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts @@ -31,6 +31,8 @@ import { PasswordInputResult } from "../../input-password/password-input-result" import { RegistrationFinishService } from "./registration-finish.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: "auth-registration-finish", templateUrl: "./registration-finish.component.html", diff --git a/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts b/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts index 9e75a8b888c..e7a3e99759c 100644 --- a/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts +++ b/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts @@ -19,6 +19,8 @@ export interface RegistrationLinkExpiredComponentData { loginRoute: 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: "auth-registration-link-expired", templateUrl: "./registration-link-expired.component.html", diff --git a/libs/auth/src/angular/registration/registration-start/registration-start-secondary.component.ts b/libs/auth/src/angular/registration/registration-start/registration-start-secondary.component.ts index f30dc8a3822..31e9cbc6316 100644 --- a/libs/auth/src/angular/registration/registration-start/registration-start-secondary.component.ts +++ b/libs/auth/src/angular/registration/registration-start/registration-start-secondary.component.ts @@ -18,6 +18,8 @@ export interface RegistrationStartSecondaryComponentData { loginRoute: 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: "auth-registration-start-secondary", templateUrl: "./registration-start-secondary.component.html", diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.component.ts b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts index 16018cecfa7..714f6d49342 100644 --- a/libs/auth/src/angular/registration/registration-start/registration-start.component.ts +++ b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts @@ -40,6 +40,8 @@ const DEFAULT_MARKETING_EMAILS_PREF_BY_REGION: Record = { [Region.SelfHosted]: false, }; +// 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: "auth-registration-start", templateUrl: "./registration-start.component.html", @@ -57,6 +59,8 @@ const DEFAULT_MARKETING_EMAILS_PREF_BY_REGION: Record = { ], }) export class RegistrationStartComponent 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() registrationStartStateChange = new EventEmitter(); state: RegistrationStartState = RegistrationStartState.USER_DATA_ENTRY; diff --git a/libs/auth/src/angular/self-hosted-env-config-dialog/self-hosted-env-config-dialog.component.ts b/libs/auth/src/angular/self-hosted-env-config-dialog/self-hosted-env-config-dialog.component.ts index 16c25f2404f..40fdfb8c17c 100644 --- a/libs/auth/src/angular/self-hosted-env-config-dialog/self-hosted-env-config-dialog.component.ts +++ b/libs/auth/src/angular/self-hosted-env-config-dialog/self-hosted-env-config-dialog.component.ts @@ -54,6 +54,8 @@ function selfHostedEnvSettingsFormValidator(): ValidatorFn { /** * Dialog for configuring self-hosted environment settings. */ +// 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: "self-hosted-env-config-dialog", templateUrl: "self-hosted-env-config-dialog.component.html", diff --git a/libs/auth/src/angular/sso/sso.component.ts b/libs/auth/src/angular/sso/sso.component.ts index 11fcdfdcd59..3c9669a1712 100644 --- a/libs/auth/src/angular/sso/sso.component.ts +++ b/libs/auth/src/angular/sso/sso.component.ts @@ -62,6 +62,8 @@ interface QueryParams { /** * This component handles the SSO flow. */ +// 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: "sso.component.html", imports: [ diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator/two-factor-auth-authenticator.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator/two-factor-auth-authenticator.component.ts index c53bffe2496..2bc5d1a0381 100644 --- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator/two-factor-auth-authenticator.component.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-authenticator/two-factor-auth-authenticator.component.ts @@ -14,6 +14,8 @@ import { AsyncActionsModule, } 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-two-factor-auth-authenticator", templateUrl: "two-factor-auth-authenticator.component.html", @@ -32,7 +34,11 @@ import { providers: [], }) export class TwoFactorAuthAuthenticatorComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) tokenFormControl: FormControl | undefined = undefined; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() tokenChange = new EventEmitter<{ token: string }>(); onTokenChange(event: Event) { diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/two-factor-auth-duo.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/two-factor-auth-duo.component.ts index 5ad70d3792d..0e089c674be 100644 --- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/two-factor-auth-duo.component.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-duo/two-factor-auth-duo.component.ts @@ -25,6 +25,8 @@ import { TwoFactorAuthDuoComponentService, } from "./two-factor-auth-duo-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: "app-two-factor-auth-duo", template: "", @@ -43,7 +45,11 @@ import { providers: [], }) export class TwoFactorAuthDuoComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() tokenEmitter = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() providerData: any; duoFramelessUrl: string | undefined = undefined; diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email-component-cache.service.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email-component-cache.service.ts index d98387e1cf5..79a7e1f9ddc 100644 --- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email-component-cache.service.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email-component-cache.service.ts @@ -36,7 +36,7 @@ export class TwoFactorAuthEmailComponentCacheService { /** * Signal for the cached email state. */ - private emailCache: WritableSignal = + private readonly emailCache: WritableSignal = this.viewCacheService.signal({ key: TWO_FACTOR_AUTH_EMAIL_COMPONENT_CACHE_KEY, initialValue: null, diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts index 084e8e6e851..000d391b62c 100644 --- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-email/two-factor-auth-email.component.ts @@ -26,6 +26,8 @@ import { import { TwoFactorAuthEmailComponentCacheService } from "./two-factor-auth-email-component-cache.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-two-factor-auth-email", templateUrl: "two-factor-auth-email.component.html", @@ -49,7 +51,11 @@ import { TwoFactorAuthEmailComponentCacheService } from "./two-factor-auth-email ], }) export class TwoFactorAuthEmailComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) tokenFormControl: FormControl | undefined = undefined; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() tokenChange = new EventEmitter<{ token: string }>(); twoFactorEmail: string | undefined = undefined; diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component.ts index 710d5dc4de0..71a91ec20e7 100644 --- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component.ts @@ -32,6 +32,8 @@ export interface WebAuthnResult { remember?: boolean; } +// 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-two-factor-auth-webauthn", templateUrl: "two-factor-auth-webauthn.component.html", @@ -50,7 +52,11 @@ export interface WebAuthnResult { providers: [], }) export class TwoFactorAuthWebAuthnComponent 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() webAuthnResultEmitter = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() webAuthnInNewTabEmitter = new EventEmitter(); webAuthnReady = false; diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-yubikey/two-factor-auth-yubikey.component.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-yubikey/two-factor-auth-yubikey.component.ts index 7218bee056c..40bd3fb551d 100644 --- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-yubikey/two-factor-auth-yubikey.component.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-yubikey/two-factor-auth-yubikey.component.ts @@ -14,6 +14,8 @@ import { AsyncActionsModule, } 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-two-factor-auth-yubikey", templateUrl: "two-factor-auth-yubikey.component.html", @@ -32,5 +34,7 @@ import { providers: [], }) export class TwoFactorAuthYubikeyComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ required: true }) tokenFormControl: FormControl | undefined = undefined; } diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth-component-cache.service.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth-component-cache.service.ts index 33aa76680e4..89fb3d34e96 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth-component-cache.service.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth-component-cache.service.ts @@ -42,7 +42,7 @@ export class TwoFactorAuthComponentCacheService { /** * Signal for the cached TwoFactorAuthData. */ - private twoFactorAuthComponentCache: WritableSignal = + private readonly twoFactorAuthComponentCache: WritableSignal = this.viewCacheService.signal({ key: TWO_FACTOR_AUTH_COMPONENT_CACHE_KEY, initialValue: null, diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts index 9418030d7a1..5d36fd384ca 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts @@ -46,6 +46,8 @@ import { TwoFactorAuthComponentCacheService } from "./two-factor-auth-component- import { TwoFactorAuthComponentService } from "./two-factor-auth-component.service"; import { TwoFactorAuthComponent } from "./two-factor-auth.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({ standalone: false }) class TestTwoFactorComponent extends TwoFactorAuthComponent {} diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts index e6c07947e8c..1de7b97c900 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts @@ -75,6 +75,8 @@ import { TwoFactorOptionsDialogResult, } from "./two-factor-options.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-two-factor-auth", templateUrl: "two-factor-auth.component.html", @@ -99,6 +101,8 @@ import { ], }) export class TwoFactorAuthComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("continueButton", { read: ElementRef, static: false }) continueButton: | ElementRef | undefined = undefined; @@ -114,6 +118,8 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { twoFactorProviders: Map | null = null; selectedProviderData: { [key: string]: string } | undefined; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("duoComponent") duoComponent!: TwoFactorAuthDuoComponent; form = this.formBuilder.group({ diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.guard.spec.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.guard.spec.ts index 06b998c5725..116da73173f 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.guard.spec.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.guard.spec.ts @@ -11,6 +11,8 @@ import { LoginStrategyServiceAbstraction } from "../../common"; import { TwoFactorAuthGuard } from "./two-factor-auth.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: true }) export class EmptyComponent {} diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts index 7e7e02e5e5a..d0ad9be6103 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts @@ -30,6 +30,8 @@ export type TwoFactorOptionsDialogResult = { type: TwoFactorProviderType; }; +// 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-two-factor-options", templateUrl: "two-factor-options.component.html", diff --git a/libs/auth/src/angular/user-verification/user-verification-dialog.component.ts b/libs/auth/src/angular/user-verification/user-verification-dialog.component.ts index 4dfb7a6a995..09d428d4ba7 100644 --- a/libs/auth/src/angular/user-verification/user-verification-dialog.component.ts +++ b/libs/auth/src/angular/user-verification/user-verification-dialog.component.ts @@ -30,6 +30,8 @@ import { } from "./user-verification-dialog.types"; import { UserVerificationFormInputComponent } from "./user-verification-form-input.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: "user-verification-dialog.component.html", imports: [ diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts index e1b8207d970..296359c92ff 100644 --- a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts @@ -40,6 +40,8 @@ import { ActiveClientVerificationOption } from "./active-client-verification-opt * This is exposed to the parent component via the ControlValueAccessor interface (e.g. bind it to a FormControl). * Use UserVerificationService to verify the user's input. */ +// 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-user-verification-form-input", templateUrl: "user-verification-form-input.component.html", @@ -69,8 +71,12 @@ import { ActiveClientVerificationOption } from "./active-client-verification-opt ], }) export class UserVerificationFormInputComponent implements ControlValueAccessor, OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() verificationType: "server" | "client" = "server"; // server represents original behavior private _invalidSecret = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get invalidSecret() { return this._invalidSecret; @@ -88,11 +94,17 @@ export class UserVerificationFormInputComponent implements ControlValueAccessor, } this.secret.updateValueAndValidity({ emitEvent: false }); } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() invalidSecretChange = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() activeClientVerificationOptionChange = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() biometricsVerificationResultChange = new EventEmitter(); readonly Icons = { UserVerificationBiometricsIcon }; diff --git a/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts b/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts index b5d87c60882..2335de34c21 100644 --- a/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts +++ b/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts @@ -44,6 +44,8 @@ type VaultTimeoutForm = FormGroup<{ type VaultTimeoutFormValue = VaultTimeoutForm["value"]; +// 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: "auth-vault-timeout-input", templateUrl: "vault-timeout-input.component.html", @@ -110,6 +112,8 @@ export class VaultTimeoutInputComponent }), }); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() vaultTimeoutOptions: VaultTimeoutOption[]; vaultTimeoutPolicy: Policy; diff --git a/libs/auth/src/common/services/auth-request/default-login-via-auth-request-cache.service.ts b/libs/auth/src/common/services/auth-request/default-login-via-auth-request-cache.service.ts index 80dbafd3159..8b947c41c46 100644 --- a/libs/auth/src/common/services/auth-request/default-login-via-auth-request-cache.service.ts +++ b/libs/auth/src/common/services/auth-request/default-login-via-auth-request-cache.service.ts @@ -16,7 +16,7 @@ const LOGIN_VIA_AUTH_CACHE_KEY = "login-via-auth-request-form-cache"; export class LoginViaAuthRequestCacheService { private viewCacheService: ViewCacheService = inject(ViewCacheService); - private defaultLoginViaAuthRequestCache: WritableSignal = + private readonly defaultLoginViaAuthRequestCache: WritableSignal = this.viewCacheService.signal({ key: LOGIN_VIA_AUTH_CACHE_KEY, initialValue: null, diff --git a/libs/common/src/admin-console/enums/policy-type.enum.ts b/libs/common/src/admin-console/enums/policy-type.enum.ts index 7c06bc41a66..ae0070dda89 100644 --- a/libs/common/src/admin-console/enums/policy-type.enum.ts +++ b/libs/common/src/admin-console/enums/policy-type.enum.ts @@ -19,4 +19,5 @@ export enum PolicyType { RestrictedItemTypes = 15, // Restricts item types that can be created within an organization UriMatchDefaults = 16, // Sets the default URI matching strategy for all users within an organization AutotypeDefaultSetting = 17, // Sets the default autotype setting for desktop app + AutoConfirm = 18, // Enables the auto confirmation feature for admins to enable in their client } diff --git a/libs/common/src/admin-console/models/data/organization.data.spec.ts b/libs/common/src/admin-console/models/data/organization.data.spec.ts index 346fc3db4bb..bc74dd189ba 100644 --- a/libs/common/src/admin-console/models/data/organization.data.spec.ts +++ b/libs/common/src/admin-console/models/data/organization.data.spec.ts @@ -30,6 +30,7 @@ describe("ORGANIZATIONS state", () => { useSecretsManager: false, usePasswordManager: false, useActivateAutofillPolicy: false, + useAutomaticUserConfirmation: false, selfHost: false, usersGetPremium: false, seats: 0, diff --git a/libs/common/src/admin-console/models/data/organization.data.ts b/libs/common/src/admin-console/models/data/organization.data.ts index 95b4b294445..e6bf9dbaaa3 100644 --- a/libs/common/src/admin-console/models/data/organization.data.ts +++ b/libs/common/src/admin-console/models/data/organization.data.ts @@ -30,6 +30,7 @@ export class OrganizationData { useSecretsManager: boolean; usePasswordManager: boolean; useActivateAutofillPolicy: boolean; + useAutomaticUserConfirmation: boolean; selfHost: boolean; usersGetPremium: boolean; seats: number; @@ -99,6 +100,7 @@ export class OrganizationData { this.useSecretsManager = response.useSecretsManager; this.usePasswordManager = response.usePasswordManager; this.useActivateAutofillPolicy = response.useActivateAutofillPolicy; + this.useAutomaticUserConfirmation = response.useAutomaticUserConfirmation; this.selfHost = response.selfHost; this.usersGetPremium = response.usersGetPremium; this.seats = response.seats; diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index 1372150bd81..aea796dfc39 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -38,6 +38,7 @@ export class Organization { useSecretsManager: boolean; usePasswordManager: boolean; useActivateAutofillPolicy: boolean; + useAutomaticUserConfirmation: boolean; selfHost: boolean; usersGetPremium: boolean; seats: number; @@ -124,6 +125,7 @@ export class Organization { this.useSecretsManager = obj.useSecretsManager; this.usePasswordManager = obj.usePasswordManager; this.useActivateAutofillPolicy = obj.useActivateAutofillPolicy; + this.useAutomaticUserConfirmation = obj.useAutomaticUserConfirmation; this.selfHost = obj.selfHost; this.usersGetPremium = obj.usersGetPremium; this.seats = obj.seats; diff --git a/libs/common/src/admin-console/models/response/profile-organization.response.ts b/libs/common/src/admin-console/models/response/profile-organization.response.ts index 53c3858ed1d..dc5090f2c05 100644 --- a/libs/common/src/admin-console/models/response/profile-organization.response.ts +++ b/libs/common/src/admin-console/models/response/profile-organization.response.ts @@ -23,6 +23,7 @@ export class ProfileOrganizationResponse extends BaseResponse { useSecretsManager: boolean; usePasswordManager: boolean; useActivateAutofillPolicy: boolean; + useAutomaticUserConfirmation: boolean; selfHost: boolean; usersGetPremium: boolean; seats: number; @@ -82,6 +83,7 @@ export class ProfileOrganizationResponse extends BaseResponse { this.useSecretsManager = this.getResponseProperty("UseSecretsManager"); this.usePasswordManager = this.getResponseProperty("UsePasswordManager"); this.useActivateAutofillPolicy = this.getResponseProperty("UseActivateAutofillPolicy"); + this.useAutomaticUserConfirmation = this.getResponseProperty("UseAutomaticUserConfirmation"); this.selfHost = this.getResponseProperty("SelfHost"); this.usersGetPremium = this.getResponseProperty("UsersGetPremium"); this.seats = this.getResponseProperty("Seats"); diff --git a/libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts b/libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts new file mode 100644 index 00000000000..b97f980b644 --- /dev/null +++ b/libs/common/src/admin-console/services/auto-confirm/auto-confirm.state.ts @@ -0,0 +1,21 @@ +import { AUTO_CONFIRM, UserKeyDefinition } from "../../../platform/state"; + +export class AutoConfirmState { + enabled: boolean; + showSetupDialog: boolean; + showBrowserNotification: boolean | undefined; + + constructor() { + this.enabled = false; + this.showSetupDialog = true; + } +} + +export const AUTO_CONFIRM_STATE = UserKeyDefinition.record( + AUTO_CONFIRM, + "autoConfirm", + { + deserializer: (autoConfirmState) => autoConfirmState, + clearOn: ["logout"], + }, +); diff --git a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts index d581fdaa95c..ef01c98ecb5 100644 --- a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts @@ -1,3 +1,5 @@ +import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; + import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response"; @@ -25,6 +27,8 @@ export abstract class BillingApiServiceAbstraction { abstract getPlans(): Promise>; + abstract getPremiumPlan(): Promise; + abstract getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise; abstract getProviderInvoices(providerId: string): Promise; diff --git a/libs/common/src/billing/models/response/premium-plan.response.ts b/libs/common/src/billing/models/response/premium-plan.response.ts new file mode 100644 index 00000000000..f5df560a601 --- /dev/null +++ b/libs/common/src/billing/models/response/premium-plan.response.ts @@ -0,0 +1,47 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class PremiumPlanResponse extends BaseResponse { + seat: { + stripePriceId: string; + price: number; + }; + storage: { + stripePriceId: string; + price: number; + }; + + constructor(response: any) { + super(response); + + const seat = this.getResponseProperty("Seat"); + if (!seat || typeof seat !== "object") { + throw new Error("PremiumPlanResponse: Missing or invalid 'Seat' property"); + } + this.seat = new PurchasableResponse(seat); + + const storage = this.getResponseProperty("Storage"); + if (!storage || typeof storage !== "object") { + throw new Error("PremiumPlanResponse: Missing or invalid 'Storage' property"); + } + this.storage = new PurchasableResponse(storage); + } +} + +class PurchasableResponse extends BaseResponse { + stripePriceId: string; + price: number; + + constructor(response: any) { + super(response); + + this.stripePriceId = this.getResponseProperty("StripePriceId"); + if (!this.stripePriceId || typeof this.stripePriceId !== "string") { + throw new Error("PurchasableResponse: Missing or invalid 'StripePriceId' property"); + } + + this.price = this.getResponseProperty("Price"); + if (typeof this.price !== "number" || isNaN(this.price)) { + throw new Error("PurchasableResponse: Missing or invalid 'Price' property"); + } + } +} diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index 165ebf5c3b4..673d4a9784e 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -1,6 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; + import { ApiService } from "../../abstractions/api.service"; import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { ListResponse } from "../../models/response/list.response"; @@ -61,10 +63,15 @@ export class BillingApiService implements BillingApiServiceAbstraction { } async getPlans(): Promise> { - const r = await this.apiService.send("GET", "/plans", null, false, true); + const r = await this.apiService.send("GET", "/plans", null, true, true); return new ListResponse(r, PlanResponse); } + async getPremiumPlan(): Promise { + const response = await this.apiService.send("GET", "/plans/premium", null, true, true); + return new PremiumPlanResponse(response); + } + async getProviderClientInvoiceReport(providerId: string, invoiceId: string): Promise { const response = await this.apiService.send( "GET", diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 6a561d29a0f..d9cd1dbfab3 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -12,6 +12,7 @@ import { ServerConfig } from "../platform/abstractions/config/server-config"; export enum FeatureFlag { /* Admin Console Team */ CreateDefaultLocation = "pm-19467-create-default-location", + AutoConfirm = "pm-19934-auto-confirm-organization-users", /* Auth */ PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods", @@ -29,6 +30,7 @@ export enum FeatureFlag { PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page", + PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -45,7 +47,7 @@ export enum FeatureFlag { ChromiumImporterWithABE = "pm-25855-chromium-importer-abe", /* DIRT */ - EventBasedOrganizationIntegrations = "event-based-organization-integrations", + EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike", PhishingDetection = "phishing-detection", PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab", @@ -80,6 +82,7 @@ const FALSE = false as boolean; export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.CreateDefaultLocation]: FALSE, + [FeatureFlag.AutoConfirm]: FALSE, /* Autofill */ [FeatureFlag.MacOsNativeCredentialSync]: FALSE, @@ -91,7 +94,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.ChromiumImporterWithABE]: FALSE, /* DIRT */ - [FeatureFlag.EventBasedOrganizationIntegrations]: FALSE, + [FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE, [FeatureFlag.PhishingDetection]: FALSE, [FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE, @@ -113,6 +116,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, [FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE, + [FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index f60b42ce450..621033ced65 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -15,7 +15,7 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-st import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; // 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 { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; +import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; import { Matrix } from "../../../spec/matrix"; import { ApiService } from "../../abstractions/api.service"; @@ -75,6 +75,7 @@ describe("DefaultSyncService", () => { let authService: MockProxy; let stateProvider: MockProxy; let securityStateService: MockProxy; + let kdfConfigService: MockProxy; let sut: DefaultSyncService; @@ -105,6 +106,7 @@ describe("DefaultSyncService", () => { authService = mock(); stateProvider = mock(); securityStateService = mock(); + kdfConfigService = mock(); sut = new DefaultSyncService( masterPasswordAbstraction, @@ -132,6 +134,7 @@ describe("DefaultSyncService", () => { authService, stateProvider, securityStateService, + kdfConfigService, ); }); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index d5fa2d0ae68..e599fbc1c48 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -12,7 +12,7 @@ import { // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; // eslint-disable-next-line no-restricted-imports -import { KeyService } from "@bitwarden/key-management"; +import { KdfConfigService, KeyService } from "@bitwarden/key-management"; // FIXME: remove `src` and fix import // eslint-disable-next-line no-restricted-imports @@ -100,6 +100,7 @@ export class DefaultSyncService extends CoreSyncService { authService: AuthService, stateProvider: StateProvider, private securityStateService: SecurityStateService, + private kdfConfigService: KdfConfigService, ) { super( tokenService, @@ -434,6 +435,7 @@ export class DefaultSyncService extends CoreSyncService { masterPasswordUnlockData, userId, ); + await this.kdfConfigService.setKdfConfig(userId, masterPasswordUnlockData.kdf); } } } diff --git a/libs/common/src/vault/models/request/attachment.request.ts b/libs/common/src/vault/models/request/attachment.request.ts index d058fa69d8b..80205835ab7 100644 --- a/libs/common/src/vault/models/request/attachment.request.ts +++ b/libs/common/src/vault/models/request/attachment.request.ts @@ -5,4 +5,5 @@ export class AttachmentRequest { key: string; fileSize: number; adminRequest: boolean; + lastKnownRevisionDate: Date; } diff --git a/libs/common/src/vault/models/request/cipher.request.ts b/libs/common/src/vault/models/request/cipher.request.ts index 63776c8aea6..b29d5865d2b 100644 --- a/libs/common/src/vault/models/request/cipher.request.ts +++ b/libs/common/src/vault/models/request/cipher.request.ts @@ -201,6 +201,7 @@ export class CipherRequest { this.attachments[attachment.id] = fileName; const attachmentRequest = new AttachmentRequest(); attachmentRequest.fileName = fileName; + attachmentRequest.lastKnownRevisionDate = cipher.revisionDate; if (attachment.key != null) { attachmentRequest.key = attachment.key.encryptedString; } diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index e6c22961673..85ce8bd0423 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -174,6 +174,37 @@ describe("Cipher Service", () => { expect(spy).toHaveBeenCalled(); }); + + it("should include lastKnownRevisionDate in the upload request", async () => { + const fileName = "filename"; + const fileData = new Uint8Array(10); + const testCipher = new Cipher(cipherData); + const expectedRevisionDate = "2022-01-31T12:00:00.000Z"; + + keyService.getOrgKey.mockReturnValue( + Promise.resolve(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey), + ); + keyService.makeDataEncKey.mockReturnValue( + Promise.resolve([ + new SymmetricCryptoKey(new Uint8Array(32)), + new EncString("encrypted-key"), + ] as any), + ); + + configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(false)); + configService.getFeatureFlag + .calledWith(FeatureFlag.CipherKeyEncryption) + .mockResolvedValue(false); + + const uploadSpy = jest.spyOn(cipherFileUploadService, "upload").mockResolvedValue({} as any); + + await cipherService.saveAttachmentRawWithServer(testCipher, fileName, fileData, userId); + + // Verify upload was called with cipher that has revisionDate + expect(uploadSpy).toHaveBeenCalled(); + const cipherArg = uploadSpy.mock.calls[0][0]; + expect(cipherArg.revisionDate).toEqual(new Date(expectedRevisionDate)); + }); }); describe("createWithServer()", () => { diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 41f94e02cdf..8032c69ed7c 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -937,7 +937,12 @@ export class CipherService implements CipherServiceAbstraction { cipher.attachments.forEach((attachment) => { if (attachment.key == null) { attachmentPromises.push( - this.shareAttachmentWithServer(attachment, cipher.id, organizationId), + this.shareAttachmentWithServer( + attachment, + cipher.id, + organizationId, + cipher.revisionDate, + ), ); } }); @@ -1722,7 +1727,10 @@ export class CipherService implements CipherServiceAbstraction { attachmentView: AttachmentView, cipherId: string, organizationId: string, + lastKnownRevisionDate: Date, ): Promise { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$); + const attachmentResponse = await this.apiService.nativeFetch( new Request(attachmentView.url, { cache: "no-store" }), ); @@ -1731,7 +1739,6 @@ export class CipherService implements CipherServiceAbstraction { } const encBuf = await EncArrayBuffer.fromResponse(attachmentResponse); - const activeUserId = await firstValueFrom(this.accountService.activeAccount$); const userKey = await this.keyService.getUserKey(activeUserId.id); const decBuf = await this.encryptService.decryptFileData(encBuf, userKey); @@ -1752,9 +1759,11 @@ export class CipherService implements CipherServiceAbstraction { const blob = new Blob([encData.buffer], { type: "application/octet-stream" }); fd.append("key", dataEncKey[1].encryptedString); fd.append("data", blob, encFileName.encryptedString); + fd.append("lastKnownRevisionDate", lastKnownRevisionDate.toISOString()); } catch (e) { if (Utils.isNode && !Utils.isBrowser) { fd.append("key", dataEncKey[1].encryptedString); + fd.append("lastKnownRevisionDate", lastKnownRevisionDate.toISOString()); fd.append( "data", Buffer.from(encData.buffer) as any, diff --git a/libs/common/src/vault/services/file-upload/cipher-file-upload.service.spec.ts b/libs/common/src/vault/services/file-upload/cipher-file-upload.service.spec.ts new file mode 100644 index 00000000000..8837f00df6b --- /dev/null +++ b/libs/common/src/vault/services/file-upload/cipher-file-upload.service.spec.ts @@ -0,0 +1,82 @@ +import { mock } from "jest-mock-extended"; + +import { ApiService } from "../../../abstractions/api.service"; +import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { FileUploadService } from "../../../platform/abstractions/file-upload/file-upload.service"; +import { Utils } from "../../../platform/misc/utils"; +import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { CipherType } from "../../enums/cipher-type"; +import { Cipher } from "../../models/domain/cipher"; +import { AttachmentUploadDataResponse } from "../../models/response/attachment-upload-data.response"; +import { CipherResponse } from "../../models/response/cipher.response"; + +import { CipherFileUploadService } from "./cipher-file-upload.service"; + +describe("CipherFileUploadService", () => { + const apiService = mock(); + const fileUploadService = mock(); + + let service: CipherFileUploadService; + + beforeEach(() => { + jest.clearAllMocks(); + + service = new CipherFileUploadService(apiService, fileUploadService); + }); + + describe("upload", () => { + it("should include lastKnownRevisionDate in the attachment request", async () => { + const cipherId = Utils.newGuid(); + const mockCipher = new Cipher({ + id: cipherId, + type: CipherType.Login, + name: "Test Cipher", + revisionDate: "2024-01-15T10:30:00.000Z", + } as any); + + const mockEncFileName = new EncString("encrypted-filename"); + const mockEncData = { + buffer: new ArrayBuffer(100), + } as unknown as EncArrayBuffer; + + const mockDataEncKey: [SymmetricCryptoKey, EncString] = [ + new SymmetricCryptoKey(new Uint8Array(32)), + new EncString("encrypted-key"), + ]; + + const mockUploadDataResponse = { + attachmentId: "attachment-id", + url: "https://upload.example.com", + fileUploadType: 0, + cipherResponse: { + id: cipherId, + type: CipherType.Login, + revisionDate: "2024-01-15T10:30:00.000Z", + } as CipherResponse, + cipherMiniResponse: null, + } as AttachmentUploadDataResponse; + + apiService.postCipherAttachment.mockResolvedValue(mockUploadDataResponse); + fileUploadService.upload.mockResolvedValue(undefined); + + await service.upload(mockCipher, mockEncFileName, mockEncData, false, mockDataEncKey); + + const callArgs = apiService.postCipherAttachment.mock.calls[0][1]; + + expect(apiService.postCipherAttachment).toHaveBeenCalledWith( + cipherId, + expect.objectContaining({ + key: "encrypted-key", + fileName: "encrypted-filename", + fileSize: 100, + adminRequest: false, + }), + ); + + // Verify lastKnownRevisionDate is set (it's converted to a Date object) + expect(callArgs.lastKnownRevisionDate).toBeDefined(); + expect(callArgs.lastKnownRevisionDate).toEqual(new Date("2024-01-15T10:30:00.000Z")); + }); + }); +}); diff --git a/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts b/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts index 2fb2746366d..8d97a921748 100644 --- a/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts +++ b/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts @@ -33,6 +33,7 @@ export class CipherFileUploadService implements CipherFileUploadServiceAbstracti fileName: encFileName.encryptedString, fileSize: encData.buffer.byteLength, adminRequest: admin, + lastKnownRevisionDate: cipher.revisionDate, }; let response: CipherResponse; diff --git a/libs/common/src/vault/tasks/abstractions/task.service.ts b/libs/common/src/vault/tasks/abstractions/task.service.ts index 79cefff0b71..816c676413a 100644 --- a/libs/common/src/vault/tasks/abstractions/task.service.ts +++ b/libs/common/src/vault/tasks/abstractions/task.service.ts @@ -25,6 +25,12 @@ export abstract class TaskService { */ abstract pendingTasks$(userId: UserId): Observable; + /** + * Observable of completed tasks for a given user. + * @param userId + */ + abstract completedTasks$(userId: UserId): Observable; + /** * Retrieves tasks from the API for a given user and updates the local state. * @param userId diff --git a/libs/common/src/vault/tasks/services/default-task.service.ts b/libs/common/src/vault/tasks/services/default-task.service.ts index 6238076ccf5..bbf58aec5e9 100644 --- a/libs/common/src/vault/tasks/services/default-task.service.ts +++ b/libs/common/src/vault/tasks/services/default-task.service.ts @@ -80,6 +80,12 @@ export class DefaultTaskService implements TaskService { ); }); + completedTasks$ = perUserCache$((userId) => { + return this.tasks$(userId).pipe( + map((tasks) => tasks.filter((t) => t.status === SecurityTaskStatus.Completed)), + ); + }); + async refreshTasks(userId: UserId): Promise { await this.fetchTasksFromApi(userId); } diff --git a/libs/components/src/banner/banner.component.ts b/libs/components/src/banner/banner.component.ts index f258ed0c48c..1f9bf960d4b 100644 --- a/libs/components/src/banner/banner.component.ts +++ b/libs/components/src/banner/banner.component.ts @@ -38,7 +38,8 @@ const defaultIcon: Record = { export class BannerComponent implements OnInit { readonly bannerType = input("info"); - readonly icon = model(); + // passing `null` will remove the icon from element from the banner + readonly icon = model(); readonly useAlertRole = input(true); readonly showClose = input(true); @@ -47,7 +48,7 @@ export class BannerComponent implements OnInit { @Output() onClose = new EventEmitter(); ngOnInit(): void { - if (!this.icon()) { + if (!this.icon() && this.icon() !== null) { this.icon.set(defaultIcon[this.bannerType()]); } } diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index c2dbccd6fdf..be2e18ccdc4 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -36,6 +36,7 @@ export const DELETE_MANAGED_USER_WARNING = new StateDefinition( web: "disk-local", }, ); +export const AUTO_CONFIRM = new StateDefinition("autoConfirm", "disk"); // Billing export const BILLING_DISK = new StateDefinition("billing", "disk"); @@ -210,6 +211,7 @@ export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition( "vaultBrowserIntroCarousel", "disk", ); +export const VAULT_AT_RISK_PASSWORDS_MEMORY = new StateDefinition("vaultAtRiskPasswords", "memory"); // KM diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts index 439c651e5ad..c88ce9f0301 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts @@ -240,6 +240,49 @@ describe("CipherAttachmentsComponent", () => { message: "maxFileSize", }); }); + + it("shows error toast with server message when saveAttachmentWithServer fails", async () => { + const file = { size: 100 } as File; + component.attachmentForm.controls.file.setValue(file); + + const serverError = new Error("Cipher has been modified by another client"); + saveAttachmentWithServer.mockRejectedValue(serverError); + + await component.submit(); + + expect(showToast).toHaveBeenCalledWith({ + variant: "error", + message: "Cipher has been modified by another client", + }); + }); + + it("shows error toast with fallback message when error has no message property", async () => { + const file = { size: 100 } as File; + component.attachmentForm.controls.file.setValue(file); + + saveAttachmentWithServer.mockRejectedValue({ code: "UNKNOWN_ERROR" }); + + await component.submit(); + + expect(showToast).toHaveBeenCalledWith({ + variant: "error", + message: "unexpectedError", + }); + }); + + it("shows error toast with string error message", async () => { + const file = { size: 100 } as File; + component.attachmentForm.controls.file.setValue(file); + + saveAttachmentWithServer.mockRejectedValue("Network connection failed"); + + await component.submit(); + + expect(showToast).toHaveBeenCalledWith({ + variant: "error", + message: "Network connection failed", + }); + }); }); describe("success", () => { diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts index 0bcb31c7af9..9ae1c62bd3e 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts @@ -222,6 +222,19 @@ export class CipherAttachmentsComponent implements OnInit, AfterViewInit { this.onUploadSuccess.emit(); } catch (e) { this.logService.error(e); + + // Extract error message from server response, fallback to generic message + let errorMessage = this.i18nService.t("unexpectedError"); + if (typeof e === "string") { + errorMessage = e; + } else if (e?.message) { + errorMessage = e.message; + } + + this.toastService.showToast({ + variant: "error", + message: errorMessage, + }); } }; diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index 67b5509c8ac..e40231ce801 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -640,6 +640,46 @@ describe("ItemDetailsSectionComponent", () => { }); }); + describe("initFromExistingCipher", () => { + it("should set organizationId to null when prefillCipher.organizationId is undefined", async () => { + component.config.organizationDataOwnershipDisabled = true; + component.config.organizations = [{ id: "org1" } as Organization]; + + const prefillCipher = { + name: "Test Cipher", + organizationId: undefined, + folderId: null, + collectionIds: [], + favorite: false, + } as unknown as CipherView; + + getInitialCipherView.mockReturnValueOnce(prefillCipher); + + await component.ngOnInit(); + + expect(component.itemDetailsForm.controls.organizationId.value).toBeNull(); + }); + + it("should preserve organizationId when prefillCipher.organizationId has a value", async () => { + component.config.organizationDataOwnershipDisabled = true; + component.config.organizations = [{ id: "org1", name: "Organization 1" } as Organization]; + + const prefillCipher = { + name: "Test Cipher", + organizationId: "org1", + folderId: null, + collectionIds: [], + favorite: false, + } as unknown as CipherView; + + getInitialCipherView.mockReturnValueOnce(prefillCipher); + + await component.ngOnInit(); + + expect(component.itemDetailsForm.controls.organizationId.value).toBe("org1"); + }); + }); + describe("form status when editing a cipher", () => { beforeEach(() => { component.config.mode = "edit"; diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index ce0244bc759..892fc5804ec 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -4,7 +4,7 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, Input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; -import { concatMap, firstValueFrom, map } from "rxjs"; +import { concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs"; // 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 @@ -236,6 +236,7 @@ export class ItemDetailsSectionComponent implements OnInit { this.itemDetailsForm.controls.organizationId.valueChanges .pipe( takeUntilDestroyed(this.destroyRef), + distinctUntilChanged(), concatMap(async () => { await this.updateCollectionOptions(); this.setFormState(); @@ -314,7 +315,10 @@ export class ItemDetailsSectionComponent implements OnInit { this.itemDetailsForm.patchValue({ name: name ? name : (this.initialValues?.name ?? ""), - organizationId: prefillCipher.organizationId, // We do not allow changing ownership of an existing cipher. + // We do not allow changing ownership of an existing cipher. + // Angular forms do not support `undefined` as a value for a form control, + // force `null` if `organizationId` is undefined. + organizationId: prefillCipher.organizationId ?? null, folderId: folderId ? folderId : (this.initialValues?.folderId ?? null), collectionIds: [], favorite: prefillCipher.favorite, diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 3f05c753da4..ccd830cd34e 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -1,3 +1,7 @@ +export { + AtRiskPasswordCalloutService, + AtRiskPasswordCalloutData, +} from "./services/at-risk-password-callout.service"; export { PasswordRepromptService } from "./services/password-reprompt.service"; export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service"; export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive"; diff --git a/libs/vault/src/services/at-risk-password-callout.service.spec.ts b/libs/vault/src/services/at-risk-password-callout.service.spec.ts new file mode 100644 index 00000000000..47b83f4a903 --- /dev/null +++ b/libs/vault/src/services/at-risk-password-callout.service.spec.ts @@ -0,0 +1,162 @@ +import { TestBed } from "@angular/core/testing"; +import { firstValueFrom, of } from "rxjs"; + +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { + SecurityTask, + SecurityTaskStatus, + SecurityTaskType, + TaskService, +} from "@bitwarden/common/vault/tasks"; +import { StateProvider } from "@bitwarden/state"; +import { UserId } from "@bitwarden/user-core"; + +import { FakeSingleUserState } from "../../../common/spec/fake-state"; + +import { + AtRiskPasswordCalloutData, + AtRiskPasswordCalloutService, +} from "./at-risk-password-callout.service"; + +const fakeUserState = () => + ({ + update: jest.fn().mockResolvedValue(undefined), + state$: of(null), + }) as unknown as FakeSingleUserState; + +class MockCipherView { + constructor( + public id: string, + private deleted: boolean, + ) {} + get isDeleted() { + return this.deleted; + } +} + +describe("AtRiskPasswordCalloutService", () => { + let service: AtRiskPasswordCalloutService; + const mockTaskService = { + pendingTasks$: jest.fn(), + completedTasks$: jest.fn(), + }; + const mockCipherService = { cipherViews$: jest.fn() }; + const mockStateProvider = { getUser: jest.fn().mockReturnValue(fakeUserState()) }; + const userId: UserId = "user1" as UserId; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + AtRiskPasswordCalloutService, + { + provide: TaskService, + useValue: mockTaskService, + }, + { + provide: CipherService, + useValue: mockCipherService, + }, + { + provide: StateProvider, + useValue: mockStateProvider, + }, + ], + }); + + service = TestBed.inject(AtRiskPasswordCalloutService); + }); + + describe("completedTasks$", () => { + it(" should return true if completed tasks exist", async () => { + const tasks: SecurityTask[] = [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Completed, + } as any, + { + id: "t2", + cipherId: "c2", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Pending, + } as any, + { + id: "t3", + cipherId: "nope", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Completed, + } as any, + { + id: "t4", + cipherId: "c3", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Completed, + } as any, + ]; + + jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of(tasks)); + + const result = await firstValueFrom(service.completedTasks$(userId)); + + expect(result).toEqual(tasks[0]); + expect(result?.id).toBe("t1"); + }); + }); + + describe("showCompletedTasksBanner$", () => { + beforeEach(() => { + jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of([])); + jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of([])); + jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of([])); + }); + + it("should return false if banner has been dismissed", async () => { + const state: AtRiskPasswordCalloutData = { + hasInteractedWithTasks: true, + tasksBannerDismissed: true, + }; + const mockState = { ...fakeUserState(), state$: of(state) }; + mockStateProvider.getUser.mockReturnValue(mockState); + + const result = await firstValueFrom(service.showCompletedTasksBanner$(userId)); + + expect(result).toBe(false); + }); + + it("should return true when has completed tasks, no pending tasks, and banner not dismissed", async () => { + const completedTasks = [ + { + id: "t1", + cipherId: "c1", + type: SecurityTaskType.UpdateAtRiskCredential, + status: SecurityTaskStatus.Completed, + }, + ]; + const ciphers = [new MockCipherView("c1", false)]; + const state: AtRiskPasswordCalloutData = { + hasInteractedWithTasks: true, + tasksBannerDismissed: false, + }; + + jest.spyOn(mockTaskService, "completedTasks$").mockReturnValue(of(completedTasks)); + jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers)); + mockStateProvider.getUser.mockReturnValue({ state$: of(state) }); + + const result = await firstValueFrom(service.showCompletedTasksBanner$(userId)); + + expect(result).toBe(true); + }); + + it("returns false when no completed tasks", async () => { + const state: AtRiskPasswordCalloutData = { + hasInteractedWithTasks: true, + tasksBannerDismissed: false, + }; + mockStateProvider.getUser.mockReturnValue({ state$: of(state) }); + + const result = await firstValueFrom(service.showCompletedTasksBanner$(userId)); + + expect(result).toBe(false); + }); + }); +}); diff --git a/libs/vault/src/services/at-risk-password-callout.service.ts b/libs/vault/src/services/at-risk-password-callout.service.ts new file mode 100644 index 00000000000..d3af4f8421e --- /dev/null +++ b/libs/vault/src/services/at-risk-password-callout.service.ts @@ -0,0 +1,93 @@ +import { Injectable } from "@angular/core"; +import { combineLatest, map, Observable } from "rxjs"; + +import { + SingleUserState, + StateProvider, + UserKeyDefinition, + VAULT_AT_RISK_PASSWORDS_MEMORY, +} from "@bitwarden/common/platform/state"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; +import { UserId } from "@bitwarden/user-core"; + +export type AtRiskPasswordCalloutData = { + hasInteractedWithTasks: boolean; + tasksBannerDismissed: boolean; +}; + +export const AT_RISK_PASSWORD_CALLOUT_KEY = new UserKeyDefinition( + VAULT_AT_RISK_PASSWORDS_MEMORY, + "atRiskPasswords", + { + deserializer: (jsonData) => jsonData, + clearOn: ["lock", "logout"], + }, +); + +@Injectable() +export class AtRiskPasswordCalloutService { + constructor( + private taskService: TaskService, + private cipherService: CipherService, + private stateProvider: StateProvider, + ) {} + + pendingTasks$(userId: UserId): Observable { + return combineLatest([ + this.taskService.pendingTasks$(userId), + this.cipherService.cipherViews$(userId), + ]).pipe( + map(([tasks, ciphers]) => { + return tasks.filter((t: SecurityTask) => { + const associatedCipher = ciphers.find((c) => c.id === t.cipherId); + + return ( + t.type === SecurityTaskType.UpdateAtRiskCredential && + associatedCipher && + !associatedCipher.isDeleted + ); + }); + }), + ); + } + + completedTasks$(userId: UserId): Observable { + return this.taskService.completedTasks$(userId).pipe( + map((tasks) => { + return tasks.find((t: SecurityTask) => t.type === SecurityTaskType.UpdateAtRiskCredential); + }), + ); + } + + showCompletedTasksBanner$(userId: UserId): Observable { + return combineLatest([ + this.pendingTasks$(userId), + this.completedTasks$(userId), + this.atRiskPasswordState(userId).state$, + ]).pipe( + map(([pendingTasks, completedTasks, state]) => { + const hasPendingTasks = pendingTasks.length > 0; + const bannerDismissed = state?.tasksBannerDismissed ?? false; + const hasInteracted = state?.hasInteractedWithTasks ?? false; + + // This will ensure the banner remains visible only in the client the user resolved their tasks in + // e.g. if the user did not see tasks in the browser, and resolves them in the web, the browser will not show the banner + if (!hasPendingTasks && (!hasInteracted || bannerDismissed)) { + return false; + } + + // Show banner if there are completed tasks and no pending tasks, and banner hasn't been dismissed + return !!completedTasks && !hasPendingTasks && !(state?.tasksBannerDismissed ?? false); + }), + ); + } + + atRiskPasswordState(userId: UserId): SingleUserState { + return this.stateProvider.getUser(userId, AT_RISK_PASSWORD_CALLOUT_KEY); + } + + updateAtRiskPasswordState(userId: UserId, updatedState: AtRiskPasswordCalloutData): void { + void this.atRiskPasswordState(userId).update(() => updatedState); + } +} diff --git a/package-lock.json b/package-lock.json index 4e3707a60e2..c9abe11b585 100644 --- a/package-lock.json +++ b/package-lock.json @@ -154,7 +154,7 @@ "jest-diff": "29.7.0", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.7", - "jest-preset-angular": "14.6.0", + "jest-preset-angular": "14.6.1", "json5": "2.2.3", "lint-staged": "16.0.0", "mini-css-extract-plugin": "2.9.2", @@ -171,7 +171,7 @@ "storybook": "8.6.12", "style-loader": "4.0.0", "tailwindcss": "3.4.17", - "ts-jest": "29.3.4", + "ts-jest": "29.4.5", "ts-loader": "9.5.2", "tsconfig-paths-webpack-plugin": "4.2.0", "type-fest": "2.19.0", @@ -26855,9 +26855,9 @@ } }, "node_modules/jest-preset-angular": { - "version": "14.6.0", - "resolved": "https://registry.npmjs.org/jest-preset-angular/-/jest-preset-angular-14.6.0.tgz", - "integrity": "sha512-LGSKLCsUhtrs2dw6f7ega/HOS8/Ni/1gV+oXmxPHmJDLHFpM6cI78Monmz8Z1P87a/A4OwnKilxgPRr+6Pzmgg==", + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/jest-preset-angular/-/jest-preset-angular-14.6.1.tgz", + "integrity": "sha512-7q5x42wKrsF2ykOwGVzcXpr9p1X4FQJMU/DnH1tpvCmeOm5XqENdwD/xDZug+nP6G8SJPdioauwdsK/PMY/MpQ==", "dev": true, "license": "MIT", "dependencies": { @@ -39290,20 +39290,19 @@ "license": "Apache-2.0" }, "node_modules/ts-jest": { - "version": "29.3.4", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.4.tgz", - "integrity": "sha512-Iqbrm8IXOmV+ggWHOTEbjwyCf2xZlUMv5npExksXohL+tk8va4Fjhb+X2+Rt9NBmgO7bJ8WpnMLOwih/DnMlFA==", + "version": "29.4.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", + "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", - "ejs": "^3.1.10", "fast-json-stable-stringify": "^2.1.0", - "jest-util": "^29.0.0", + "handlebars": "^4.7.8", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.2", + "semver": "^7.7.3", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -39315,10 +39314,11 @@ }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0", - "@jest/types": "^29.0.0", - "babel-jest": "^29.0.0", - "jest": "^29.0.0", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <6" }, "peerDependenciesMeta": { @@ -39336,9 +39336,25 @@ }, "esbuild": { "optional": true + }, + "jest-util": { + "optional": true } } }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ts-jest/node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", diff --git a/package.json b/package.json index 4acf038b65a..88cf2bda43c 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "jest-diff": "29.7.0", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.7", - "jest-preset-angular": "14.6.0", + "jest-preset-angular": "14.6.1", "json5": "2.2.3", "lint-staged": "16.0.0", "mini-css-extract-plugin": "2.9.2", @@ -134,7 +134,7 @@ "storybook": "8.6.12", "style-loader": "4.0.0", "tailwindcss": "3.4.17", - "ts-jest": "29.3.4", + "ts-jest": "29.4.5", "ts-loader": "9.5.2", "tsconfig-paths-webpack-plugin": "4.2.0", "type-fest": "2.19.0",