diff --git a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts index fe3b0837aa8..afaf1bd49d2 100644 --- a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts +++ b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts @@ -9,6 +9,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; @@ -33,6 +34,7 @@ describe("WebRegistrationFinishService", () => { let policyApiService: MockProxy; let logService: MockProxy; let policyService: MockProxy; + let configService: MockProxy; const mockUserId = Utils.newGuid() as UserId; let accountService: FakeAccountService; @@ -44,6 +46,7 @@ describe("WebRegistrationFinishService", () => { logService = mock(); policyService = mock(); accountService = mockAccountServiceWith(mockUserId); + configService = mock(); service = new WebRegistrationFinishService( keyService, @@ -53,6 +56,7 @@ describe("WebRegistrationFinishService", () => { logService, policyService, accountService, + configService, ); }); @@ -418,4 +422,22 @@ describe("WebRegistrationFinishService", () => { ); }); }); + + describe("determineLoginSuccessRoute", () => { + it("returns /setup-extension when the end user activation feature flag is enabled", async () => { + configService.getFeatureFlag.mockResolvedValue(true); + + const result = await service.determineLoginSuccessRoute(); + + expect(result).toBe("/setup-extension"); + }); + + it("returns /vault when the end user activation feature flag is disabled", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + const result = await service.determineLoginSuccessRoute(); + + expect(result).toBe("/vault"); + }); + }); }); diff --git a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts index 3d99b3b6712..05b8ab5cb0f 100644 --- a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts +++ b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts @@ -14,6 +14,8 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { KeyService } from "@bitwarden/key-management"; @@ -32,6 +34,7 @@ export class WebRegistrationFinishService private logService: LogService, private policyService: PolicyService, private accountService: AccountService, + private configService: ConfigService, ) { super(keyService, accountApiService); } @@ -76,6 +79,18 @@ export class WebRegistrationFinishService return masterPasswordPolicyOpts; } + override async determineLoginSuccessRoute(): Promise { + const endUserActivationFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM19315EndUserActivationMvp, + ); + + if (endUserActivationFlagEnabled) { + return "/setup-extension"; + } else { + return super.determineLoginSuccessRoute(); + } + } + // Note: the org invite token and email verification are mutually exclusive. Only one will be present. override async buildRegisterRequest( email: string, diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index b6a6ca102d8..b8baa762e91 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -62,6 +62,7 @@ import { VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService, Urls, @@ -256,6 +257,7 @@ const safeProviders: SafeProvider[] = [ LogService, PolicyService, AccountService, + ConfigService, ], }), safeProvider({ diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 615bb545811..31b9ca26e70 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -81,6 +81,7 @@ import { AccessComponent, SendAccessExplainerComponent } from "./tools/send/send import { SendComponent } from "./tools/send/send.component"; import { BrowserExtensionPromptInstallComponent } from "./vault/components/browser-extension-prompt/browser-extension-prompt-install.component"; import { BrowserExtensionPromptComponent } from "./vault/components/browser-extension-prompt/browser-extension-prompt.component"; +import { SetupExtensionComponent } from "./vault/components/setup-extension/setup-extension.component"; import { VaultModule } from "./vault/individual-vault/vault.module"; const routes: Routes = [ @@ -579,6 +580,20 @@ const routes: Routes = [ }, ], }, + { + path: "setup-extension", + data: { + hideCardWrapper: true, + hideIcon: true, + maxWidth: "3xl", + } satisfies AnonLayoutWrapperData, + children: [ + { + path: "", + component: SetupExtensionComponent, + }, + ], + }, ], }, { diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.html b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.html new file mode 100644 index 00000000000..df1786e227e --- /dev/null +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.html @@ -0,0 +1,25 @@ + +
+ +
+ +
+ {{ "cannotAutofillPasswordsWithoutExtensionTitle" | i18n }} +
+
{{ "cannotAutofillPasswordsWithoutExtensionDesc" | i18n }}
+
+ + + {{ "getTheExtension" | i18n }} + + + {{ "skipToWebApp" | i18n }} + + +
diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.spec.ts b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.spec.ts new file mode 100644 index 00000000000..d34dba737dd --- /dev/null +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.spec.ts @@ -0,0 +1,42 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; +import { RouterModule } from "@angular/router"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { AddExtensionLaterDialogComponent } from "./add-extension-later-dialog.component"; + +describe("AddExtensionLaterDialogComponent", () => { + let fixture: ComponentFixture; + const getDevice = jest.fn().mockReturnValue(null); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AddExtensionLaterDialogComponent, RouterModule.forRoot([])], + providers: [ + provideNoopAnimations(), + { provide: PlatformUtilsService, useValue: { getDevice } }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AddExtensionLaterDialogComponent); + fixture.detectChanges(); + }); + + it("renders the 'Get the Extension' link with correct href", () => { + const link = fixture.debugElement.queryAll(By.css("a[bitButton]"))[0]; + + expect(link.nativeElement.getAttribute("href")).toBe( + "https://bitwarden.com/download/#downloads-web-browser", + ); + }); + + it("renders the 'Skip to Web App' link with correct routerLink", () => { + const skipLink = fixture.debugElement.queryAll(By.css("a[bitButton]"))[1]; + + expect(skipLink.attributes.href).toBe("/vault"); + }); +}); diff --git a/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.ts b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.ts new file mode 100644 index 00000000000..3324cb8b1b0 --- /dev/null +++ b/apps/web/src/app/vault/components/setup-extension/add-extension-later-dialog.component.ts @@ -0,0 +1,23 @@ +import { Component, inject, OnInit } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url"; +import { ButtonComponent, DialogModule, TypographyModule } from "@bitwarden/components"; + +@Component({ + selector: "vault-add-extension-later-dialog", + templateUrl: "./add-extension-later-dialog.component.html", + imports: [DialogModule, JslibModule, TypographyModule, ButtonComponent, RouterModule], +}) +export class AddExtensionLaterDialogComponent implements OnInit { + private platformUtilsService = inject(PlatformUtilsService); + + /** Download Url for the extension based on the browser */ + protected webStoreUrl: string = ""; + + ngOnInit(): void { + this.webStoreUrl = getWebStoreUrl(this.platformUtilsService.getDevice()); + } +} diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html new file mode 100644 index 00000000000..fc2b1bc60cb --- /dev/null +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html @@ -0,0 +1,59 @@ + + +
+

