1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-06 18:43:25 +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

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