diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 0dc93dd0b32..5200cf81d09 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3287,9 +3287,18 @@ "opensInANewWindow": { "message": "Opens in a new window" }, + "rememberThisDeviceToMakeFutureLoginsSeamless": { + "message": "Remember this device to make future logins seamless" + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, + "deviceApprovalRequiredV2": { + "message": "Device approval required" + }, + "selectAnApprovalOptionBelow": { + "message": "Select an approval option below" + }, "rememberThisDevice": { "message": "Remember this device" }, @@ -3363,6 +3372,9 @@ "userEmailMissing": { "message": "User email missing" }, + "activeUserEmailNotFoundLoggingYouOut": { + "message": "Active user email not found. Logging you out." + }, "deviceTrusted": { "message": "Device trusted" }, @@ -3799,6 +3811,9 @@ "accessing": { "message": "Accessing" }, + "loggedInExclamation": { + "message": "Logged in!" + }, "passkeyNotCopied": { "message": "Passkey will not be copied" }, diff --git a/apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.spec.ts b/apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.spec.ts new file mode 100644 index 00000000000..8f3199cdfce --- /dev/null +++ b/apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.spec.ts @@ -0,0 +1,64 @@ +import { Router } from "@angular/router"; +import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { postLogoutMessageListener$ } from "../utils/post-logout-message-listener"; + +import { ExtensionLoginDecryptionOptionsService } from "./extension-login-decryption-options.service"; + +// Mock the module providing postLogoutMessageListener$ +jest.mock("../utils/post-logout-message-listener", () => { + return { + postLogoutMessageListener$: new BehaviorSubject(""), // Replace with mock subject + }; +}); + +describe("ExtensionLoginDecryptionOptionsService", () => { + let service: ExtensionLoginDecryptionOptionsService; + + let messagingService: MockProxy; + let router: MockProxy; + let postLogoutMessageSubject: BehaviorSubject; + + beforeEach(() => { + messagingService = mock(); + router = mock(); + + // Cast postLogoutMessageListener$ to BehaviorSubject for dynamic control + postLogoutMessageSubject = postLogoutMessageListener$ as BehaviorSubject; + + service = new ExtensionLoginDecryptionOptionsService(messagingService, router); + }); + + it("should instantiate the service", () => { + expect(service).not.toBeFalsy(); + }); + + describe("logOut()", () => { + it("should send a logout message", async () => { + postLogoutMessageSubject.next("switchAccountFinish"); + + await service.logOut(); + + expect(messagingService.send).toHaveBeenCalledWith("logout"); + }); + + it("should navigate to root on 'switchAccountFinish'", async () => { + postLogoutMessageSubject.next("switchAccountFinish"); + + await service.logOut(); + + expect(router.navigate).toHaveBeenCalledWith(["/"]); + }); + + it("should not navigate for 'doneLoggingOut'", async () => { + postLogoutMessageSubject.next("doneLoggingOut"); + + await service.logOut(); + + expect(router.navigate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.ts b/apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.ts new file mode 100644 index 00000000000..ea529e277e6 --- /dev/null +++ b/apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.ts @@ -0,0 +1,37 @@ +import { Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { + DefaultLoginDecryptionOptionsService, + LoginDecryptionOptionsService, +} from "@bitwarden/auth/angular"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { postLogoutMessageListener$ } from "../utils/post-logout-message-listener"; + +export class ExtensionLoginDecryptionOptionsService + extends DefaultLoginDecryptionOptionsService + implements LoginDecryptionOptionsService +{ + constructor( + protected messagingService: MessagingService, + private router: Router, + ) { + super(messagingService); + } + + override async logOut(): Promise { + // start listening for "switchAccountFinish" or "doneLoggingOut" + const messagePromise = firstValueFrom(postLogoutMessageListener$); + + super.logOut(); + + // wait for messages + const command = await messagePromise; + + // doneLoggingOut already has a message handler that will navigate us + if (command === "switchAccountFinish") { + await this.router.navigate(["/"]); + } + } +} diff --git a/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.html b/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options-v1.component.html similarity index 100% rename from apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.html rename to apps/browser/src/auth/popup/login-decryption-options/login-decryption-options-v1.component.html diff --git a/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.ts b/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options-v1.component.ts similarity index 80% rename from apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.ts rename to apps/browser/src/auth/popup/login-decryption-options/login-decryption-options-v1.component.ts index 6231b027749..bd8f808c910 100644 --- a/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.ts +++ b/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options-v1.component.ts @@ -1,15 +1,15 @@ import { Component } from "@angular/core"; import { firstValueFrom } from "rxjs"; -import { BaseLoginDecryptionOptionsComponent } from "@bitwarden/angular/auth/components/base-login-decryption-options.component"; +import { BaseLoginDecryptionOptionsComponentV1 } from "@bitwarden/angular/auth/components/base-login-decryption-options-v1.component"; import { postLogoutMessageListener$ } from "../utils/post-logout-message-listener"; @Component({ selector: "browser-login-decryption-options", - templateUrl: "login-decryption-options.component.html", + templateUrl: "login-decryption-options-v1.component.html", }) -export class LoginDecryptionOptionsComponent extends BaseLoginDecryptionOptionsComponent { +export class LoginDecryptionOptionsComponentV1 extends BaseLoginDecryptionOptionsComponentV1 { override async createUser(): Promise { try { await super.createUser(); diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index ba8ab1e7aaf..5e6f38e80b0 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -21,7 +21,6 @@ import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, - DevicesIcon, LoginComponent, LoginSecondaryContentComponent, LockIcon, @@ -37,6 +36,8 @@ import { SetPasswordJitComponent, UserLockIcon, VaultIcon, + LoginDecryptionOptionsComponent, + DevicesIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -51,7 +52,7 @@ import { import { HintComponent } from "../auth/popup/hint.component"; import { HomeComponent } from "../auth/popup/home.component"; import { LockComponent } from "../auth/popup/lock.component"; -import { LoginDecryptionOptionsComponent } from "../auth/popup/login-decryption-options/login-decryption-options.component"; +import { LoginDecryptionOptionsComponentV1 } from "../auth/popup/login-decryption-options/login-decryption-options-v1.component"; import { LoginComponentV1 } from "../auth/popup/login-v1.component"; import { LoginViaAuthRequestComponentV1 } from "../auth/popup/login-via-auth-request-v1.component"; import { RegisterComponent } from "../auth/popup/register.component"; @@ -206,12 +207,6 @@ const routes: Routes = [ canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { state: "2fa-options" } satisfies RouteDataProperties, }, - { - path: "login-initiated", - component: LoginDecryptionOptionsComponent, - canActivate: [tdeDecryptionRequiredGuard()], - data: { state: "login-initiated" } satisfies RouteDataProperties, - }, { path: "sso", component: SsoComponent, @@ -534,6 +529,23 @@ const routes: Routes = [ ], }, ), + ...unauthUiRefreshSwap( + LoginDecryptionOptionsComponentV1, + ExtensionAnonLayoutWrapperComponent, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + data: { state: "login-initiated" } satisfies RouteDataProperties, + }, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + data: { + pageIcon: DevicesIcon, + }, + children: [{ path: "", component: LoginDecryptionOptionsComponent }], + }, + ), { path: "", component: ExtensionAnonLayoutWrapperComponent, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index d6e46de6ba0..d637f695e81 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -24,7 +24,7 @@ import { ExtensionAnonLayoutWrapperComponent } from "../auth/popup/extension-ano import { HintComponent } from "../auth/popup/hint.component"; import { HomeComponent } from "../auth/popup/home.component"; import { LockComponent } from "../auth/popup/lock.component"; -import { LoginDecryptionOptionsComponent } from "../auth/popup/login-decryption-options/login-decryption-options.component"; +import { LoginDecryptionOptionsComponentV1 } from "../auth/popup/login-decryption-options/login-decryption-options-v1.component"; import { LoginComponentV1 } from "../auth/popup/login-v1.component"; import { LoginViaAuthRequestComponentV1 } from "../auth/popup/login-via-auth-request-v1.component"; import { RegisterComponent } from "../auth/popup/register.component"; @@ -161,7 +161,7 @@ import "../platform/popup/locales"; LockComponent, LoginViaAuthRequestComponentV1, LoginComponentV1, - LoginDecryptionOptionsComponent, + LoginDecryptionOptionsComponentV1, NotificationsSettingsV1Component, AppearanceComponent, GeneratorComponent, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 9a1acb54ab7..b68102033bb 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -1,4 +1,5 @@ import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core"; +import { Router } from "@angular/router"; import { Subject, merge, of } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; @@ -22,6 +23,7 @@ import { AnonLayoutWrapperDataService, LoginComponentService, LockComponentService, + LoginDecryptionOptionsService, } from "@bitwarden/auth/angular"; import { LockService, LoginEmailService, PinServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -115,6 +117,7 @@ import { PasswordRepromptService } from "@bitwarden/vault"; import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service"; import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; import { ExtensionLoginComponentService } from "../../auth/popup/login/extension-login-component.service"; +import { ExtensionLoginDecryptionOptionsService } from "../../auth/popup/login-decryption-options/extension-login-decryption-options.service"; import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service"; import AutofillService from "../../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../../autofill/services/inline-menu-field-qualification.service"; @@ -591,6 +594,11 @@ const safeProviders: SafeProvider[] = [ useExisting: PopupCompactModeService, deps: [], }), + safeProvider({ + provide: LoginDecryptionOptionsService, + useClass: ExtensionLoginDecryptionOptionsService, + deps: [MessagingServiceAbstraction, Router], + }), ]; @NgModule({ diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index c1e4fd18692..e61335859c4 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -18,7 +18,6 @@ import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-ref import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, - DevicesIcon, LoginComponent, LoginSecondaryContentComponent, LockIcon, @@ -34,6 +33,8 @@ import { SetPasswordJitComponent, UserLockIcon, VaultIcon, + LoginDecryptionOptionsComponent, + DevicesIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -42,7 +43,7 @@ import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.compo import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; import { HintComponent } from "../auth/hint.component"; import { LockComponent } from "../auth/lock.component"; -import { LoginDecryptionOptionsComponent } from "../auth/login/login-decryption-options/login-decryption-options.component"; +import { LoginDecryptionOptionsComponentV1 } from "../auth/login/login-decryption-options/login-decryption-options-v1.component"; import { LoginComponentV1 } from "../auth/login/login-v1.component"; import { LoginViaAuthRequestComponentV1 } from "../auth/login/login-via-auth-request-v1.component"; import { RegisterComponent } from "../auth/register.component"; @@ -95,11 +96,6 @@ const routes: Routes = [ ], }, ), - { - path: "login-initiated", - component: LoginDecryptionOptionsComponent, - canActivate: [tdeDecryptionRequiredGuard()], - }, { path: "register", component: RegisterComponent }, { path: "vault", @@ -241,6 +237,22 @@ const routes: Routes = [ ], }, ), + ...unauthUiRefreshSwap( + LoginDecryptionOptionsComponentV1, + AnonLayoutWrapperComponent, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + }, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + data: { + pageIcon: DevicesIcon, + }, + children: [{ path: "", component: LoginDecryptionOptionsComponent }], + }, + ), { path: "", component: AnonLayoutWrapperComponent, diff --git a/apps/desktop/src/auth/login/login-decryption-options/login-decryption-options.component.html b/apps/desktop/src/auth/login/login-decryption-options/login-decryption-options-v1.component.html similarity index 100% rename from apps/desktop/src/auth/login/login-decryption-options/login-decryption-options.component.html rename to apps/desktop/src/auth/login/login-decryption-options/login-decryption-options-v1.component.html diff --git a/apps/desktop/src/auth/login/login-decryption-options/login-decryption-options.component.ts b/apps/desktop/src/auth/login/login-decryption-options/login-decryption-options-v1.component.ts similarity index 56% rename from apps/desktop/src/auth/login/login-decryption-options/login-decryption-options.component.ts rename to apps/desktop/src/auth/login/login-decryption-options/login-decryption-options-v1.component.ts index f64ec977ce7..d9cc07adb7e 100644 --- a/apps/desktop/src/auth/login/login-decryption-options/login-decryption-options.component.ts +++ b/apps/desktop/src/auth/login/login-decryption-options/login-decryption-options-v1.component.ts @@ -1,12 +1,12 @@ import { Component } from "@angular/core"; -import { BaseLoginDecryptionOptionsComponent } from "@bitwarden/angular/auth/components/base-login-decryption-options.component"; +import { BaseLoginDecryptionOptionsComponentV1 } from "@bitwarden/angular/auth/components/base-login-decryption-options-v1.component"; @Component({ selector: "desktop-login-decryption-options", - templateUrl: "login-decryption-options.component.html", + templateUrl: "login-decryption-options-v1.component.html", }) -export class LoginDecryptionOptionsComponent extends BaseLoginDecryptionOptionsComponent { +export class LoginDecryptionOptionsComponentV1 extends BaseLoginDecryptionOptionsComponentV1 { override async createUser(): Promise { try { await super.createUser(); diff --git a/apps/desktop/src/auth/login/login.module.ts b/apps/desktop/src/auth/login/login.module.ts index 20c0bc97c6c..427cbcb2069 100644 --- a/apps/desktop/src/auth/login/login.module.ts +++ b/apps/desktop/src/auth/login/login.module.ts @@ -5,7 +5,7 @@ import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components import { SharedModule } from "../../app/shared/shared.module"; -import { LoginDecryptionOptionsComponent } from "./login-decryption-options/login-decryption-options.component"; +import { LoginDecryptionOptionsComponentV1 } from "./login-decryption-options/login-decryption-options-v1.component"; import { LoginComponentV1 } from "./login-v1.component"; import { LoginViaAuthRequestComponentV1 } from "./login-via-auth-request-v1.component"; @@ -15,7 +15,7 @@ import { LoginViaAuthRequestComponentV1 } from "./login-via-auth-request-v1.comp LoginComponentV1, LoginViaAuthRequestComponentV1, EnvironmentSelectorComponent, - LoginDecryptionOptionsComponent, + LoginDecryptionOptionsComponentV1, ], exports: [LoginComponentV1, LoginViaAuthRequestComponentV1], }) diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index e9f26d23e94..ab6fb586948 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2832,6 +2832,9 @@ "checkForBreaches": { "message": "Check known data breaches for this password" }, + "loggedInExclamation": { + "message": "Logged in!" + }, "important": { "message": "Important:" }, @@ -2862,9 +2865,18 @@ "windowsBiometricUpdateWarningTitle": { "message": "Recommended Settings Update" }, + "rememberThisDeviceToMakeFutureLoginsSeamless": { + "message": "Remember this device to make future logins seamless" + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, + "deviceApprovalRequiredV2": { + "message": "Device approval required" + }, + "selectAnApprovalOptionBelow": { + "message": "Select an approval option below" + }, "rememberThisDevice": { "message": "Remember this device" }, @@ -2917,6 +2929,9 @@ "userEmailMissing": { "message": "User email missing" }, + "activeUserEmailNotFoundLoggingYouOut": { + "message": "Active user email not found. Logging you out." + }, "deviceTrusted": { "message": "Device trusted" }, diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts index a2e674c2a95..c14292d7c6d 100644 --- a/apps/web/src/app/auth/core/services/index.ts +++ b/apps/web/src/app/auth/core/services/index.ts @@ -1,4 +1,5 @@ export * from "./login"; +export * from "./login-decryption-options"; export * from "./webauthn-login"; export * from "./set-password-jit"; export * from "./registration"; diff --git a/apps/web/src/app/auth/core/services/login-decryption-options/index.ts b/apps/web/src/app/auth/core/services/login-decryption-options/index.ts new file mode 100644 index 00000000000..f0ff30b8727 --- /dev/null +++ b/apps/web/src/app/auth/core/services/login-decryption-options/index.ts @@ -0,0 +1 @@ +export * from "./web-login-decryption-options.service"; diff --git a/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.spec.ts b/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.spec.ts new file mode 100644 index 00000000000..31df33a6ece --- /dev/null +++ b/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.spec.ts @@ -0,0 +1,41 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { RouterService } from "../../../../core/router.service"; +import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; + +import { WebLoginDecryptionOptionsService } from "./web-login-decryption-options.service"; + +describe("WebLoginDecryptionOptionsService", () => { + let service: WebLoginDecryptionOptionsService; + + let messagingService: MockProxy; + let routerService: MockProxy; + let acceptOrganizationInviteService: MockProxy; + + beforeEach(() => { + messagingService = mock(); + routerService = mock(); + acceptOrganizationInviteService = mock(); + + service = new WebLoginDecryptionOptionsService( + messagingService, + routerService, + acceptOrganizationInviteService, + ); + }); + + it("should instantiate the service", () => { + expect(service).not.toBeFalsy(); + }); + + describe("handleCreateUserSuccess()", () => { + it("should clear the redirect URL and the org invite", async () => { + await service.handleCreateUserSuccess(); + + expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalled(); + expect(acceptOrganizationInviteService.clearOrganizationInvitation).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.ts b/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.ts new file mode 100644 index 00000000000..30654decdc3 --- /dev/null +++ b/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.ts @@ -0,0 +1,33 @@ +import { + LoginDecryptionOptionsService, + DefaultLoginDecryptionOptionsService, +} from "@bitwarden/auth/angular"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { RouterService } from "../../../../core/router.service"; +import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; + +export class WebLoginDecryptionOptionsService + extends DefaultLoginDecryptionOptionsService + implements LoginDecryptionOptionsService +{ + constructor( + protected messagingService: MessagingService, + private routerService: RouterService, + private acceptOrganizationInviteService: AcceptOrganizationInviteService, + ) { + super(messagingService); + } + + override async handleCreateUserSuccess(): Promise { + try { + // Invites from TDE orgs go through here, but the invite is + // accepted while being enrolled in admin recovery. So we need to clear + // the redirect and stored org invite. + await this.routerService.getAndClearLoginRedirectUrl(); + await this.acceptOrganizationInviteService.clearOrganizationInvitation(); + } catch (error) { + throw new Error(error); + } + } +} diff --git a/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.html b/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options-v1.component.html similarity index 100% rename from apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.html rename to apps/web/src/app/auth/login/login-decryption-options/login-decryption-options-v1.component.html diff --git a/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.ts b/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options-v1.component.ts similarity index 78% rename from apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.ts rename to apps/web/src/app/auth/login/login-decryption-options/login-decryption-options-v1.component.ts index 991fe8b5971..5eb72503b90 100644 --- a/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.ts +++ b/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options-v1.component.ts @@ -1,14 +1,14 @@ import { Component, inject } from "@angular/core"; -import { BaseLoginDecryptionOptionsComponent } from "@bitwarden/angular/auth/components/base-login-decryption-options.component"; +import { BaseLoginDecryptionOptionsComponentV1 } from "@bitwarden/angular/auth/components/base-login-decryption-options-v1.component"; import { RouterService } from "../../../core"; import { AcceptOrganizationInviteService } from "../../organization-invite/accept-organization.service"; @Component({ selector: "web-login-decryption-options", - templateUrl: "login-decryption-options.component.html", + templateUrl: "login-decryption-options-v1.component.html", }) -export class LoginDecryptionOptionsComponent extends BaseLoginDecryptionOptionsComponent { +export class LoginDecryptionOptionsComponentV1 extends BaseLoginDecryptionOptionsComponentV1 { protected routerService = inject(RouterService); protected acceptOrganizationInviteService = inject(AcceptOrganizationInviteService); diff --git a/apps/web/src/app/auth/login/login.module.ts b/apps/web/src/app/auth/login/login.module.ts index b8f39890aa1..a33a6b8a5a8 100644 --- a/apps/web/src/app/auth/login/login.module.ts +++ b/apps/web/src/app/auth/login/login.module.ts @@ -4,7 +4,7 @@ import { CheckboxModule } from "@bitwarden/components"; import { SharedModule } from "../../../app/shared"; -import { LoginDecryptionOptionsComponent } from "./login-decryption-options/login-decryption-options.component"; +import { LoginDecryptionOptionsComponentV1 } from "./login-decryption-options/login-decryption-options-v1.component"; import { LoginComponentV1 } from "./login-v1.component"; import { LoginViaAuthRequestComponentV1 } from "./login-via-auth-request-v1.component"; import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webauthn.component"; @@ -14,13 +14,13 @@ import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webaut declarations: [ LoginComponentV1, LoginViaAuthRequestComponentV1, - LoginDecryptionOptionsComponent, + LoginDecryptionOptionsComponentV1, LoginViaWebAuthnComponent, ], exports: [ LoginComponentV1, LoginViaAuthRequestComponentV1, - LoginDecryptionOptionsComponent, + LoginDecryptionOptionsComponentV1, LoginViaWebAuthnComponent, ], }) diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index cfca5659c38..79a7862178f 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -30,6 +30,7 @@ import { LoginComponentService, LockComponentService, SetPasswordJitService, + LoginDecryptionOptionsService, } from "@bitwarden/auth/angular"; import { InternalUserDecryptionOptionsServiceAbstraction, @@ -60,6 +61,7 @@ import { import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } 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 { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; @@ -95,6 +97,7 @@ import { WebRegistrationFinishService, WebLoginComponentService, WebLockComponentService, + WebLoginDecryptionOptionsService, } from "../auth"; import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service"; import { HtmlStorageService } from "../core/html-storage.service"; @@ -296,6 +299,11 @@ const safeProviders: SafeProvider[] = [ useClass: LoginEmailService, deps: [AccountService, AuthService, StateProvider], }), + safeProvider({ + provide: LoginDecryptionOptionsService, + useClass: WebLoginDecryptionOptionsService, + deps: [MessagingService, RouterService, AcceptOrganizationInviteService], + }), ]; @NgModule({ diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 20361c7edc2..6e2e97d8e06 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -33,6 +33,7 @@ import { RegistrationLockAltIcon, RegistrationExpiredLinkIcon, VaultIcon, + LoginDecryptionOptionsComponent, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -46,7 +47,7 @@ import { CreateOrganizationComponent } from "./admin-console/settings/create-org import { deepLinkGuard } from "./auth/guards/deep-link.guard"; import { HintComponent } from "./auth/hint.component"; import { LockComponent } from "./auth/lock.component"; -import { LoginDecryptionOptionsComponent } from "./auth/login/login-decryption-options/login-decryption-options.component"; +import { LoginDecryptionOptionsComponentV1 } from "./auth/login/login-decryption-options/login-decryption-options-v1.component"; import { LoginComponentV1 } from "./auth/login/login-v1.component"; import { LoginViaAuthRequestComponentV1 } from "./auth/login/login-via-auth-request-v1.component"; import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login-via-webauthn.component"; @@ -103,11 +104,6 @@ const routes: Routes = [ component: LoginViaWebAuthnComponent, data: { titleId: "logInWithPasskey" } satisfies RouteDataProperties, }, - { - path: "login-initiated", - component: LoginDecryptionOptionsComponent, - canActivate: [tdeDecryptionRequiredGuard()], - }, { path: "register", component: TrialInitiationComponent, @@ -272,6 +268,22 @@ const routes: Routes = [ ], }, ), + ...unauthUiRefreshSwap( + LoginDecryptionOptionsComponentV1, + AnonLayoutWrapperComponent, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + }, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + data: { + pageIcon: DevicesIcon, + }, + children: [{ path: "", component: LoginDecryptionOptionsComponent }], + }, + ), ...unauthUiRefreshSwap( AnonLayoutWrapperComponent, AnonLayoutWrapperComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index ff3f0505699..419a89056c7 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -8051,9 +8051,18 @@ "loginInitiated": { "message": "Login initiated" }, + "rememberThisDeviceToMakeFutureLoginsSeamless": { + "message": "Remember this device to make future logins seamless" + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, + "deviceApprovalRequiredV2": { + "message": "Device approval required" + }, + "selectAnApprovalOptionBelow": { + "message": "Select an approval option below" + }, "rememberThisDevice": { "message": "Remember this device" }, @@ -8283,6 +8292,9 @@ "userEmailMissing": { "message": "User email missing" }, + "activeUserEmailNotFoundLoggingYouOut": { + "message": "Active user email not found. Logging you out." + }, "deviceTrusted": { "message": "Device trusted" }, diff --git a/libs/angular/src/auth/components/base-login-decryption-options.component.ts b/libs/angular/src/auth/components/base-login-decryption-options-v1.component.ts similarity index 99% rename from libs/angular/src/auth/components/base-login-decryption-options.component.ts rename to libs/angular/src/auth/components/base-login-decryption-options-v1.component.ts index f674a32af8b..df99503b6d7 100644 --- a/libs/angular/src/auth/components/base-login-decryption-options.component.ts +++ b/libs/angular/src/auth/components/base-login-decryption-options-v1.component.ts @@ -63,7 +63,7 @@ type ExistingUserUntrustedDeviceData = { type Data = NewUserData | ExistingUserUntrustedDeviceData; @Directive() -export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { +export class BaseLoginDecryptionOptionsComponentV1 implements OnInit, OnDestroy { private destroy$ = new Subject(); protected State = State; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 9d1cd6e502d..0208a3cdc7a 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -16,6 +16,8 @@ import { DefaultAnonLayoutWrapperDataService, LoginComponentService, DefaultLoginComponentService, + LoginDecryptionOptionsService, + DefaultLoginDecryptionOptionsService, } from "@bitwarden/auth/angular"; import { AuthRequestServiceAbstraction, @@ -1384,6 +1386,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultAuthRequestApiService, deps: [ApiServiceAbstraction, LogService], }), + safeProvider({ + provide: LoginDecryptionOptionsService, + useClass: DefaultLoginDecryptionOptionsService, + deps: [MessagingServiceAbstraction], + }), ]; @NgModule({ diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 5c028065c62..16ae77e937f 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -24,6 +24,11 @@ export * from "./login/login-secondary-content.component"; export * from "./login/login-component.service"; export * from "./login/default-login-component.service"; +// login decryption options +export * from "./login-decryption-options/login-decryption-options.component"; +export * from "./login-decryption-options/login-decryption-options.service"; +export * from "./login-decryption-options/default-login-decryption-options.service"; + // login via auth request export * from "./login-via-auth-request/login-via-auth-request.component"; diff --git a/libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.spec.ts b/libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.spec.ts new file mode 100644 index 00000000000..735b7667540 --- /dev/null +++ b/libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.spec.ts @@ -0,0 +1,37 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { DefaultLoginDecryptionOptionsService } from "./default-login-decryption-options.service"; + +describe("DefaultLoginDecryptionOptionsService", () => { + let service: DefaultLoginDecryptionOptionsService; + + let messagingService: MockProxy; + + beforeEach(() => { + messagingService = mock(); + + service = new DefaultLoginDecryptionOptionsService(messagingService); + }); + + it("should instantiate the service", () => { + expect(service).not.toBeFalsy(); + }); + + describe("handleCreateUserSuccess()", () => { + it("should return null", async () => { + const result = await service.handleCreateUserSuccess(); + + expect(result).toBeNull(); + }); + }); + + describe("logOut()", () => { + it("should send a logout message", async () => { + await service.logOut(); + + expect(messagingService.send).toHaveBeenCalledWith("logout"); + }); + }); +}); diff --git a/libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.ts b/libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.ts new file mode 100644 index 00000000000..17ea0bc9653 --- /dev/null +++ b/libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.ts @@ -0,0 +1,15 @@ +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { LoginDecryptionOptionsService } from "./login-decryption-options.service"; + +export class DefaultLoginDecryptionOptionsService implements LoginDecryptionOptionsService { + constructor(protected messagingService: MessagingService) {} + + handleCreateUserSuccess(): Promise { + return null; + } + + async logOut(): Promise { + this.messagingService.send("logout"); + } +} diff --git a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.html b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.html new file mode 100644 index 00000000000..cb340f646f1 --- /dev/null +++ b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.html @@ -0,0 +1,60 @@ + +
+ + {{ "loading" | i18n }} +
+
+ +
+ + + {{ "rememberThisDevice" | i18n }} + {{ "uncheckIfPublicDevice" | i18n }} + +
+ + + + + + +
+ + + +
+ {{ "or" | i18n }} +
+
+ + + + +
+
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 new file mode 100644 index 00000000000..38771f9dada --- /dev/null +++ b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts @@ -0,0 +1,299 @@ +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, FormControl, ReactiveFormsModule } from "@angular/forms"; +import { Router } from "@angular/router"; +import { catchError, defer, firstValueFrom, from, map, of, switchMap, throwError } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + LoginEmailServiceAbstraction, + UserDecryptionOptions, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; +import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { ClientType } from "@bitwarden/common/enums"; +import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + AsyncActionsModule, + ButtonModule, + CheckboxModule, + FormFieldModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; + +import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; + +import { LoginDecryptionOptionsService } from "./login-decryption-options.service"; + +enum State { + NewUser, + ExistingUserUntrustedDevice, +} + +@Component({ + standalone: true, + templateUrl: "./login-decryption-options.component.html", + imports: [ + AsyncActionsModule, + ButtonModule, + CheckboxModule, + CommonModule, + FormFieldModule, + JslibModule, + ReactiveFormsModule, + TypographyModule, + ], +}) +export class LoginDecryptionOptionsComponent implements OnInit { + private activeAccountId: UserId; + private clientType: ClientType; + private email: string; + + protected loading = false; + protected state: State; + protected State = State; + + protected formGroup = this.formBuilder.group({ + rememberDevice: [true], // Remember device means for the user to trust the device + }); + + private get rememberDeviceControl(): FormControl { + return this.formGroup.controls.rememberDevice; + } + + // New User Properties + private newUserOrgId: string; + + // Existing User Untrusted Device Properties + protected canApproveFromOtherDevice = false; + protected canRequestAdminApproval = false; + protected canApproveWithMasterPassword = false; + + constructor( + private accountService: AccountService, + private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private apiService: ApiService, + private destroyRef: DestroyRef, + private deviceTrustService: DeviceTrustServiceAbstraction, + private formBuilder: FormBuilder, + private i18nService: I18nService, + private keyService: KeyService, + private loginDecryptionOptionsService: LoginDecryptionOptionsService, + private loginEmailService: LoginEmailServiceAbstraction, + private messagingService: MessagingService, + private organizationApiService: OrganizationApiServiceAbstraction, + private passwordResetEnrollmentService: PasswordResetEnrollmentServiceAbstraction, + private platformUtilsService: PlatformUtilsService, + private router: Router, + private ssoLoginService: SsoLoginServiceAbstraction, + private toastService: ToastService, + private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private validationService: ValidationService, + ) { + this.clientType === this.platformUtilsService.getClientType(); + } + + async ngOnInit() { + this.loading = true; + + this.activeAccountId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + + this.email = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.email)), + ); + + if (!this.email) { + await this.handleMissingEmail(); + return; + } + + this.observeAndPersistRememberDeviceValueChanges(); + await this.setRememberDeviceDefaultValueFromState(); + + try { + const userDecryptionOptions = await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptions$, + ); + + if ( + !userDecryptionOptions?.trustedDeviceOption?.hasAdminApproval && + !userDecryptionOptions?.hasMasterPassword + ) { + /** + * We are dealing with a new account if both are true: + * - User does NOT have admin approval (i.e. has not enrolled in admin reset) + * - User does NOT have a master password + */ + await this.loadNewUserData(); + } else { + this.loadExistingUserUntrustedDeviceData(userDecryptionOptions); + } + } catch (err) { + this.validationService.showError(err); + } finally { + this.loading = false; + } + } + + private async handleMissingEmail() { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("activeUserEmailNotFoundLoggingYouOut"), + }); + + setTimeout(async () => { + // We can't simply redirect to `/login` because the user is authed and the unauthGuard + // will prevent navigation. We must logout the user first via messagingService, which + // redirects to `/`, which will be handled by the redirectGuard to navigate the user to `/login`. + // The timeout just gives the user a chance to see the error toast before process reload runs on logout. + await this.loginDecryptionOptionsService.logOut(); + }, 5000); + } + + private observeAndPersistRememberDeviceValueChanges() { + this.rememberDeviceControl.valueChanges + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((value) => + defer(() => this.deviceTrustService.setShouldTrustDevice(this.activeAccountId, value)), + ), + ) + .subscribe(); + } + + private async setRememberDeviceDefaultValueFromState() { + const rememberDeviceFromState = await this.deviceTrustService.getShouldTrustDevice( + this.activeAccountId, + ); + + const rememberDevice = rememberDeviceFromState ?? true; + + this.rememberDeviceControl.setValue(rememberDevice); + } + + private async loadNewUserData() { + this.state = State.NewUser; + + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { + key: "loggedInExclamation", + }, + pageSubtitle: { + key: "rememberThisDeviceToMakeFutureLoginsSeamless", + }, + }); + + const autoEnrollStatus$ = defer(() => + this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(), + ).pipe( + switchMap((organizationIdentifier) => { + if (organizationIdentifier == undefined) { + return throwError(() => new Error(this.i18nService.t("ssoIdentifierRequired"))); + } + + return from(this.organizationApiService.getAutoEnrollStatus(organizationIdentifier)); + }), + catchError((err: unknown) => { + this.validationService.showError(err); + return of(undefined); + }), + ); + + const autoEnrollStatus = await firstValueFrom(autoEnrollStatus$); + + this.newUserOrgId = autoEnrollStatus.id; + } + + private loadExistingUserUntrustedDeviceData(userDecryptionOptions: UserDecryptionOptions) { + this.state = State.ExistingUserUntrustedDevice; + + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { + key: "deviceApprovalRequiredV2", + }, + pageSubtitle: { + key: "selectAnApprovalOptionBelow", + }, + }); + + this.canApproveFromOtherDevice = + userDecryptionOptions?.trustedDeviceOption?.hasLoginApprovingDevice || false; + this.canRequestAdminApproval = + userDecryptionOptions?.trustedDeviceOption?.hasAdminApproval || false; + this.canApproveWithMasterPassword = userDecryptionOptions?.hasMasterPassword || false; + } + + protected createUser = async () => { + if (this.state !== State.NewUser) { + return; + } + + try { + const { publicKey, privateKey } = await this.keyService.initAccount(); + const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString); + await this.apiService.postAccountKeys(keysRequest); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("accountSuccessfullyCreated"), + }); + + await this.passwordResetEnrollmentService.enroll(this.newUserOrgId); + + if (this.formGroup.value.rememberDevice) { + await this.deviceTrustService.trustDevice(this.activeAccountId); + } + + await this.loginDecryptionOptionsService.handleCreateUserSuccess(); + + if (this.clientType === ClientType.Desktop) { + this.messagingService.send("redrawMenu"); + } + + await this.handleCreateUserSuccessNavigation(); + } catch (err) { + this.validationService.showError(err); + } + }; + + private async handleCreateUserSuccessNavigation() { + if (this.clientType === ClientType.Browser) { + await this.router.navigate(["/tabs/vault"]); + } else { + await this.router.navigate(["/vault"]); + } + } + + protected async approveFromOtherDevice() { + this.loginEmailService.setLoginEmail(this.email); + await this.router.navigate(["/login-with-device"]); + } + + protected async approveWithMasterPassword() { + await this.router.navigate(["/lock"], { + queryParams: { + from: "login-initiated", + }, + }); + } + + protected async requestAdminApproval() { + this.loginEmailService.setLoginEmail(this.email); + await this.router.navigate(["/admin-approval-requested"]); + } +} diff --git a/libs/auth/src/angular/login-decryption-options/login-decryption-options.service.ts b/libs/auth/src/angular/login-decryption-options/login-decryption-options.service.ts new file mode 100644 index 00000000000..d81d56d6393 --- /dev/null +++ b/libs/auth/src/angular/login-decryption-options/login-decryption-options.service.ts @@ -0,0 +1,10 @@ +export abstract class LoginDecryptionOptionsService { + /** + * Handles client-specific logic that runs after a user was successfully created + */ + abstract handleCreateUserSuccess(): Promise; + /** + * Logs the user out + */ + abstract logOut(): Promise; +}