diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index fcc80e5ff7d..9360524da1d 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3035,6 +3035,9 @@ "accountSecurity": { "message": "Account security" }, + "notifications": { + "message": "Notifications" + }, "appearance": { "message": "Appearance" }, diff --git a/apps/browser/src/auth/guards/fido2-auth.guard.ts b/apps/browser/src/auth/guards/fido2-auth.guard.ts index f6b560c71d6..0c4e6268bfc 100644 --- a/apps/browser/src/auth/guards/fido2-auth.guard.ts +++ b/apps/browser/src/auth/guards/fido2-auth.guard.ts @@ -26,7 +26,9 @@ export const fido2AuthGuard: CanActivateFn = async ( const authStatus = await authService.getAuthStatus(); if (authStatus === AuthenticationStatus.Locked) { - routerService.setPreviousUrl(state.url); + // Appending fromLock=true to the query params to indicate that the user is being redirected from the lock screen, this is used for user verification. + const previousUrl = `${state.url}&fromLock=true`; + routerService.setPreviousUrl(previousUrl); return router.createUrlTree(["/lock"], { queryParams: route.queryParams }); } diff --git a/apps/browser/src/popup/settings/excluded-domains.component.html b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html similarity index 98% rename from apps/browser/src/popup/settings/excluded-domains.component.html rename to apps/browser/src/autofill/popup/settings/excluded-domains.component.html index 652eb4aef08..8f78ac16404 100644 --- a/apps/browser/src/popup/settings/excluded-domains.component.html +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html @@ -1,7 +1,7 @@
- diff --git a/apps/browser/src/popup/settings/excluded-domains.component.ts b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts similarity index 97% rename from apps/browser/src/popup/settings/excluded-domains.component.ts rename to apps/browser/src/autofill/popup/settings/excluded-domains.component.ts index 1741fbaa659..5dad991dfa4 100644 --- a/apps/browser/src/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts @@ -8,8 +8,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { BrowserApi } from "../../platform/browser/browser-api"; -import { enableAccountSwitching } from "../../platform/flags"; +import { BrowserApi } from "../../../platform/browser/browser-api"; +import { enableAccountSwitching } from "../../../platform/flags"; interface ExcludedDomain { uri: string; diff --git a/apps/browser/src/autofill/popup/settings/notifications.component.html b/apps/browser/src/autofill/popup/settings/notifications.component.html new file mode 100644 index 00000000000..89d83c9e480 --- /dev/null +++ b/apps/browser/src/autofill/popup/settings/notifications.component.html @@ -0,0 +1,89 @@ +
+
+ +
+

+ {{ "notifications" | i18n }} +

+
+ +
+
+
+
+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ +
+
+
+ +
+
+
diff --git a/apps/browser/src/autofill/popup/settings/notifications.component.ts b/apps/browser/src/autofill/popup/settings/notifications.component.ts new file mode 100644 index 00000000000..8e092192757 --- /dev/null +++ b/apps/browser/src/autofill/popup/settings/notifications.component.ts @@ -0,0 +1,53 @@ +import { Component, OnInit } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service"; +import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; + +import { enableAccountSwitching } from "../../../platform/flags"; + +@Component({ + selector: "autofill-notification-settings", + templateUrl: "notifications.component.html", +}) +export class NotifcationsSettingsComponent implements OnInit { + enableAddLoginNotification = false; + enableChangedPasswordNotification = false; + enablePasskeys = true; + accountSwitcherEnabled = false; + + constructor( + private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction, + private vaultSettingsService: VaultSettingsService, + ) { + this.accountSwitcherEnabled = enableAccountSwitching(); + } + + async ngOnInit() { + this.enableAddLoginNotification = await firstValueFrom( + this.userNotificationSettingsService.enableAddedLoginPrompt$, + ); + + this.enableChangedPasswordNotification = await firstValueFrom( + this.userNotificationSettingsService.enableChangedPasswordPrompt$, + ); + + this.enablePasskeys = await firstValueFrom(this.vaultSettingsService.enablePasskeys$); + } + + async updateAddLoginNotification() { + await this.userNotificationSettingsService.setEnableAddedLoginPrompt( + this.enableAddLoginNotification, + ); + } + + async updateChangedPasswordNotification() { + await this.userNotificationSettingsService.setEnableChangedPasswordPrompt( + this.enableChangedPasswordNotification, + ); + } + + async updateEnablePasskeys() { + await this.vaultSettingsService.setEnablePasskeys(this.enablePasskeys); + } +} diff --git a/apps/browser/src/popup/components/pop-out.component.html b/apps/browser/src/platform/popup/components/pop-out.component.html similarity index 100% rename from apps/browser/src/popup/components/pop-out.component.html rename to apps/browser/src/platform/popup/components/pop-out.component.html diff --git a/apps/browser/src/popup/components/pop-out.component.ts b/apps/browser/src/platform/popup/components/pop-out.component.ts similarity index 64% rename from apps/browser/src/popup/components/pop-out.component.ts rename to apps/browser/src/platform/popup/components/pop-out.component.ts index d60936a2355..154bb55a0c9 100644 --- a/apps/browser/src/popup/components/pop-out.component.ts +++ b/apps/browser/src/platform/popup/components/pop-out.component.ts @@ -1,12 +1,16 @@ +import { CommonModule } from "@angular/common"; import { Component, Input, OnInit } from "@angular/core"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; +import BrowserPopupUtils from "../browser-popup-utils"; @Component({ selector: "app-pop-out", templateUrl: "pop-out.component.html", + standalone: true, + imports: [CommonModule, JslibModule], }) export class PopOutComponent implements OnInit { @Input() show = true; @@ -24,9 +28,7 @@ export class PopOutComponent implements OnInit { } } - expand() { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - BrowserPopupUtils.openCurrentPagePopout(window); + async expand() { + await BrowserPopupUtils.openCurrentPagePopout(window); } } diff --git a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts index 855492521bd..4163ca93107 100644 --- a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts @@ -163,6 +163,10 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic * the view is open. */ async isViewOpen(): Promise { + if (this.isSafari()) { + // Query views on safari since chrome.runtime.sendMessage does not timeout and will hang. + return BrowserApi.isPopupOpen(); + } return Boolean(await BrowserApi.sendMessageWithResponse("checkVaultPopupHeartbeat")); } diff --git a/apps/browser/src/popup/app-routing.animations.ts b/apps/browser/src/popup/app-routing.animations.ts index 55d2687a436..96e5dbbe378 100644 --- a/apps/browser/src/popup/app-routing.animations.ts +++ b/apps/browser/src/popup/app-routing.animations.ts @@ -196,9 +196,6 @@ export const routerTransition = trigger("routerTransition", [ transition("vault-settings => sync", inSlideLeft), transition("sync => vault-settings", outSlideRight), - transition("tabs => excluded-domains", inSlideLeft), - transition("excluded-domains => tabs", outSlideRight), - transition("tabs => options", inSlideLeft), transition("options => tabs", outSlideRight), @@ -223,6 +220,13 @@ export const routerTransition = trigger("routerTransition", [ transition("tabs => edit-send, send-type => edit-send", inSlideUp), transition("edit-send => tabs, edit-send => send-type", outSlideDown), + // Notification settings + transition("tabs => notifications", inSlideLeft), + transition("notifications => tabs", outSlideRight), + + transition("notifications => excluded-domains", inSlideLeft), + transition("excluded-domains => notifications", outSlideRight), + transition("tabs => autofill", inSlideLeft), transition("autofill => tabs", outSlideRight), diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 8fb397fe240..1db2f92d3e5 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -27,6 +27,8 @@ import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.comp import { TwoFactorComponent } from "../auth/popup/two-factor.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; +import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component"; +import { NotifcationsSettingsComponent } from "../autofill/popup/settings/notifications.component"; import { PremiumComponent } from "../billing/popup/settings/premium.component"; import BrowserPopupUtils from "../platform/popup/browser-popup-utils"; import { GeneratorComponent } from "../tools/popup/generator/generator.component"; @@ -56,7 +58,6 @@ import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.c import { extensionRefreshRedirect, extensionRefreshSwap } from "./extension-refresh-route-utils"; import { debounceNavigationGuard } from "./services/debounce-navigation.service"; -import { ExcludedDomainsComponent } from "./settings/excluded-domains.component"; import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component"; import { OptionsComponent } from "./settings/options.component"; import { TabsV2Component } from "./tabs-v2.component"; @@ -256,6 +257,12 @@ const routes: Routes = [ canActivate: [AuthGuard], data: { state: "account-security" }, }, + { + path: "notifications", + component: NotifcationsSettingsComponent, + canActivate: [AuthGuard], + data: { state: "notifications" }, + }, { path: "vault-settings", component: VaultSettingsComponent, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 4a310027c1b..05158d3295d 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -14,6 +14,7 @@ import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe"; import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe"; +import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; import { AvatarModule, ButtonModule, ToastModule } from "@bitwarden/components"; import { ExportScopeCalloutComponent } from "@bitwarden/vault-export-ui"; @@ -37,7 +38,10 @@ import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.comp import { TwoFactorComponent } from "../auth/popup/two-factor.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; +import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component"; +import { NotifcationsSettingsComponent } from "../autofill/popup/settings/notifications.component"; import { PremiumComponent } from "../billing/popup/settings/premium.component"; +import { PopOutComponent } from "../platform/popup/components/pop-out.component"; import { HeaderComponent } from "../platform/popup/header.component"; import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component"; @@ -78,10 +82,8 @@ import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.c import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; -import { PopOutComponent } from "./components/pop-out.component"; import { UserVerificationComponent } from "./components/user-verification.component"; import { ServicesModule } from "./services/services.module"; -import { ExcludedDomainsComponent } from "./settings/excluded-domains.component"; import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component"; import { OptionsComponent } from "./settings/options.component"; import { TabsV2Component } from "./tabs-v2.component"; @@ -116,10 +118,12 @@ import "../platform/popup/locales"; AccountComponent, ButtonModule, ExportScopeCalloutComponent, + PopOutComponent, PopupPageComponent, PopupTabNavigationComponent, PopupFooterComponent, PopupHeaderComponent, + UserVerificationDialogComponent, ], declarations: [ ActionButtonsComponent, @@ -149,11 +153,11 @@ import "../platform/popup/locales"; LoginViaAuthRequestComponent, LoginDecryptionOptionsComponent, OptionsComponent, + NotifcationsSettingsComponent, AppearanceComponent, GeneratorComponent, PasswordGeneratorHistoryComponent, PasswordHistoryComponent, - PopOutComponent, PremiumComponent, RegisterComponent, SendAddEditComponent, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index dad8c39cdc5..de2fc727474 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -87,6 +87,7 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; import { UnauthGuardService } from "../../auth/popup/services"; import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service"; @@ -117,6 +118,7 @@ import { ForegroundMemoryStorageService } from "../../platform/storage/foregroun import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging"; import { BrowserSendStateService } from "../../tools/popup/services/browser-send-state.service"; import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; +import { Fido2UserVerificationService } from "../../vault/services/fido2-user-verification.service"; import { VaultBrowserStateService } from "../../vault/services/vault-browser-state.service"; import { VaultFilterService } from "../../vault/services/vault-filter.service"; @@ -600,6 +602,11 @@ const safeProviders: SafeProvider[] = [ provide: CLIENT_TYPE, useValue: ClientType.Browser, }), + safeProvider({ + provide: Fido2UserVerificationService, + useClass: Fido2UserVerificationService, + deps: [PasswordRepromptService, UserVerificationService, DialogService], + }), ]; @NgModule({ diff --git a/apps/browser/src/popup/settings/options.component.html b/apps/browser/src/popup/settings/options.component.html index fa2b7514dbf..0382eb5b866 100644 --- a/apps/browser/src/popup/settings/options.component.html +++ b/apps/browser/src/popup/settings/options.component.html @@ -60,67 +60,6 @@
-
-
-
- - -
-
- -
-
-
-
- - -
-
- -
-
-
-
- - -
-
- -
diff --git a/apps/browser/src/popup/settings/options.component.ts b/apps/browser/src/popup/settings/options.component.ts index 0344362d36a..cfcc81bb22c 100644 --- a/apps/browser/src/popup/settings/options.component.ts +++ b/apps/browser/src/popup/settings/options.component.ts @@ -3,7 +3,6 @@ import { firstValueFrom } from "rxjs"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; -import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { ClearClipboardDelaySetting } from "@bitwarden/common/autofill/types"; import { UriMatchStrategy, @@ -25,9 +24,6 @@ export class OptionsComponent implements OnInit { autoFillOnPageLoadOptions: any[]; enableAutoTotpCopy = false; // TODO: Does it matter if this is set to false or true? enableContextMenuItem = false; - enableAddLoginNotification = false; - enableChangedPasswordNotification = false; - enablePasskeys = true; showCardsCurrentTab = false; showIdentitiesCurrentTab = false; showClearClipboard = true; @@ -36,13 +32,11 @@ export class OptionsComponent implements OnInit { clearClipboard: ClearClipboardDelaySetting; clearClipboardOptions: any[]; showGeneral = true; - showAutofill = true; showDisplay = true; accountSwitcherEnabled = false; constructor( private messagingService: MessagingService, - private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction, private autofillSettingsService: AutofillSettingsServiceAbstraction, private domainSettingsService: DomainSettingsService, i18nService: I18nService, @@ -82,14 +76,6 @@ export class OptionsComponent implements OnInit { this.autofillSettingsService.autofillOnPageLoadDefault$, ); - this.enableAddLoginNotification = await firstValueFrom( - this.userNotificationSettingsService.enableAddedLoginPrompt$, - ); - - this.enableChangedPasswordNotification = await firstValueFrom( - this.userNotificationSettingsService.enableChangedPasswordPrompt$, - ); - this.enableContextMenuItem = await firstValueFrom( this.autofillSettingsService.enableContextMenu$, ); @@ -101,8 +87,6 @@ export class OptionsComponent implements OnInit { this.enableAutoTotpCopy = await firstValueFrom(this.autofillSettingsService.autoCopyTotp$); - this.enablePasskeys = await firstValueFrom(this.vaultSettingsService.enablePasskeys$); - const defaultUriMatch = await firstValueFrom( this.domainSettingsService.defaultUriMatchStrategy$, ); @@ -111,22 +95,6 @@ export class OptionsComponent implements OnInit { this.clearClipboard = await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$); } - async updateAddLoginNotification() { - await this.userNotificationSettingsService.setEnableAddedLoginPrompt( - this.enableAddLoginNotification, - ); - } - - async updateChangedPasswordNotification() { - await this.userNotificationSettingsService.setEnableChangedPasswordPrompt( - this.enableChangedPasswordNotification, - ); - } - - async updateEnablePasskeys() { - await this.vaultSettingsService.setEnablePasskeys(this.enablePasskeys); - } - async updateContextMenuItem() { await this.autofillSettingsService.setEnableContextMenu(this.enableContextMenuItem); this.messagingService.send("bgUpdateContextMenu"); diff --git a/apps/browser/src/tools/popup/settings/about/about.component.html b/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.html similarity index 100% rename from apps/browser/src/tools/popup/settings/about/about.component.html rename to apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.html diff --git a/apps/browser/src/tools/popup/settings/about/about.component.ts b/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts similarity index 93% rename from apps/browser/src/tools/popup/settings/about/about.component.ts rename to apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts index d7f98c1e7f7..0467debdfb5 100644 --- a/apps/browser/src/tools/popup/settings/about/about.component.ts +++ b/apps/browser/src/tools/popup/settings/about-dialog/about-dialog.component.ts @@ -9,11 +9,11 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { ButtonModule, DialogModule } from "@bitwarden/components"; @Component({ - templateUrl: "about.component.html", + templateUrl: "about-dialog.component.html", standalone: true, imports: [CommonModule, JslibModule, DialogModule, ButtonModule], }) -export class AboutComponent { +export class AboutDialogComponent { protected year = new Date().getFullYear(); protected version$: Observable; diff --git a/apps/browser/src/tools/popup/settings/settings.component.html b/apps/browser/src/tools/popup/settings/settings.component.html index 997bc557b67..7506a07da55 100644 --- a/apps/browser/src/tools/popup/settings/settings.component.html +++ b/apps/browser/src/tools/popup/settings/settings.component.html @@ -30,17 +30,17 @@
diff --git a/apps/browser/src/tools/popup/settings/settings.component.ts b/apps/browser/src/tools/popup/settings/settings.component.ts index 81727c442cd..d0c5d63092b 100644 --- a/apps/browser/src/tools/popup/settings/settings.component.ts +++ b/apps/browser/src/tools/popup/settings/settings.component.ts @@ -13,7 +13,7 @@ import { DialogService } from "@bitwarden/components"; import { BrowserApi } from "../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; -import { AboutComponent } from "./about/about.component"; +import { AboutDialogComponent } from "./about-dialog/about-dialog.component"; const RateUrls = { [DeviceType.ChromeExtension]: @@ -84,7 +84,7 @@ export class SettingsComponent implements OnInit { } about() { - this.dialogService.open(AboutComponent); + this.dialogService.open(AboutDialogComponent); } rate() { diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts index 323d2ab4f29..8d46cc60339 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts +++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts @@ -27,13 +27,13 @@ import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; import { DialogService } from "@bitwarden/components"; -import { PasswordRepromptService } from "@bitwarden/vault"; import { ZonedMessageListenerService } from "../../../../platform/browser/zoned-message-listener.service"; import { BrowserFido2Message, BrowserFido2UserInterfaceSession, } from "../../../fido2/browser-fido2-user-interface.service"; +import { Fido2UserVerificationService } from "../../../services/fido2-user-verification.service"; import { VaultPopoutType } from "../../utils/vault-popout-window"; interface ViewData { @@ -59,6 +59,7 @@ export class Fido2Component implements OnInit, OnDestroy { protected data$: Observable; protected sessionId?: string; protected senderTabId?: string; + protected fromLock?: boolean; protected ciphers?: CipherView[] = []; protected displayedCiphers?: CipherView[] = []; protected loading = false; @@ -71,13 +72,13 @@ export class Fido2Component implements OnInit, OnDestroy { private router: Router, private activatedRoute: ActivatedRoute, private cipherService: CipherService, - private passwordRepromptService: PasswordRepromptService, private platformUtilsService: PlatformUtilsService, private domainSettingsService: DomainSettingsService, private searchService: SearchService, private logService: LogService, private dialogService: DialogService, private browserMessagingApi: ZonedMessageListenerService, + private fido2UserVerificationService: Fido2UserVerificationService, ) {} ngOnInit() { @@ -89,6 +90,7 @@ export class Fido2Component implements OnInit, OnDestroy { sessionId: queryParamMap.get("sessionId"), senderTabId: queryParamMap.get("senderTabId"), senderUrl: queryParamMap.get("senderUrl"), + fromLock: queryParamMap.get("fromLock"), })), ); @@ -101,6 +103,7 @@ export class Fido2Component implements OnInit, OnDestroy { this.sessionId = queryParams.sessionId; this.senderTabId = queryParams.senderTabId; this.url = queryParams.senderUrl; + this.fromLock = queryParams.fromLock === "true"; // For a 'NewSessionCreatedRequest', abort if it doesn't belong to the current session. if ( message.type === "NewSessionCreatedRequest" && @@ -210,7 +213,11 @@ export class Fido2Component implements OnInit, OnDestroy { protected async submit() { const data = this.message$.value; if (data?.type === "PickCredentialRequest") { - const userVerified = await this.handleUserVerification(data.userVerification, this.cipher); + const userVerified = await this.fido2UserVerificationService.handleUserVerification( + data.userVerification, + this.cipher, + this.fromLock, + ); this.send({ sessionId: this.sessionId, @@ -231,7 +238,11 @@ export class Fido2Component implements OnInit, OnDestroy { } } - const userVerified = await this.handleUserVerification(data.userVerification, this.cipher); + const userVerified = await this.fido2UserVerificationService.handleUserVerification( + data.userVerification, + this.cipher, + this.fromLock, + ); this.send({ sessionId: this.sessionId, @@ -248,14 +259,21 @@ export class Fido2Component implements OnInit, OnDestroy { const data = this.message$.value; if (data?.type === "ConfirmNewCredentialRequest") { const name = data.credentialName || data.rpId; - await this.createNewCipher(name); + const userVerified = await this.fido2UserVerificationService.handleUserVerification( + data.userVerification, + this.cipher, + this.fromLock, + ); + + if (!data.userVerification || userVerified) { + await this.createNewCipher(name); + } - // We are bypassing user verification pending implementation of PIN and biometric support. this.send({ sessionId: this.sessionId, cipherId: this.cipher?.id, type: "ConfirmNewCredentialResponse", - userVerified: data.userVerification, + userVerified, }); } @@ -304,6 +322,7 @@ export class Fido2Component implements OnInit, OnDestroy { uilocation: "popout", senderTabId: this.senderTabId, sessionId: this.sessionId, + fromLock: this.fromLock, userVerification: data.userVerification, singleActionPopout: `${VaultPopoutType.fido2Popout}_${this.sessionId}`, }, @@ -374,20 +393,6 @@ export class Fido2Component implements OnInit, OnDestroy { } } - private async handleUserVerification( - userVerificationRequested: boolean, - cipher: CipherView, - ): Promise { - const masterPasswordRepromptRequired = cipher && cipher.reprompt !== 0; - - if (masterPasswordRepromptRequired) { - return await this.passwordRepromptService.showPasswordPrompt(); - } - - // We are bypassing user verification pending implementation of PIN and biometric support. - return userVerificationRequested; - } - private send(msg: BrowserFido2Message) { BrowserFido2UserInterfaceSession.sendMessage({ sessionId: this.sessionId, diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts index a566b054c07..05255a3c011 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts @@ -30,6 +30,7 @@ import { BrowserApi } from "../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; import { PopupCloseWarningService } from "../../../../popup/services/popup-close-warning.service"; import { BrowserFido2UserInterfaceSession } from "../../../fido2/browser-fido2-user-interface.service"; +import { Fido2UserVerificationService } from "../../../services/fido2-user-verification.service"; import { fido2PopoutSessionData$ } from "../../utils/fido2-popout-session-data"; import { closeAddEditVaultItemPopout, VaultPopoutType } from "../../utils/vault-popout-window"; @@ -69,6 +70,7 @@ export class AddEditComponent extends BaseAddEditComponent { dialogService: DialogService, datePipe: DatePipe, configService: ConfigService, + private fido2UserVerificationService: Fido2UserVerificationService, ) { super( cipherService, @@ -168,11 +170,17 @@ export class AddEditComponent extends BaseAddEditComponent { async submit(): Promise { const fido2SessionData = await firstValueFrom(this.fido2PopoutSessionData$); - const { isFido2Session, sessionId, userVerification } = fido2SessionData; + const { isFido2Session, sessionId, userVerification, fromLock } = fido2SessionData; const inFido2PopoutWindow = BrowserPopupUtils.inPopout(window) && isFido2Session; + if ( inFido2PopoutWindow && - !(await this.handleFido2UserVerification(sessionId, userVerification)) + userVerification && + !(await this.fido2UserVerificationService.handleUserVerification( + userVerification, + this.cipher, + fromLock, + )) ) { return false; } @@ -327,14 +335,6 @@ export class AddEditComponent extends BaseAddEditComponent { }, 200); } - private async handleFido2UserVerification( - sessionId: string, - userVerification: boolean, - ): Promise { - // We are bypassing user verification pending implementation of PIN and biometric support. - return true; - } - repromptChanged() { super.repromptChanged(); diff --git a/apps/browser/src/vault/popup/utils/fido2-popout-session-data.ts b/apps/browser/src/vault/popup/utils/fido2-popout-session-data.ts index 9917b7411da..a4d95ff48f6 100644 --- a/apps/browser/src/vault/popup/utils/fido2-popout-session-data.ts +++ b/apps/browser/src/vault/popup/utils/fido2-popout-session-data.ts @@ -16,6 +16,7 @@ export function fido2PopoutSessionData$() { fallbackSupported: queryParams.fallbackSupported === "true", userVerification: queryParams.userVerification === "true", senderUrl: queryParams.senderUrl as string, + fromLock: queryParams.fromLock === "true", })), ); } diff --git a/apps/browser/src/vault/services/fido2-user-verification.service.spec.ts b/apps/browser/src/vault/services/fido2-user-verification.service.spec.ts new file mode 100644 index 00000000000..acee6ba20f7 --- /dev/null +++ b/apps/browser/src/vault/services/fido2-user-verification.service.spec.ts @@ -0,0 +1,248 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { SetPinComponent } from "./../../auth/popup/components/set-pin.component"; +import { Fido2UserVerificationService } from "./fido2-user-verification.service"; + +jest.mock("@bitwarden/auth/angular", () => ({ + UserVerificationDialogComponent: { + open: jest.fn().mockResolvedValue({ userAction: "confirm", verificationSuccess: true }), + }, +})); + +jest.mock("../../auth/popup/components/set-pin.component", () => { + return { + SetPinComponent: { + open: jest.fn(), + }, + }; +}); + +describe("Fido2UserVerificationService", () => { + let fido2UserVerificationService: Fido2UserVerificationService; + + let passwordRepromptService: MockProxy; + let userVerificationService: MockProxy; + let dialogService: MockProxy; + let cipher: CipherView; + + beforeEach(() => { + passwordRepromptService = mock(); + userVerificationService = mock(); + dialogService = mock(); + + cipher = createCipherView(); + + fido2UserVerificationService = new Fido2UserVerificationService( + passwordRepromptService, + userVerificationService, + dialogService, + ); + + (UserVerificationDialogComponent.open as jest.Mock).mockResolvedValue({ + userAction: "confirm", + verificationSuccess: true, + }); + }); + + describe("handleUserVerification", () => { + describe("user verification requested is true", () => { + it("should return true if user is redirected from lock screen and master password reprompt is not required", async () => { + const result = await fido2UserVerificationService.handleUserVerification( + true, + cipher, + true, + ); + expect(result).toBe(true); + }); + + it("should call master password reprompt dialog if user is redirected from lock screen, has master password and master password reprompt is required", async () => { + cipher.reprompt = CipherRepromptType.Password; + userVerificationService.hasMasterPassword.mockResolvedValue(true); + passwordRepromptService.showPasswordPrompt.mockResolvedValue(true); + + const result = await fido2UserVerificationService.handleUserVerification( + true, + cipher, + true, + ); + + expect(passwordRepromptService.showPasswordPrompt).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should call user verification dialog if user is redirected from lock screen, does not have a master password and master password reprompt is required", async () => { + cipher.reprompt = CipherRepromptType.Password; + userVerificationService.hasMasterPassword.mockResolvedValue(false); + + const result = await fido2UserVerificationService.handleUserVerification( + true, + cipher, + true, + ); + + expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, { + clientSideOnlyVerification: true, + }); + expect(result).toBe(true); + }); + + it("should call user verification dialog if user is not redirected from lock screen, does not have a master password and master password reprompt is required", async () => { + cipher.reprompt = CipherRepromptType.Password; + userVerificationService.hasMasterPassword.mockResolvedValue(false); + + const result = await fido2UserVerificationService.handleUserVerification( + true, + cipher, + false, + ); + + expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, { + clientSideOnlyVerification: true, + }); + expect(result).toBe(true); + }); + + it("should call master password reprompt dialog if user is not redirected from lock screen, has a master password and master password reprompt is required", async () => { + cipher.reprompt = CipherRepromptType.Password; + userVerificationService.hasMasterPassword.mockResolvedValue(false); + passwordRepromptService.showPasswordPrompt.mockResolvedValue(true); + + const result = await fido2UserVerificationService.handleUserVerification( + true, + cipher, + false, + ); + + expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, { + clientSideOnlyVerification: true, + }); + expect(result).toBe(true); + }); + + it("should call user verification dialog if user is not redirected from lock screen and no master password reprompt is required", async () => { + const result = await fido2UserVerificationService.handleUserVerification( + true, + cipher, + false, + ); + + expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, { + clientSideOnlyVerification: true, + }); + expect(result).toBe(true); + }); + + it("should prompt user to set pin if user has no verification method", async () => { + (UserVerificationDialogComponent.open as jest.Mock).mockResolvedValue({ + userAction: "confirm", + verificationSuccess: false, + noAvailableClientVerificationMethods: true, + }); + + await fido2UserVerificationService.handleUserVerification(true, cipher, false); + + expect(SetPinComponent.open).toHaveBeenCalledWith(dialogService); + }); + }); + + describe("user verification requested is false", () => { + it("should return false if user is redirected from lock screen and master password reprompt is not required", async () => { + const result = await fido2UserVerificationService.handleUserVerification( + false, + cipher, + true, + ); + expect(result).toBe(false); + }); + + it("should return false if user is not redirected from lock screen and master password reprompt is not required", async () => { + const result = await fido2UserVerificationService.handleUserVerification( + false, + cipher, + false, + ); + expect(result).toBe(false); + }); + + it("should call master password reprompt dialog if user is redirected from lock screen, has master password and master password reprompt is required", async () => { + cipher.reprompt = CipherRepromptType.Password; + userVerificationService.hasMasterPassword.mockResolvedValue(true); + passwordRepromptService.showPasswordPrompt.mockResolvedValue(true); + + const result = await fido2UserVerificationService.handleUserVerification( + false, + cipher, + true, + ); + + expect(result).toBe(true); + expect(passwordRepromptService.showPasswordPrompt).toHaveBeenCalled(); + }); + + it("should call user verification dialog if user is redirected from lock screen, does not have a master password and master password reprompt is required", async () => { + cipher.reprompt = CipherRepromptType.Password; + userVerificationService.hasMasterPassword.mockResolvedValue(false); + + const result = await fido2UserVerificationService.handleUserVerification( + false, + cipher, + true, + ); + + expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, { + clientSideOnlyVerification: true, + }); + expect(result).toBe(true); + }); + + it("should call user verification dialog if user is not redirected from lock screen, does not have a master password and master password reprompt is required", async () => { + cipher.reprompt = CipherRepromptType.Password; + userVerificationService.hasMasterPassword.mockResolvedValue(false); + + const result = await fido2UserVerificationService.handleUserVerification( + false, + cipher, + false, + ); + + expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, { + clientSideOnlyVerification: true, + }); + expect(result).toBe(true); + }); + + it("should call master password reprompt dialog if user is not redirected from lock screen, has a master password and master password reprompt is required", async () => { + cipher.reprompt = CipherRepromptType.Password; + userVerificationService.hasMasterPassword.mockResolvedValue(false); + passwordRepromptService.showPasswordPrompt.mockResolvedValue(true); + + const result = await fido2UserVerificationService.handleUserVerification( + false, + cipher, + false, + ); + + expect(UserVerificationDialogComponent.open).toHaveBeenCalledWith(dialogService, { + clientSideOnlyVerification: true, + }); + expect(result).toBe(true); + }); + }); + }); +}); + +function createCipherView() { + const cipher = new CipherView(); + cipher.id = Utils.newGuid(); + cipher.type = CipherType.Login; + cipher.reprompt = CipherRepromptType.None; + return cipher; +} diff --git a/apps/browser/src/vault/services/fido2-user-verification.service.ts b/apps/browser/src/vault/services/fido2-user-verification.service.ts new file mode 100644 index 00000000000..90c4d8ca610 --- /dev/null +++ b/apps/browser/src/vault/services/fido2-user-verification.service.ts @@ -0,0 +1,101 @@ +import { firstValueFrom } from "rxjs"; + +import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { SetPinComponent } from "../../auth/popup/components/set-pin.component"; + +export class Fido2UserVerificationService { + constructor( + private passwordRepromptService: PasswordRepromptService, + private userVerificationService: UserVerificationService, + private dialogService: DialogService, + ) {} + + /** + * Handles user verification for a user based on the cipher and user verification requested. + * @param userVerificationRequested Indicates if user verification is required or not. + * @param cipher Contains details about the cipher including master password reprompt. + * @param fromLock Indicates if the request is from the lock screen. + * @returns + */ + async handleUserVerification( + userVerificationRequested: boolean, + cipher: CipherView, + fromLock: boolean, + ): Promise { + const masterPasswordRepromptRequired = cipher && cipher.reprompt !== 0; + + // If the request is from the lock screen, treat unlocking the vault as user verification, + // unless a master password reprompt is required. + if (fromLock) { + return masterPasswordRepromptRequired + ? await this.handleMasterPasswordReprompt() + : userVerificationRequested; + } + + if (masterPasswordRepromptRequired) { + return await this.handleMasterPasswordReprompt(); + } + + if (userVerificationRequested) { + return await this.showUserVerificationDialog(); + } + + return userVerificationRequested; + } + + private async showMasterPasswordReprompt(): Promise { + return await this.passwordRepromptService.showPasswordPrompt(); + } + + private async showUserVerificationDialog(): Promise { + const result = await UserVerificationDialogComponent.open(this.dialogService, { + clientSideOnlyVerification: true, + }); + + if (result.userAction === "cancel") { + return; + } + + // Handle unsuccessful verification attempts. + if (!result.verificationSuccess) { + // Check if no client-side verification methods are available. + if (result.noAvailableClientVerificationMethods) { + return await this.promptUserToSetPin(); + } + return; + } + + return result.verificationSuccess; + } + + private async handleMasterPasswordReprompt(): Promise { + const hasMasterPassword = await this.userVerificationService.hasMasterPassword(); + + // TDE users have no master password, so we need to use the UserVerification prompt + return hasMasterPassword + ? await this.showMasterPasswordReprompt() + : await this.showUserVerificationDialog(); + } + + private async promptUserToSetPin() { + const dialogRef = SetPinComponent.open(this.dialogService); + + if (!dialogRef) { + return; + } + + const userHasPinSet = await firstValueFrom(dialogRef.closed); + + if (!userHasPinSet) { + return; + } + + // If the user has set a PIN, re-invoke the user verification dialog to complete the verification process. + return await this.showUserVerificationDialog(); + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts index fcdbe1e4962..c029d2ecdbe 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts @@ -165,23 +165,4 @@ export class OrganizationUserResetPasswordService { } return requests; } - - /** - * @deprecated Nov 6, 2023: Use new Key Rotation Service for posting rotated data. - */ - async postLegacyRotation( - userId: string, - requests: OrganizationUserResetPasswordWithIdRequest[], - ): Promise { - if (requests == null) { - return; - } - for (const request of requests) { - await this.organizationUserService.putOrganizationUserResetPasswordEnrollment( - request.organizationId, - userId, - request, - ); - } - } } diff --git a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts index 8cf7ed313fe..c95ff754c45 100644 --- a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts @@ -1,6 +1,6 @@ import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { concatMap, takeUntil, map, lastValueFrom } from "rxjs"; +import { concatMap, takeUntil, map } from "rxjs"; import { tap } from "rxjs/operators"; import { ModalService } from "@bitwarden/angular/services/modal.service"; @@ -16,7 +16,6 @@ import { DialogService } from "@bitwarden/components"; import { TwoFactorDuoComponent } from "../../../auth/settings/two-factor-duo.component"; import { TwoFactorSetupComponent as BaseTwoFactorSetupComponent } from "../../../auth/settings/two-factor-setup.component"; -import { TwoFactorVerifyComponent } from "../../../auth/settings/two-factor-verify.component"; @Component({ selector: "app-two-factor-setup", @@ -66,17 +65,17 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent { async manage(type: TwoFactorProviderType) { switch (type) { case TwoFactorProviderType.OrganizationDuo: { - const twoFactorVerifyDialogRef = TwoFactorVerifyComponent.open(this.dialogService, { - data: { type: type, organizationId: this.organizationId }, - }); - const result: AuthResponse = await lastValueFrom( - twoFactorVerifyDialogRef.closed, + const result: AuthResponse = await this.callTwoFactorVerifyDialog( + TwoFactorProviderType.OrganizationDuo, ); + if (!result) { return; } const duoComp = await this.openModal(this.duoModalRef, TwoFactorDuoComponent); + duoComp.type = TwoFactorProviderType.OrganizationDuo; + duoComp.organizationId = this.organizationId; duoComp.auth(result); duoComp.onUpdated.pipe(takeUntil(this.destroy$)).subscribe((enabled: boolean) => { this.updateStatus(enabled, TwoFactorProviderType.OrganizationDuo); diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 6e2761a9c4e..7d2b6d999aa 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -2,7 +2,17 @@ import { DOCUMENT } from "@angular/common"; import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core"; import { NavigationEnd, Router } from "@angular/router"; import * as jq from "jquery"; -import { Subject, filter, firstValueFrom, map, switchMap, takeUntil, timeout, timer } from "rxjs"; +import { + Subject, + combineLatest, + filter, + firstValueFrom, + map, + switchMap, + takeUntil, + timeout, + timer, +} from "rxjs"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; @@ -15,6 +25,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -241,8 +252,12 @@ export class AppComponent implements OnDestroy, OnInit { new SendOptionsPolicy(), ]); - this.paymentMethodWarningsRefresh$ + combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.ShowPaymentMethodWarningBanners), + this.paymentMethodWarningsRefresh$, + ]) .pipe( + filter(([showPaymentMethodWarningBanners]) => showPaymentMethodWarningBanners), switchMap(() => this.organizationService.memberOrganizations$), switchMap( async (organizations) => diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts index 819b80c1ad7..a50a5adc6cb 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts @@ -328,16 +328,4 @@ export class EmergencyAccessService { private async encryptKey(userKey: UserKey, publicKey: Uint8Array): Promise { return (await this.cryptoService.rsaEncrypt(userKey.key, publicKey)).encryptedString; } - - /** - * @deprecated Nov 6, 2023: Use new Key Rotation Service for posting rotated data. - */ - async postLegacyRotation(requests: EmergencyAccessWithIdRequest[]): Promise { - if (requests == null) { - return; - } - for (const request of requests) { - await this.emergencyAccessApiService.putEmergencyAccess(request.id, request); - } - } } diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts index ec685569318..792ae15690f 100644 --- a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts @@ -82,7 +82,6 @@ describe("KeyRotationService", () => { mockEncryptService, mockStateService, mockAccountService, - mockConfigService, mockKdfConfigService, ); }); @@ -191,16 +190,6 @@ describe("KeyRotationService", () => { ); }); - it("uses legacy rotation if feature flag is off", async () => { - mockConfigService.getFeatureFlag.mockResolvedValueOnce(false); - - await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"); - - expect(mockApiService.postUserKeyUpdate).toHaveBeenCalled(); - expect(mockEmergencyAccessService.postLegacyRotation).toHaveBeenCalled(); - expect(mockResetPasswordService.postLegacyRotation).toHaveBeenCalled(); - }); - it("throws if server rotation fails", async () => { mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError")); diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts index dc5f9337247..2763de71b37 100644 --- a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts @@ -5,8 +5,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @@ -39,7 +37,6 @@ export class UserKeyRotationService { private encryptService: EncryptService, private stateService: StateService, private accountService: AccountService, - private configService: ConfigService, private kdfConfigService: KdfConfigService, ) {} @@ -90,11 +87,7 @@ export class UserKeyRotationService { request.emergencyAccessKeys = await this.emergencyAccessService.getRotatedKeys(newUserKey); request.resetPasswordKeys = await this.resetPasswordService.getRotatedKeys(newUserKey); - if (await this.configService.getFeatureFlag(FeatureFlag.KeyRotationImprovements)) { - await this.apiService.postUserKeyUpdate(request); - } else { - await this.rotateUserKeyAndEncryptedDataLegacy(request); - } + await this.apiService.postUserKeyUpdate(request); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); await this.deviceTrustService.rotateDevicesTrust( @@ -139,16 +132,4 @@ export class UserKeyRotationService { }), ); } - - private async rotateUserKeyAndEncryptedDataLegacy(request: UpdateKeyRequest): Promise { - // Update keys, ciphers, folders, and sends - await this.apiService.postUserKeyUpdate(request); - - // Update emergency access keys - await this.emergencyAccessService.postLegacyRotation(request.emergencyAccessKeys); - - // Update account recovery keys - const userId = await this.stateService.getUserId(); - await this.resetPasswordService.postLegacyRotation(userId, request.resetPasswordKeys); - } } diff --git a/apps/web/src/app/billing/individual/premium.component.html b/apps/web/src/app/billing/individual/premium.component.html index f962f7cfe13..e3afa7779b8 100644 --- a/apps/web/src/app/billing/individual/premium.component.html +++ b/apps/web/src/app/billing/individual/premium.component.html @@ -1,129 +1,143 @@ - -
-

{{ "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 - }} -

- +

{{ "goPremium" | i18n }}

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

{{ "uploadLicenseFilePremium" | i18n }}

- -
- - - {{ - "licenseFileDesc" | i18n: "bitwarden_premium_license.json" - }} -
- + {{ this.licenseFile ? this.licenseFile.name : ("noFileChosen" | i18n) }} +
+ + {{ "licenseFileDesc" | i18n: "bitwarden_premium_license.json" }} + + - -
-

{{ "addons" | i18n }}

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

{{ "addons" | i18n }}

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

{{ "summary" | i18n }}

- {{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }}
- {{ "additionalStorageGb" | i18n }}: {{ additionalStorage || 0 }} GB × - {{ storageGbPrice | currency: "$" }} = - {{ additionalStorageTotal | currency: "$" }} -
-

{{ "paymentInformation" | i18n }}

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

{{ "summary" | i18n }}

+ {{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }}
+ {{ "additionalStorageGb" | i18n }}: {{ additionalStorage || 0 }} GB × + {{ storageGbPrice | currency: "$" }} = + {{ additionalStorageTotal | currency: "$" }} +
+
+ +

{{ "paymentInformation" | i18n }}

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

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

-
-

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

-
- {{ "paymentChargedAnnually" | i18n }} - +

{{ "paymentChargedAnnually" | i18n }}

+ + diff --git a/apps/web/src/app/billing/individual/premium.component.ts b/apps/web/src/app/billing/individual/premium.component.ts index 60536c17b50..79a8bad75ae 100644 --- a/apps/web/src/app/billing/individual/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium.component.ts @@ -1,4 +1,5 @@ import { Component, OnInit, ViewChild } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; import { Router } from "@angular/router"; import { firstValueFrom, Observable } from "rxjs"; @@ -7,7 +8,6 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service" import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; @@ -26,11 +26,16 @@ export class PremiumComponent implements OnInit { premiumPrice = 10; familyPlanMaxUserCount = 6; storageGbPrice = 4; - additionalStorage = 0; cloudWebVaultUrl: string; + licenseFile: File = null; formPromise: Promise; - + protected licenseForm = new FormGroup({ + file: new FormControl(null, [Validators.required]), + }); + protected addonForm = new FormGroup({ + additionalStorage: new FormControl(0, [Validators.max(99), Validators.min(0)]), + }); constructor( private apiService: ApiService, private i18nService: I18nService, @@ -39,14 +44,17 @@ export class PremiumComponent implements OnInit { private router: Router, private messagingService: MessagingService, private syncService: SyncService, - private logService: LogService, private environmentService: EnvironmentService, private billingAccountProfileStateService: BillingAccountProfileStateService, ) { this.selfHosted = platformUtilsService.isSelfHost(); this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$; } - + protected setSelectedFile(event: Event) { + const fileInputEl = event.target; + const file: File = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null; + this.licenseFile = file; + } async ngOnInit() { this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$); if (await firstValueFrom(this.billingAccountProfileStateService.hasPremiumPersonally$)) { @@ -56,13 +64,11 @@ export class PremiumComponent implements OnInit { return; } } - - async submit() { - let files: FileList = null; + submit = async () => { + this.licenseForm.markAllAsTouched(); + this.addonForm.markAllAsTouched(); if (this.selfHosted) { - const fileEl = document.getElementById("file") as HTMLInputElement; - files = fileEl.files; - if (files == null || files.length === 0) { + if (this.licenseFile == null) { this.platformUtilsService.showToast( "error", this.i18nService.t("errorOccurred"), @@ -72,53 +78,48 @@ export class PremiumComponent implements OnInit { } } - try { - if (this.selfHosted) { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - if (!this.tokenService.getEmailVerified()) { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("errorOccurred"), - this.i18nService.t("verifyEmailFirst"), - ); - return; - } - - const fd = new FormData(); - fd.append("license", files[0]); - this.formPromise = this.apiService.postAccountLicense(fd).then(() => { - return this.finalizePremium(); - }); - } else { - this.formPromise = this.paymentComponent - .createPaymentToken() - .then((result) => { - const fd = new FormData(); - fd.append("paymentMethodType", result[1].toString()); - if (result[0] != null) { - fd.append("paymentToken", result[0]); - } - fd.append("additionalStorageGb", (this.additionalStorage || 0).toString()); - fd.append("country", this.taxInfoComponent.taxInfo.country); - fd.append("postalCode", this.taxInfoComponent.taxInfo.postalCode); - return this.apiService.postPremium(fd); - }) - .then((paymentResponse) => { - if (!paymentResponse.success && paymentResponse.paymentIntentClientSecret != null) { - return this.paymentComponent.handleStripeCardPayment( - paymentResponse.paymentIntentClientSecret, - () => this.finalizePremium(), - ); - } else { - return this.finalizePremium(); - } - }); + if (this.selfHosted) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + if (!this.tokenService.getEmailVerified()) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("verifyEmailFirst"), + ); + return; } - await this.formPromise; - } catch (e) { - this.logService.error(e); + + const fd = new FormData(); + fd.append("license", this.licenseFile); + await this.apiService.postAccountLicense(fd).then(() => { + return this.finalizePremium(); + }); + } else { + await this.paymentComponent + .createPaymentToken() + .then((result) => { + const fd = new FormData(); + fd.append("paymentMethodType", result[1].toString()); + if (result[0] != null) { + fd.append("paymentToken", result[0]); + } + fd.append("additionalStorageGb", (this.additionalStorage || 0).toString()); + fd.append("country", this.taxInfoComponent.taxInfo.country); + fd.append("postalCode", this.taxInfoComponent.taxInfo.postalCode); + return this.apiService.postPremium(fd); + }) + .then((paymentResponse) => { + if (!paymentResponse.success && paymentResponse.paymentIntentClientSecret != null) { + return this.paymentComponent.handleStripeCardPayment( + paymentResponse.paymentIntentClientSecret, + () => this.finalizePremium(), + ); + } else { + return this.finalizePremium(); + } + }); } - } + }; async finalizePremium() { await this.apiService.refreshIdentityToken(); @@ -127,6 +128,9 @@ export class PremiumComponent implements OnInit { await this.router.navigate(["/settings/subscription/user-subscription"]); } + get additionalStorage(): number { + return this.addonForm.get("additionalStorage").value; + } get additionalStorageTotal(): number { return this.storageGbPrice * Math.abs(this.additionalStorage || 0); } diff --git a/apps/web/src/app/billing/organizations/adjust-subscription.component.html b/apps/web/src/app/billing/organizations/adjust-subscription.component.html index f0200da6384..9fe8d205407 100644 --- a/apps/web/src/app/billing/organizations/adjust-subscription.component.html +++ b/apps/web/src/app/billing/organizations/adjust-subscription.component.html @@ -1,65 +1,57 @@ -
-
-
-
- - - + +
+
+ + {{ "subscriptionSeats" | i18n }} + + {{ "total" | i18n }}: {{ additionalSeatCount || 0 }} × {{ seatPrice | currency: "$" }} = {{ adjustedSeatTotal | currency: "$" }} / - {{ interval | i18n }} - -
+ {{ interval | i18n }} +
-
-
-
- - -
- {{ "limitSubscriptionDesc" | i18n }} -
-
-
-
- +
+
+ + + {{ "limitSubscription" | i18n }} + {{ "limitSubscriptionDesc" | i18n }} + +
+
+
+ + {{ "maxSeatLimit" | i18n }} - + {{ "maxSeatCost" | i18n }}: {{ additionalMaxSeatCount || 0 }} × {{ seatPrice | currency: "$" }} = {{ maxSeatTotal | currency: "$" }} / - {{ interval | i18n }} - -
+ {{ interval | i18n }} +
-
+ diff --git a/apps/web/src/app/billing/organizations/adjust-subscription.component.ts b/apps/web/src/app/billing/organizations/adjust-subscription.component.ts index 4290a1281b0..b843c79cb95 100644 --- a/apps/web/src/app/billing/organizations/adjust-subscription.component.ts +++ b/apps/web/src/app/billing/organizations/adjust-subscription.component.ts @@ -1,77 +1,102 @@ -import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { Subject, takeUntil } from "rxjs"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationSubscriptionUpdateRequest } from "@bitwarden/common/billing/models/request/organization-subscription-update.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @Component({ selector: "app-adjust-subscription", templateUrl: "adjust-subscription.component.html", }) -export class AdjustSubscription { +export class AdjustSubscription implements OnInit, OnDestroy { @Input() organizationId: string; @Input() maxAutoscaleSeats: number; @Input() currentSeatCount: number; @Input() seatPrice = 0; @Input() interval = "year"; @Output() onAdjusted = new EventEmitter(); + private destroy$ = new Subject(); - formPromise: Promise; - limitSubscription: boolean; - newSeatCount: number; - newMaxSeats: number; - + adjustSubscriptionForm = this.formBuilder.group({ + newSeatCount: [0, [Validators.min(0)]], + limitSubscription: [false], + newMaxSeats: [0, [Validators.min(0)]], + }); + get limitSubscription(): boolean { + return this.adjustSubscriptionForm.value.limitSubscription; + } constructor( private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, - private logService: LogService, private organizationApiService: OrganizationApiServiceAbstraction, + private formBuilder: FormBuilder, ) {} ngOnInit() { - this.limitSubscription = this.maxAutoscaleSeats != null; - this.newSeatCount = this.currentSeatCount; - this.newMaxSeats = this.maxAutoscaleSeats; + this.adjustSubscriptionForm.patchValue({ + newSeatCount: this.currentSeatCount, + limitSubscription: this.maxAutoscaleSeats != null, + newMaxSeats: this.maxAutoscaleSeats, + }); + this.adjustSubscriptionForm + .get("limitSubscription") + .valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe((value: boolean) => { + if (value) { + this.adjustSubscriptionForm + .get("newMaxSeats") + .addValidators([ + Validators.min( + this.adjustSubscriptionForm.value.newSeatCount == null + ? 1 + : this.adjustSubscriptionForm.value.newSeatCount, + ), + Validators.required, + ]); + } + this.adjustSubscriptionForm.get("newMaxSeats").updateValueAndValidity(); + }); } - async submit() { - try { - const request = new OrganizationSubscriptionUpdateRequest( - this.additionalSeatCount, - this.newMaxSeats, - ); - this.formPromise = this.organizationApiService.updatePasswordManagerSeats( - this.organizationId, - request, - ); - - await this.formPromise; - - this.platformUtilsService.showToast( - "success", - null, - this.i18nService.t("subscriptionUpdated"), - ); - } catch (e) { - this.logService.error(e); + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + submit = async () => { + this.adjustSubscriptionForm.markAllAsTouched(); + if (this.adjustSubscriptionForm.invalid) { + return; } + const request = new OrganizationSubscriptionUpdateRequest( + this.additionalSeatCount, + this.adjustSubscriptionForm.value.newMaxSeats, + ); + await this.organizationApiService.updatePasswordManagerSeats(this.organizationId, request); + + this.platformUtilsService.showToast("success", null, this.i18nService.t("subscriptionUpdated")); + this.onAdjusted.emit(); - } + }; limitSubscriptionChanged() { - if (!this.limitSubscription) { - this.newMaxSeats = null; + if (!this.adjustSubscriptionForm.value.limitSubscription) { + this.adjustSubscriptionForm.value.newMaxSeats = null; } } get additionalSeatCount(): number { - return this.newSeatCount ? this.newSeatCount - this.currentSeatCount : 0; + return this.adjustSubscriptionForm.value.newSeatCount + ? this.adjustSubscriptionForm.value.newSeatCount - this.currentSeatCount + : 0; } get additionalMaxSeatCount(): number { - return this.newMaxSeats ? this.newMaxSeats - this.currentSeatCount : 0; + return this.adjustSubscriptionForm.value.newMaxSeats + ? this.adjustSubscriptionForm.value.newMaxSeats - this.currentSeatCount + : 0; } get adjustedSeatTotal(): number { diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index a77d42a3594..3c8ef2c9337 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -1,400 +1,428 @@ - {{ "loading" | i18n }} + {{ "loading" | i18n }} -

{{ "uploadLicenseFileOrg" | i18n }}

-
-
- - - {{ - "licenseFileDesc" | i18n: "bitwarden_organization_license.json" - }} -
- + {{ selectedFile?.name ?? ("noFileChosen" | i18n) }} +
+ + {{ "licenseFileDesc" | i18n: "bitwarden_organization_license.json" }} + +
- -

{{ "chooseYourPlan" | i18n }}

-
- -