{{ "setupExtensionPageTitle" | i18n }}

+

{{ "setupExtensionPageDescription" | i18n }}

+
+ + +
+
+ + {{ "getTheExtension" | i18n }} + + +
+
+ +
+ +

{{ "bitwardenExtensionInstalled" | i18n }}

+
+

{{ "openExtensionToAutofill" | i18n }}

+ + + {{ "skipToWebApp" | i18n }} + +
+

+ {{ "gettingStartedWithBitwardenPart1" | i18n }} + + {{ "gettingStartedWithBitwardenPart2" | i18n }} + + {{ "and" | i18n }} + + {{ "gettingStartedWithBitwardenPart3" | i18n }} + +

+
diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts new file mode 100644 index 00000000000..304aaafab9e --- /dev/null +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.spec.ts @@ -0,0 +1,124 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { Router, RouterModule } from "@angular/router"; +import { BehaviorSubject } from "rxjs"; + +import { DeviceType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service"; + +import { SetupExtensionComponent } from "./setup-extension.component"; + +describe("SetupExtensionComponent", () => { + let fixture: ComponentFixture; + let component: SetupExtensionComponent; + + const getFeatureFlag = jest.fn().mockResolvedValue(false); + const navigate = jest.fn().mockResolvedValue(true); + const openExtension = jest.fn().mockResolvedValue(true); + const extensionInstalled$ = new BehaviorSubject(null); + + beforeEach(async () => { + navigate.mockClear(); + openExtension.mockClear(); + getFeatureFlag.mockClear().mockResolvedValue(true); + + await TestBed.configureTestingModule({ + imports: [SetupExtensionComponent, RouterModule.forRoot([])], + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: ConfigService, useValue: { getFeatureFlag } }, + { provide: WebBrowserInteractionService, useValue: { extensionInstalled$, openExtension } }, + { provide: PlatformUtilsService, useValue: { getDevice: () => DeviceType.UnknownBrowser } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SetupExtensionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + const router = TestBed.inject(Router); + router.navigate = navigate; + }); + + it("initially shows the loading spinner", () => { + const spinner = fixture.debugElement.query(By.css("i")); + + expect(spinner.nativeElement.title).toBe("loading"); + }); + + it("sets webStoreUrl", () => { + expect(component["webStoreUrl"]).toBe("https://bitwarden.com/download/#downloads-web-browser"); + }); + + describe("initialization", () => { + it("redirects to the vault if the feature flag is disabled", async () => { + Utils.isMobileBrowser = false; + getFeatureFlag.mockResolvedValue(false); + navigate.mockClear(); + + await component.ngOnInit(); + + expect(navigate).toHaveBeenCalledWith(["/vault"]); + }); + + it("redirects to the vault if the user is on a mobile browser", async () => { + Utils.isMobileBrowser = true; + getFeatureFlag.mockResolvedValue(true); + navigate.mockClear(); + + await component.ngOnInit(); + + expect(navigate).toHaveBeenCalledWith(["/vault"]); + }); + + it("does not redirect the user", async () => { + Utils.isMobileBrowser = false; + getFeatureFlag.mockResolvedValue(true); + navigate.mockClear(); + + await component.ngOnInit(); + + expect(getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.PM19315EndUserActivationMvp); + expect(navigate).not.toHaveBeenCalled(); + }); + }); + + describe("extensionInstalled$", () => { + it("redirects the user to the vault when the first emitted value is true", () => { + extensionInstalled$.next(true); + + expect(navigate).toHaveBeenCalledWith(["/vault"]); + }); + + describe("success state", () => { + beforeEach(() => { + // avoid initial redirect + extensionInstalled$.next(false); + + fixture.detectChanges(); + + extensionInstalled$.next(true); + fixture.detectChanges(); + }); + + it("shows link to the vault", () => { + const successLink = fixture.debugElement.query(By.css("a")); + + expect(successLink.nativeElement.href).toContain("/vault"); + }); + + it("shows open extension button", () => { + const openExtensionButton = fixture.debugElement.query(By.css("button")); + + openExtensionButton.triggerEventHandler("click"); + + expect(openExtension).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts new file mode 100644 index 00000000000..839572f3a30 --- /dev/null +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts @@ -0,0 +1,107 @@ +import { NgIf } from "@angular/common"; +import { Component, DestroyRef, inject, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Router, RouterModule } from "@angular/router"; +import { pairwise, startWith } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url"; +import { + ButtonComponent, + DialogRef, + DialogService, + IconModule, + LinkModule, +} from "@bitwarden/components"; +import { VaultIcons } from "@bitwarden/vault"; + +import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service"; + +import { AddExtensionLaterDialogComponent } from "./add-extension-later-dialog.component"; + +const SetupExtensionState = { + Loading: "loading", + NeedsExtension: "needs-extension", + Success: "success", +} as const; + +type SetupExtensionState = UnionOfValues; + +@Component({ + selector: "vault-setup-extension", + templateUrl: "./setup-extension.component.html", + imports: [NgIf, JslibModule, ButtonComponent, LinkModule, IconModule, RouterModule], +}) +export class SetupExtensionComponent implements OnInit { + private webBrowserExtensionInteractionService = inject(WebBrowserInteractionService); + private configService = inject(ConfigService); + private router = inject(Router); + private destroyRef = inject(DestroyRef); + private platformUtilsService = inject(PlatformUtilsService); + private dialogService = inject(DialogService); + + protected SetupExtensionState = SetupExtensionState; + protected PartyIcon = VaultIcons.Party; + + /** The current state of the setup extension component. */ + protected state: SetupExtensionState = SetupExtensionState.Loading; + + /** Download Url for the extension based on the browser */ + protected webStoreUrl: string = ""; + + /** Reference to the add it later dialog */ + protected dialogRef: DialogRef | null = null; + + async ngOnInit() { + await this.conditionallyRedirectUser(); + + this.webStoreUrl = getWebStoreUrl(this.platformUtilsService.getDevice()); + + this.webBrowserExtensionInteractionService.extensionInstalled$ + .pipe(takeUntilDestroyed(this.destroyRef), startWith(null), pairwise()) + .subscribe(([previousState, currentState]) => { + // Initial state transitioned to extension installed, redirect the user + if (previousState === null && currentState) { + void this.router.navigate(["/vault"]); + } + + // Extension was not installed and now it is, show success state + if (previousState === false && currentState) { + this.dialogRef?.close(); + this.state = SetupExtensionState.Success; + } + + // Extension is not installed + if (currentState === false) { + this.state = SetupExtensionState.NeedsExtension; + } + }); + } + + /** Conditionally redirects the user to the vault upon landing on the page. */ + async conditionallyRedirectUser() { + const isFeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM19315EndUserActivationMvp, + ); + const isMobile = Utils.isMobileBrowser; + + if (!isFeatureEnabled || isMobile) { + await this.router.navigate(["/vault"]); + } + } + + /** Opens the add extension later dialog */ + addItLater() { + this.dialogRef = this.dialogService.open(AddExtensionLaterDialogComponent); + } + + /** Opens the browser extension */ + openExtension() { + void this.webBrowserExtensionInteractionService.openExtension(); + } +} diff --git a/apps/web/src/app/vault/services/web-browser-interaction.service.spec.ts b/apps/web/src/app/vault/services/web-browser-interaction.service.spec.ts index 68a9ca6d099..fef5d45e8c3 100644 --- a/apps/web/src/app/vault/services/web-browser-interaction.service.spec.ts +++ b/apps/web/src/app/vault/services/web-browser-interaction.service.spec.ts @@ -61,6 +61,7 @@ describe("WebBrowserInteractionService", () => { tick(1500); expect(results[0]).toBe(false); + tick(2500); // then emit `HasBwInstalled` dispatchEvent(VaultMessages.HasBwInstalled); tick(); diff --git a/apps/web/src/app/vault/services/web-browser-interaction.service.ts b/apps/web/src/app/vault/services/web-browser-interaction.service.ts index 46c566140e4..f1005ef6dc9 100644 --- a/apps/web/src/app/vault/services/web-browser-interaction.service.ts +++ b/apps/web/src/app/vault/services/web-browser-interaction.service.ts @@ -1,6 +1,21 @@ import { DestroyRef, inject, Injectable } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { concatWith, filter, fromEvent, map, Observable, race, take, tap, timer } from "rxjs"; +import { + concat, + filter, + fromEvent, + interval, + map, + Observable, + of, + race, + shareReplay, + switchMap, + take, + takeWhile, + tap, + timer, +} from "rxjs"; import { ExtensionPageUrls } from "@bitwarden/common/vault/enums"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; @@ -22,13 +37,22 @@ export class WebBrowserInteractionService { ); /** Emits the installation status of the extension. */ - extensionInstalled$ = this.checkForExtension().pipe( - concatWith( - this.messages$.pipe( - filter((event) => event.data.command === VaultMessages.HasBwInstalled), - map(() => true), - ), - ), + extensionInstalled$: Observable = this.checkForExtension().pipe( + switchMap((installed) => { + if (installed) { + return of(true); + } + + return concat( + of(false), + interval(2500).pipe( + switchMap(() => this.checkForExtension()), + takeWhile((installed) => !installed, true), + filter((installed) => installed), + ), + ); + }), + shareReplay({ bufferSize: 1, refCount: true }), ); /** Attempts to open the extension, rejects if the extension is not installed or it fails to open. */ diff --git a/apps/web/src/images/setup-extension/setup-extension-placeholder.png b/apps/web/src/images/setup-extension/setup-extension-placeholder.png new file mode 100644 index 00000000000..03a6d8951c0 Binary files /dev/null and b/apps/web/src/images/setup-extension/setup-extension-placeholder.png differ diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 76dcf2cb97d..6ed8ffe9bb3 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -10698,6 +10698,51 @@ "description": "Two part message", "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, + "setupExtensionPageTitle": { + "message": "Autofill your passwords securely with one click" + }, + "setupExtensionPageDescription": { + "message": "Get the Bitwarden browser extension and start autofilling today" + }, + "getTheExtension": { + "message": "Get the extension" + }, + "addItLater": { + "message": "Add it later" + }, + "cannotAutofillPasswordsWithoutExtensionTitle": { + "message": "You can't autofill passwords without the browser extension" + }, + "cannotAutofillPasswordsWithoutExtensionDesc": { + "message": "Are you sure you don't want to add the extension now?" + }, + "skipToWebApp": { + "message": "Skip to web app" + }, + "bitwardenExtensionInstalled": { + "message": "Bitwarden extension installed!" + }, + "openExtensionToAutofill": { + "message": "Open the extension to log in and start autofilling." + }, + "openBitwardenExtension": { + "message": "Open Bitwarden extension" + }, + "gettingStartedWithBitwardenPart1": { + "message": "For tips on getting started with Bitwarden visit the", + "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'." + }, + "gettingStartedWithBitwardenPart2": { + "message": "Learning Center", + "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'." + }, + "gettingStartedWithBitwardenPart3": { + "message": "Help Center", + "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'." + }, + "setupExtensionContentAlt": { + "message": "With the Bitwarden browser extension you can easily create new logins, access your saved logins directly from your browser toolbar, and sign in to accounts quickly using Bitwarden autofill." + }, "restart": { "message": "Restart" }, diff --git a/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts b/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts index 5510cbe9f30..b116a62dd4d 100644 --- a/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts +++ b/libs/auth/src/angular/registration/registration-finish/default-registration-finish.service.ts @@ -25,6 +25,10 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi return null; } + determineLoginSuccessRoute(): Promise { + return Promise.resolve("/vault"); + } + async finishRegistration( email: string, passwordInputResult: PasswordInputResult, diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts index 7ef4d9690a7..1d1a2d8f892 100644 --- a/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.component.ts @@ -10,7 +10,9 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; import { RegisterVerificationEmailClickedRequest } from "@bitwarden/common/auth/models/request/registration/register-verification-email-clicked.request"; import { HttpStatusCode } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -77,6 +79,7 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy { private logService: LogService, private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, private loginSuccessHandlerService: LoginSuccessHandlerService, + private configService: ConfigService, ) {} async ngOnInit() { @@ -186,15 +189,23 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy { return; } - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("youHaveBeenLoggedIn"), - }); + const endUserActivationFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM19315EndUserActivationMvp, + ); + + if (!endUserActivationFlagEnabled) { + // Only show the toast when the end user activation feature flag is _not_ enabled + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("youHaveBeenLoggedIn"), + }); + } await this.loginSuccessHandlerService.run(authenticationResult.userId); - await this.router.navigate(["/vault"]); + const successRoute = await this.registrationFinishService.determineLoginSuccessRoute(); + await this.router.navigate([successRoute]); } catch (e) { // If login errors, redirect to login page per product. Don't show error this.logService.error("Error logging in after registration: ", e.message); diff --git a/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts b/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts index 5f3c04e5155..523a4c79c54 100644 --- a/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts +++ b/libs/auth/src/angular/registration/registration-finish/registration-finish.service.ts @@ -16,6 +16,11 @@ export abstract class RegistrationFinishService { */ abstract getMasterPasswordPolicyOptsFromOrgInvite(): Promise; + /** + * Returns the route the user is redirected to after a successful login. + */ + abstract determineLoginSuccessRoute(): Promise; + /** * Finishes the registration process by creating a new user account. * diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 55c96c2334c..dd2aadc0009 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -55,6 +55,7 @@ export enum FeatureFlag { PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms", EndUserNotifications = "pm-10609-end-user-notifications", RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy", + PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp", /* Platform */ IpcChannelFramework = "ipc-channel-framework", @@ -99,6 +100,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EndUserNotifications]: FALSE, [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.RemoveCardItemTypePolicy]: FALSE, + [FeatureFlag.PM19315EndUserActivationMvp]: FALSE, /* Auth */ [FeatureFlag.PM16117_SetInitialPasswordRefactor]: FALSE, diff --git a/libs/common/src/vault/utils/get-web-store-url.ts b/libs/common/src/vault/utils/get-web-store-url.ts new file mode 100644 index 00000000000..87698d65de2 --- /dev/null +++ b/libs/common/src/vault/utils/get-web-store-url.ts @@ -0,0 +1,22 @@ +import { DeviceType } from "@bitwarden/common/enums"; + +/** + * Returns the web store URL for the Bitwarden browser extension based on the device type. + * @defaults Bitwarden download page + */ +export const getWebStoreUrl = (deviceType: DeviceType): string => { + switch (deviceType) { + case DeviceType.ChromeBrowser: + return "https://chromewebstore.google.com/detail/bitwarden-password-manage/nngceckbapebfimnlniiiahkandclblb"; + case DeviceType.FirefoxBrowser: + return "https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/"; + case DeviceType.SafariBrowser: + return "https://apps.apple.com/us/app/bitwarden/id1352778147?mt=12"; + case DeviceType.OperaBrowser: + return "https://addons.opera.com/extensions/details/bitwarden-free-password-manager/"; + case DeviceType.EdgeBrowser: + return "https://microsoftedge.microsoft.com/addons/detail/jbkfoedolllekgbhcbcoahefnbanhhlh"; + default: + return "https://bitwarden.com/download/#downloads-web-browser"; + } +}; diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.html b/libs/components/src/anon-layout/anon-layout-wrapper.component.html index 0d393b30362..3509e4dcdb0 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.html +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.html @@ -5,6 +5,7 @@ [showReadonlyHostname]="showReadonlyHostname" [maxWidth]="maxWidth" [hideCardWrapper]="hideCardWrapper" + [hideIcon]="hideIcon" > diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts index 20380f137a6..ac192645ee6 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -29,6 +29,10 @@ export interface AnonLayoutWrapperData { * The optional icon to display on the page. */ pageIcon?: Icon | null; + /** + * Hides the default Bitwarden shield icon. + */ + hideIcon?: boolean; /** * Optional flag to either show the optional environment selector (false) or just a readonly hostname (true). */ @@ -56,6 +60,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { protected showReadonlyHostname: boolean; protected maxWidth: AnonLayoutMaxWidth; protected hideCardWrapper: boolean; + protected hideIcon: boolean = false; constructor( private router: Router, @@ -104,6 +109,10 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { this.pageIcon = firstChildRouteData["pageIcon"]; } + if (firstChildRouteData["hideIcon"] !== undefined) { + this.hideIcon = firstChildRouteData["hideIcon"]; + } + this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]); this.maxWidth = firstChildRouteData["maxWidth"]; this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]); diff --git a/libs/vault/src/icons/index.ts b/libs/vault/src/icons/index.ts index ef4a7f52a3d..904399da4b3 100644 --- a/libs/vault/src/icons/index.ts +++ b/libs/vault/src/icons/index.ts @@ -8,3 +8,4 @@ export * from "./security-handshake"; export * from "./login-cards"; export * from "./secure-user"; export * from "./secure-devices"; +export * from "./party"; diff --git a/libs/vault/src/icons/party.ts b/libs/vault/src/icons/party.ts new file mode 100644 index 00000000000..2e506c5d705 --- /dev/null +++ b/libs/vault/src/icons/party.ts @@ -0,0 +1,30 @@ +import { svgIcon } from "@bitwarden/components"; + +export const Party = svgIcon` + + + + + + + + + + + + + + + + + + + + + + + + + + +`;