1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 22:33:35 +00:00

[PM-22179] Redirect user to /setup-extension (#15375)

* add end user feature flag

* add initial setup extension component and route

* redirect users from registration completion to the setup extension page

* add `hideIcon` to anon layout for web
- matches implementation on the browser.

* integrate with anon layout for extension wrapper

* add initial loading state

* conditionally redirect the user upon initialization

* redirect the user to the vault if the extension is installed

* add initial copy for setup-extension page

* add confirmation dialog for skipping the extension installation

* add success state for setup extension page

* only show loggedin toast when end user activation is not enabled.

* add image alt

* lower threshold for polling extension

* close the dialog when linking to the vault

* update party colors

* use the platform specific registration service to to only forward the web registrations to `/setup-extension`

* call `super` rather than `/vault` directly, it could change in the future
This commit is contained in:
Nick Krantz
2025-07-03 06:14:25 -05:00
committed by GitHub
parent cef6a5e8d0
commit ab4af7deed
23 changed files with 603 additions and 14 deletions

View File

@@ -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<PolicyApiServiceAbstraction>;
let logService: MockProxy<LogService>;
let policyService: MockProxy<PolicyService>;
let configService: MockProxy<ConfigService>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
@@ -44,6 +46,7 @@ describe("WebRegistrationFinishService", () => {
logService = mock<LogService>();
policyService = mock<PolicyService>();
accountService = mockAccountServiceWith(mockUserId);
configService = mock<ConfigService>();
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");
});
});
});

View File

@@ -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<string> {
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,

View File

@@ -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({

View File

@@ -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,
},
],
},
],
},
{

View File

@@ -0,0 +1,25 @@
<bit-simple-dialog>
<div bitDialogIcon>
<i class="bwi bwi-info-circle bwi-2x tw-text-info" aria-hidden="true"></i>
</div>
<ng-container bitDialogContent>
<div bitTypography="h3">
{{ "cannotAutofillPasswordsWithoutExtensionTitle" | i18n }}
</div>
<div bitTypography="body1">{{ "cannotAutofillPasswordsWithoutExtensionDesc" | i18n }}</div>
</ng-container>
<ng-container bitDialogFooter>
<a
bitButton
buttonType="primary"
[href]="webStoreUrl"
target="_blank"
rel="noopener noreferrer"
>
{{ "getTheExtension" | i18n }}
</a>
<a bitButton buttonType="secondary" routerLink="/vault" bitDialogClose>
{{ "skipToWebApp" | i18n }}
</a>
</ng-container>
</bit-simple-dialog>

View File

@@ -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<AddExtensionLaterDialogComponent>;
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");
});
});

View File

@@ -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());
}
}

View File

@@ -0,0 +1,59 @@
<i
*ngIf="state === SetupExtensionState.Loading"
class="bwi bwi-spinner bwi-spin bwi-3x tw-text-muted"
aria-hidden="true"
[appA11yTitle]="'loading' | i18n"
></i>
<section *ngIf="state === SetupExtensionState.NeedsExtension" class="tw-text-center tw-mt-4">
<h1 bitTypography="h2">{{ "setupExtensionPageTitle" | i18n }}</h1>
<h2 bitTypography="body1">{{ "setupExtensionPageDescription" | i18n }}</h2>
<div class="tw-mb-6">
<!-- Placeholder - will be removed in following tickets -->
<img
class="tw-max-w-3xl"
[alt]="'setupExtensionContentAlt' | i18n"
src="/images/setup-extension/setup-extension-placeholder.png"
/>
</div>
<div class="tw-flex tw-flex-col tw-gap-4 tw-items-center">
<a
bitButton
buttonType="primary"
[href]="webStoreUrl"
target="_blank"
rel="noopener noreferrer"
>
{{ "getTheExtension" | i18n }}
</a>
<button type="button" bitLink (click)="addItLater()">
{{ "addItLater" | i18n }}
</button>
</div>
</section>
<section *ngIf="state === SetupExtensionState.Success" class="tw-flex tw-flex-col tw-items-center">
<bit-icon [icon]="PartyIcon"></bit-icon>
<h1 bitTypography="h2" class="tw-mb-6 tw-mt-4">{{ "bitwardenExtensionInstalled" | i18n }}</h1>
<div
class="tw-flex tw-flex-col tw-rounded-2xl tw-bg-background tw-border tw-border-solid tw-border-secondary-300 tw-p-8"
>
<p>{{ "openExtensionToAutofill" | i18n }}</p>
<button type="button" bitButton buttonType="primary" class="tw-mb-2" (click)="openExtension()">
{{ "openBitwardenExtension" | i18n }}
</button>
<a bitButton buttonType="secondary" routerLink="/vault">
{{ "skipToWebApp" | i18n }}
</a>
</div>
<p class="tw-mt-10 tw-max-w-96 tw-text-center">
{{ "gettingStartedWithBitwardenPart1" | i18n }}
<a bitLink href="https://bitwarden.com/help/">
{{ "gettingStartedWithBitwardenPart2" | i18n }}
</a>
{{ "and" | i18n }}
<a bitLink href="https://bitwarden.com/help/learning-center/">
{{ "gettingStartedWithBitwardenPart3" | i18n }}
</a>
</p>
</section>

View File

@@ -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<SetupExtensionComponent>;
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<boolean | null>(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();
});
});
});
});

View File

@@ -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<typeof SetupExtensionState>;
@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();
}
}

View File

@@ -61,6 +61,7 @@ describe("WebBrowserInteractionService", () => {
tick(1500);
expect(results[0]).toBe(false);
tick(2500);
// then emit `HasBwInstalled`
dispatchEvent(VaultMessages.HasBwInstalled);
tick();

View File

@@ -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<boolean> = 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. */

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

View File

@@ -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"
},