mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 13:23:34 +00:00
[PM-23596] Redirect to /setup-extension (#15641)
* remove current redirection from auth code * update timeouts of the web browser interaction * add guard for setup-extension page * decrease timeout to 25ms * avoid redirection for mobile users + add tests * add tests * condense variables * catch error from profile fetch --------- Co-authored-by: Shane Melton <smelton@bitwarden.com>
This commit is contained in:
@@ -12,7 +12,6 @@ import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-a
|
||||
import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
@@ -30,7 +29,6 @@ describe("WebRegistrationFinishService", () => {
|
||||
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
|
||||
beforeEach(() => {
|
||||
keyService = mock<KeyService>();
|
||||
@@ -39,7 +37,6 @@ describe("WebRegistrationFinishService", () => {
|
||||
policyApiService = mock<PolicyApiServiceAbstraction>();
|
||||
logService = mock<LogService>();
|
||||
policyService = mock<PolicyService>();
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
service = new WebRegistrationFinishService(
|
||||
keyService,
|
||||
@@ -48,7 +45,6 @@ describe("WebRegistrationFinishService", () => {
|
||||
policyApiService,
|
||||
logService,
|
||||
policyService,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -414,22 +410,4 @@ 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,12 +14,10 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||
import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import {
|
||||
EncryptedString,
|
||||
EncString,
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
@@ -34,7 +32,6 @@ export class WebRegistrationFinishService
|
||||
private policyApiService: PolicyApiServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private policyService: PolicyService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
super(keyService, accountApiService);
|
||||
}
|
||||
@@ -79,18 +76,6 @@ 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,
|
||||
|
||||
@@ -264,7 +264,6 @@ const safeProviders: SafeProvider[] = [
|
||||
PolicyApiServiceAbstraction,
|
||||
LogService,
|
||||
PolicyService,
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -83,6 +83,7 @@ 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 { setupExtensionRedirectGuard } from "./vault/guards/setup-extension-redirect.guard";
|
||||
import { VaultModule } from "./vault/individual-vault/vault.module";
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -628,6 +629,7 @@ const routes: Routes = [
|
||||
children: [
|
||||
{
|
||||
path: "vault",
|
||||
canActivate: [setupExtensionRedirectGuard],
|
||||
loadChildren: () => VaultModule,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,7 +18,13 @@
|
||||
>
|
||||
{{ "getTheExtension" | i18n }}
|
||||
</a>
|
||||
<a bitButton buttonType="secondary" routerLink="/vault" bitDialogClose>
|
||||
<a
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
routerLink="/vault"
|
||||
bitDialogClose
|
||||
(click)="dismissExtensionPage()"
|
||||
>
|
||||
{{ "skipToWebApp" | i18n }}
|
||||
</a>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DialogRef } from "@angular/cdk/dialog";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { provideNoopAnimations } from "@angular/platform-browser/animations";
|
||||
@@ -5,20 +6,26 @@ 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 { DIALOG_DATA } from "@bitwarden/components";
|
||||
|
||||
import { AddExtensionLaterDialogComponent } from "./add-extension-later-dialog.component";
|
||||
|
||||
describe("AddExtensionLaterDialogComponent", () => {
|
||||
let fixture: ComponentFixture<AddExtensionLaterDialogComponent>;
|
||||
const getDevice = jest.fn().mockReturnValue(null);
|
||||
const onDismiss = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
onDismiss.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AddExtensionLaterDialogComponent, RouterModule.forRoot([])],
|
||||
providers: [
|
||||
provideNoopAnimations(),
|
||||
{ provide: PlatformUtilsService, useValue: { getDevice } },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: DialogRef, useValue: { close: jest.fn() } },
|
||||
{ provide: DIALOG_DATA, useValue: { onDismiss } },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -39,4 +46,11 @@ describe("AddExtensionLaterDialogComponent", () => {
|
||||
|
||||
expect(skipLink.attributes.href).toBe("/vault");
|
||||
});
|
||||
|
||||
it('invokes `onDismiss` when "Skip to Web App" is clicked', () => {
|
||||
const skipLink = fixture.debugElement.queryAll(By.css("a[bitButton]"))[1];
|
||||
skipLink.triggerEventHandler("click", {});
|
||||
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,17 @@ 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";
|
||||
import {
|
||||
ButtonComponent,
|
||||
DIALOG_DATA,
|
||||
DialogModule,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export type AddExtensionLaterDialogData = {
|
||||
/** Method invoked when the dialog is dismissed */
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "vault-add-extension-later-dialog",
|
||||
@@ -13,6 +23,7 @@ import { ButtonComponent, DialogModule, TypographyModule } from "@bitwarden/comp
|
||||
})
|
||||
export class AddExtensionLaterDialogComponent implements OnInit {
|
||||
private platformUtilsService = inject(PlatformUtilsService);
|
||||
private data: AddExtensionLaterDialogData = inject(DIALOG_DATA);
|
||||
|
||||
/** Download Url for the extension based on the browser */
|
||||
protected webStoreUrl: string = "";
|
||||
@@ -20,4 +31,8 @@ export class AddExtensionLaterDialogComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
this.webStoreUrl = getWebStoreUrl(this.platformUtilsService.getDevice());
|
||||
}
|
||||
|
||||
async dismissExtensionPage() {
|
||||
this.data.onDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ import { By } from "@angular/platform-browser";
|
||||
import { Router, RouterModule } from "@angular/router";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
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 { StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
|
||||
|
||||
@@ -21,11 +23,13 @@ describe("SetupExtensionComponent", () => {
|
||||
const getFeatureFlag = jest.fn().mockResolvedValue(false);
|
||||
const navigate = jest.fn().mockResolvedValue(true);
|
||||
const openExtension = jest.fn().mockResolvedValue(true);
|
||||
const update = jest.fn().mockResolvedValue(true);
|
||||
const extensionInstalled$ = new BehaviorSubject<boolean | null>(null);
|
||||
|
||||
beforeEach(async () => {
|
||||
navigate.mockClear();
|
||||
openExtension.mockClear();
|
||||
update.mockClear();
|
||||
getFeatureFlag.mockClear().mockResolvedValue(true);
|
||||
window.matchMedia = jest.fn().mockReturnValue(false);
|
||||
|
||||
@@ -36,6 +40,14 @@ describe("SetupExtensionComponent", () => {
|
||||
{ provide: ConfigService, useValue: { getFeatureFlag } },
|
||||
{ provide: WebBrowserInteractionService, useValue: { extensionInstalled$, openExtension } },
|
||||
{ provide: PlatformUtilsService, useValue: { getDevice: () => DeviceType.UnknownBrowser } },
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: { activeAccount$: new BehaviorSubject({ account: { id: "account-id" } }) },
|
||||
},
|
||||
{
|
||||
provide: StateProvider,
|
||||
useValue: { getUser: () => ({ update }) },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -120,6 +132,10 @@ describe("SetupExtensionComponent", () => {
|
||||
|
||||
expect(openExtension).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dismisses the extension page", () => {
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,16 @@ import { DOCUMENT, NgIf } from "@angular/common";
|
||||
import { Component, DestroyRef, inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { Router, RouterModule } from "@angular/router";
|
||||
import { pairwise, startWith } from "rxjs";
|
||||
import { firstValueFrom, pairwise, startWith } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
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 { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url";
|
||||
import {
|
||||
@@ -20,9 +23,13 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
import { VaultIcons } from "@bitwarden/vault";
|
||||
|
||||
import { SETUP_EXTENSION_DISMISSED } from "../../guards/setup-extension-redirect.guard";
|
||||
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
|
||||
|
||||
import { AddExtensionLaterDialogComponent } from "./add-extension-later-dialog.component";
|
||||
import {
|
||||
AddExtensionLaterDialogComponent,
|
||||
AddExtensionLaterDialogData,
|
||||
} from "./add-extension-later-dialog.component";
|
||||
import { AddExtensionVideosComponent } from "./add-extension-videos.component";
|
||||
|
||||
const SetupExtensionState = {
|
||||
@@ -53,6 +60,8 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private platformUtilsService = inject(PlatformUtilsService);
|
||||
private dialogService = inject(DialogService);
|
||||
private stateProvider = inject(StateProvider);
|
||||
private accountService = inject(AccountService);
|
||||
private document = inject(DOCUMENT);
|
||||
|
||||
protected SetupExtensionState = SetupExtensionState;
|
||||
@@ -96,6 +105,7 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
|
||||
// Extension was not installed and now it is, show success state
|
||||
if (previousState === false && currentState) {
|
||||
this.dialogRef?.close();
|
||||
void this.dismissExtensionPage();
|
||||
this.state = SetupExtensionState.Success;
|
||||
}
|
||||
|
||||
@@ -125,17 +135,31 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
|
||||
const isMobile = Utils.isMobileBrowser;
|
||||
|
||||
if (!isFeatureEnabled || isMobile) {
|
||||
await this.dismissExtensionPage();
|
||||
await this.router.navigate(["/vault"]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the add extension later dialog */
|
||||
addItLater() {
|
||||
this.dialogRef = this.dialogService.open(AddExtensionLaterDialogComponent);
|
||||
this.dialogRef = this.dialogService.open<unknown, AddExtensionLaterDialogData>(
|
||||
AddExtensionLaterDialogComponent,
|
||||
{
|
||||
data: {
|
||||
onDismiss: this.dismissExtensionPage.bind(this),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens the browser extension */
|
||||
openExtension() {
|
||||
void this.webBrowserExtensionInteractionService.openExtension();
|
||||
}
|
||||
|
||||
/** Update local state to never show this page again. */
|
||||
private async dismissExtensionPage() {
|
||||
const accountId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
void this.stateProvider.getUser(accountId, SETUP_EXTENSION_DISMISSED).update(() => true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from "@angular/router";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { WebBrowserInteractionService } from "../services/web-browser-interaction.service";
|
||||
|
||||
import { setupExtensionRedirectGuard } from "./setup-extension-redirect.guard";
|
||||
|
||||
describe("setupExtensionRedirectGuard", () => {
|
||||
const _state = Object.freeze({}) as RouterStateSnapshot;
|
||||
const emptyRoute = Object.freeze({ queryParams: {} }) as ActivatedRouteSnapshot;
|
||||
const seventeenDaysAgo = new Date();
|
||||
seventeenDaysAgo.setDate(seventeenDaysAgo.getDate() - 17);
|
||||
|
||||
const account = {
|
||||
id: "account-id",
|
||||
} as unknown as Account;
|
||||
|
||||
const activeAccount$ = new BehaviorSubject<Account | null>(account);
|
||||
const extensionInstalled$ = new BehaviorSubject<boolean>(false);
|
||||
const state$ = new BehaviorSubject<boolean>(false);
|
||||
const createUrlTree = jest.fn();
|
||||
const getFeatureFlag = jest.fn().mockImplementation((key) => {
|
||||
if (key === FeatureFlag.PM19315EndUserActivationMvp) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return Promise.resolve(false);
|
||||
});
|
||||
const getProfileCreationDate = jest.fn().mockResolvedValue(seventeenDaysAgo);
|
||||
|
||||
beforeEach(() => {
|
||||
Utils.isMobileBrowser = false;
|
||||
|
||||
getFeatureFlag.mockClear();
|
||||
getProfileCreationDate.mockClear();
|
||||
createUrlTree.mockClear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: Router, useValue: { createUrlTree } },
|
||||
{ provide: ConfigService, useValue: { getFeatureFlag } },
|
||||
{ provide: AccountService, useValue: { activeAccount$ } },
|
||||
{ provide: StateProvider, useValue: { getUser: () => ({ state$ }) } },
|
||||
{ provide: WebBrowserInteractionService, useValue: { extensionInstalled$ } },
|
||||
{
|
||||
provide: VaultProfileService,
|
||||
useValue: { getProfileCreationDate },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
function setupExtensionGuard(route?: ActivatedRouteSnapshot) {
|
||||
// Run the guard within injection context so `inject` works as you'd expect
|
||||
// Pass state object to make TypeScript happy
|
||||
return TestBed.runInInjectionContext(async () =>
|
||||
setupExtensionRedirectGuard(route ?? emptyRoute, _state),
|
||||
);
|
||||
}
|
||||
|
||||
it("returns `true` when the profile was created more than 30 days ago", async () => {
|
||||
const thirtyOneDaysAgo = new Date();
|
||||
thirtyOneDaysAgo.setDate(thirtyOneDaysAgo.getDate() - 31);
|
||||
|
||||
getProfileCreationDate.mockResolvedValueOnce(thirtyOneDaysAgo);
|
||||
|
||||
expect(await setupExtensionGuard()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns `true` when the profile check fails", async () => {
|
||||
getProfileCreationDate.mockRejectedValueOnce(new Error("Profile check failed"));
|
||||
|
||||
expect(await setupExtensionGuard()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns `true` when the feature flag is disabled", async () => {
|
||||
getFeatureFlag.mockResolvedValueOnce(false);
|
||||
|
||||
expect(await setupExtensionGuard()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns `true` when the user is on a mobile device", async () => {
|
||||
Utils.isMobileBrowser = true;
|
||||
|
||||
expect(await setupExtensionGuard()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns `true` when the user has dismissed the extension page", async () => {
|
||||
state$.next(true);
|
||||
|
||||
expect(await setupExtensionGuard()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns `true` when the user has the extension installed", async () => {
|
||||
state$.next(false);
|
||||
extensionInstalled$.next(true);
|
||||
|
||||
expect(await setupExtensionGuard()).toBe(true);
|
||||
});
|
||||
|
||||
it('redirects the user to "/setup-extension" when all criteria do not pass', async () => {
|
||||
state$.next(false);
|
||||
extensionInstalled$.next(false);
|
||||
|
||||
await setupExtensionGuard();
|
||||
|
||||
expect(createUrlTree).toHaveBeenCalledWith(["/setup-extension"]);
|
||||
});
|
||||
|
||||
describe("missing current account", () => {
|
||||
afterAll(() => {
|
||||
// reset `activeAccount$` observable
|
||||
activeAccount$.next(account);
|
||||
});
|
||||
|
||||
it("redirects to login when account is missing", async () => {
|
||||
activeAccount$.next(null);
|
||||
|
||||
await setupExtensionGuard();
|
||||
|
||||
expect(createUrlTree).toHaveBeenCalledWith(["/login"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
109
apps/web/src/app/vault/guards/setup-extension-redirect.guard.ts
Normal file
109
apps/web/src/app/vault/guards/setup-extension-redirect.guard.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { CanActivateFn, Router } from "@angular/router";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import {
|
||||
SETUP_EXTENSION_DISMISSED_DISK,
|
||||
StateProvider,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
|
||||
import { WebBrowserInteractionService } from "../services/web-browser-interaction.service";
|
||||
|
||||
export const SETUP_EXTENSION_DISMISSED = new UserKeyDefinition<boolean>(
|
||||
SETUP_EXTENSION_DISMISSED_DISK,
|
||||
"setupExtensionDismissed",
|
||||
{
|
||||
deserializer: (dismissed) => dismissed,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
export const setupExtensionRedirectGuard: CanActivateFn = async () => {
|
||||
const router = inject(Router);
|
||||
const configService = inject(ConfigService);
|
||||
const accountService = inject(AccountService);
|
||||
const vaultProfileService = inject(VaultProfileService);
|
||||
const stateProvider = inject(StateProvider);
|
||||
const webBrowserInteractionService = inject(WebBrowserInteractionService);
|
||||
|
||||
const isMobile = Utils.isMobileBrowser;
|
||||
|
||||
const endUserFeatureEnabled = await configService.getFeatureFlag(
|
||||
FeatureFlag.PM19315EndUserActivationMvp,
|
||||
);
|
||||
|
||||
// The extension page isn't applicable for mobile users, do not redirect them.
|
||||
// Include before any other checks to avoid unnecessary processing.
|
||||
if (!endUserFeatureEnabled || isMobile) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentAcct = await firstValueFrom(accountService.activeAccount$);
|
||||
|
||||
if (!currentAcct) {
|
||||
return router.createUrlTree(["/login"]);
|
||||
}
|
||||
|
||||
const hasExtensionInstalledPromise = firstValueFrom(
|
||||
webBrowserInteractionService.extensionInstalled$,
|
||||
);
|
||||
|
||||
const dismissedExtensionPage = await firstValueFrom(
|
||||
stateProvider
|
||||
.getUser(currentAcct.id, SETUP_EXTENSION_DISMISSED)
|
||||
.state$.pipe(map((dismissed) => dismissed ?? false)),
|
||||
);
|
||||
|
||||
const isProfileOlderThan30Days = await profileIsOlderThan30Days(
|
||||
vaultProfileService,
|
||||
currentAcct.id,
|
||||
).catch(
|
||||
() =>
|
||||
// If the call for the profile fails for any reason, do not block the user
|
||||
true,
|
||||
);
|
||||
|
||||
if (dismissedExtensionPage || isProfileOlderThan30Days) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Checking for the extension is a more expensive operation, do it last to avoid unnecessary delays.
|
||||
const hasExtensionInstalled = await hasExtensionInstalledPromise;
|
||||
|
||||
if (hasExtensionInstalled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return router.createUrlTree(["/setup-extension"]);
|
||||
};
|
||||
|
||||
/** Returns true when the user's profile is older than 30 days */
|
||||
async function profileIsOlderThan30Days(
|
||||
vaultProfileService: VaultProfileService,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const creationDate = await vaultProfileService.getProfileCreationDate(userId);
|
||||
return isMoreThan30DaysAgo(creationDate);
|
||||
}
|
||||
|
||||
/** Returns the true when the date given is older than 30 days */
|
||||
function isMoreThan30DaysAgo(date?: string | Date): boolean {
|
||||
if (!date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const inputDate = new Date(date).getTime();
|
||||
const today = new Date().getTime();
|
||||
|
||||
const differenceInMS = today - inputDate;
|
||||
const msInADay = 1000 * 60 * 60 * 24;
|
||||
const differenceInDays = Math.round(differenceInMS / msInADay);
|
||||
|
||||
return differenceInDays > 30;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ describe("WebBrowserInteractionService", () => {
|
||||
expect(installed).toBe(false);
|
||||
});
|
||||
|
||||
tick(1500);
|
||||
tick(150);
|
||||
}));
|
||||
|
||||
it("returns true when the extension is installed", (done) => {
|
||||
@@ -58,13 +58,13 @@ describe("WebBrowserInteractionService", () => {
|
||||
});
|
||||
|
||||
// initial timeout, should emit false
|
||||
tick(1500);
|
||||
tick(26);
|
||||
expect(results[0]).toBe(false);
|
||||
|
||||
tick(2500);
|
||||
// then emit `HasBwInstalled`
|
||||
dispatchEvent(VaultMessages.HasBwInstalled);
|
||||
tick();
|
||||
tick(26);
|
||||
expect(results[1]).toBe(true);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -21,10 +21,19 @@ import { ExtensionPageUrls } from "@bitwarden/common/vault/enums";
|
||||
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
|
||||
|
||||
/**
|
||||
* The amount of time in milliseconds to wait for a response from the browser extension.
|
||||
* The amount of time in milliseconds to wait for a response from the browser extension. A longer duration is
|
||||
* used to allow for the extension to open and then emit to the message.
|
||||
* NOTE: This value isn't computed by any means, it is just a reasonable timeout for the extension to respond.
|
||||
*/
|
||||
const MESSAGE_RESPONSE_TIMEOUT_MS = 1500;
|
||||
const OPEN_RESPONSE_TIMEOUT_MS = 1500;
|
||||
|
||||
/**
|
||||
* Timeout for checking if the extension is installed.
|
||||
*
|
||||
* A shorter timeout is used to avoid waiting for too long for the extension. The listener for
|
||||
* checking the installation runs in the background scripts so the response should be relatively quick.
|
||||
*/
|
||||
const CHECK_FOR_EXTENSION_TIMEOUT_MS = 25;
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
@@ -63,7 +72,7 @@ export class WebBrowserInteractionService {
|
||||
filter((event) => event.data.command === VaultMessages.PopupOpened),
|
||||
map(() => true),
|
||||
),
|
||||
timer(MESSAGE_RESPONSE_TIMEOUT_MS).pipe(map(() => false)),
|
||||
timer(OPEN_RESPONSE_TIMEOUT_MS).pipe(map(() => false)),
|
||||
)
|
||||
.pipe(take(1))
|
||||
.subscribe((didOpen) => {
|
||||
@@ -85,7 +94,7 @@ export class WebBrowserInteractionService {
|
||||
filter((event) => event.data.command === VaultMessages.HasBwInstalled),
|
||||
map(() => true),
|
||||
),
|
||||
timer(MESSAGE_RESPONSE_TIMEOUT_MS).pipe(map(() => false)),
|
||||
timer(CHECK_FOR_EXTENSION_TIMEOUT_MS).pipe(map(() => false)),
|
||||
).pipe(
|
||||
tap({
|
||||
subscribe: () => {
|
||||
|
||||
@@ -28,10 +28,6 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi
|
||||
return null;
|
||||
}
|
||||
|
||||
determineLoginSuccessRoute(): Promise<string> {
|
||||
return Promise.resolve("/vault");
|
||||
}
|
||||
|
||||
async finishRegistration(
|
||||
email: string,
|
||||
passwordInputResult: PasswordInputResult,
|
||||
|
||||
@@ -204,8 +204,7 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
||||
|
||||
await this.loginSuccessHandlerService.run(authenticationResult.userId);
|
||||
|
||||
const successRoute = await this.registrationFinishService.determineLoginSuccessRoute();
|
||||
await this.router.navigate([successRoute]);
|
||||
await this.router.navigate(["/vault"]);
|
||||
} 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);
|
||||
|
||||
@@ -16,11 +16,6 @@ export abstract class RegistrationFinishService {
|
||||
*/
|
||||
abstract getMasterPasswordPolicyOptsFromOrgInvite(): Promise<MasterPasswordPolicyOptions | null>;
|
||||
|
||||
/**
|
||||
* Returns the route the user is redirected to after a successful login.
|
||||
*/
|
||||
abstract determineLoginSuccessRoute(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Finishes the registration process by creating a new user account.
|
||||
*
|
||||
|
||||
@@ -202,6 +202,13 @@ export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
|
||||
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
|
||||
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");
|
||||
export const NUDGES_DISK = new StateDefinition("nudges", "disk", { web: "disk-local" });
|
||||
export const SETUP_EXTENSION_DISMISSED_DISK = new StateDefinition(
|
||||
"setupExtensionDismissed",
|
||||
"disk",
|
||||
{
|
||||
web: "disk-local",
|
||||
},
|
||||
);
|
||||
export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition(
|
||||
"vaultBrowserIntroCarousel",
|
||||
"disk",
|
||||
|
||||
Reference in New Issue
Block a user