mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53: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 { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite";
|
||||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||||
@@ -30,7 +29,6 @@ describe("WebRegistrationFinishService", () => {
|
|||||||
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
|
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
|
||||||
let logService: MockProxy<LogService>;
|
let logService: MockProxy<LogService>;
|
||||||
let policyService: MockProxy<PolicyService>;
|
let policyService: MockProxy<PolicyService>;
|
||||||
let configService: MockProxy<ConfigService>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
keyService = mock<KeyService>();
|
keyService = mock<KeyService>();
|
||||||
@@ -39,7 +37,6 @@ describe("WebRegistrationFinishService", () => {
|
|||||||
policyApiService = mock<PolicyApiServiceAbstraction>();
|
policyApiService = mock<PolicyApiServiceAbstraction>();
|
||||||
logService = mock<LogService>();
|
logService = mock<LogService>();
|
||||||
policyService = mock<PolicyService>();
|
policyService = mock<PolicyService>();
|
||||||
configService = mock<ConfigService>();
|
|
||||||
|
|
||||||
service = new WebRegistrationFinishService(
|
service = new WebRegistrationFinishService(
|
||||||
keyService,
|
keyService,
|
||||||
@@ -48,7 +45,6 @@ describe("WebRegistrationFinishService", () => {
|
|||||||
policyApiService,
|
policyApiService,
|
||||||
logService,
|
logService,
|
||||||
policyService,
|
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 { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||||
import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request";
|
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 { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
|
||||||
import {
|
import {
|
||||||
EncryptedString,
|
EncryptedString,
|
||||||
EncString,
|
EncString,
|
||||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
} 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { KeyService } from "@bitwarden/key-management";
|
import { KeyService } from "@bitwarden/key-management";
|
||||||
|
|
||||||
@@ -34,7 +32,6 @@ export class WebRegistrationFinishService
|
|||||||
private policyApiService: PolicyApiServiceAbstraction,
|
private policyApiService: PolicyApiServiceAbstraction,
|
||||||
private logService: LogService,
|
private logService: LogService,
|
||||||
private policyService: PolicyService,
|
private policyService: PolicyService,
|
||||||
private configService: ConfigService,
|
|
||||||
) {
|
) {
|
||||||
super(keyService, accountApiService);
|
super(keyService, accountApiService);
|
||||||
}
|
}
|
||||||
@@ -79,18 +76,6 @@ export class WebRegistrationFinishService
|
|||||||
return masterPasswordPolicyOpts;
|
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.
|
// Note: the org invite token and email verification are mutually exclusive. Only one will be present.
|
||||||
override async buildRegisterRequest(
|
override async buildRegisterRequest(
|
||||||
email: string,
|
email: string,
|
||||||
|
|||||||
@@ -264,7 +264,6 @@ const safeProviders: SafeProvider[] = [
|
|||||||
PolicyApiServiceAbstraction,
|
PolicyApiServiceAbstraction,
|
||||||
LogService,
|
LogService,
|
||||||
PolicyService,
|
PolicyService,
|
||||||
ConfigService,
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
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 { 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 { BrowserExtensionPromptComponent } from "./vault/components/browser-extension-prompt/browser-extension-prompt.component";
|
||||||
import { SetupExtensionComponent } from "./vault/components/setup-extension/setup-extension.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";
|
import { VaultModule } from "./vault/individual-vault/vault.module";
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
@@ -628,6 +629,7 @@ const routes: Routes = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "vault",
|
path: "vault",
|
||||||
|
canActivate: [setupExtensionRedirectGuard],
|
||||||
loadChildren: () => VaultModule,
|
loadChildren: () => VaultModule,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,7 +18,13 @@
|
|||||||
>
|
>
|
||||||
{{ "getTheExtension" | i18n }}
|
{{ "getTheExtension" | i18n }}
|
||||||
</a>
|
</a>
|
||||||
<a bitButton buttonType="secondary" routerLink="/vault" bitDialogClose>
|
<a
|
||||||
|
bitButton
|
||||||
|
buttonType="secondary"
|
||||||
|
routerLink="/vault"
|
||||||
|
bitDialogClose
|
||||||
|
(click)="dismissExtensionPage()"
|
||||||
|
>
|
||||||
{{ "skipToWebApp" | i18n }}
|
{{ "skipToWebApp" | i18n }}
|
||||||
</a>
|
</a>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DialogRef } from "@angular/cdk/dialog";
|
||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
import { By } from "@angular/platform-browser";
|
import { By } from "@angular/platform-browser";
|
||||||
import { provideNoopAnimations } from "@angular/platform-browser/animations";
|
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.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";
|
import { AddExtensionLaterDialogComponent } from "./add-extension-later-dialog.component";
|
||||||
|
|
||||||
describe("AddExtensionLaterDialogComponent", () => {
|
describe("AddExtensionLaterDialogComponent", () => {
|
||||||
let fixture: ComponentFixture<AddExtensionLaterDialogComponent>;
|
let fixture: ComponentFixture<AddExtensionLaterDialogComponent>;
|
||||||
const getDevice = jest.fn().mockReturnValue(null);
|
const getDevice = jest.fn().mockReturnValue(null);
|
||||||
|
const onDismiss = jest.fn();
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
onDismiss.mockClear();
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [AddExtensionLaterDialogComponent, RouterModule.forRoot([])],
|
imports: [AddExtensionLaterDialogComponent, RouterModule.forRoot([])],
|
||||||
providers: [
|
providers: [
|
||||||
provideNoopAnimations(),
|
provideNoopAnimations(),
|
||||||
{ provide: PlatformUtilsService, useValue: { getDevice } },
|
{ provide: PlatformUtilsService, useValue: { getDevice } },
|
||||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||||
|
{ provide: DialogRef, useValue: { close: jest.fn() } },
|
||||||
|
{ provide: DIALOG_DATA, useValue: { onDismiss } },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
@@ -39,4 +46,11 @@ describe("AddExtensionLaterDialogComponent", () => {
|
|||||||
|
|
||||||
expect(skipLink.attributes.href).toBe("/vault");
|
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 { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url";
|
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({
|
@Component({
|
||||||
selector: "vault-add-extension-later-dialog",
|
selector: "vault-add-extension-later-dialog",
|
||||||
@@ -13,6 +23,7 @@ import { ButtonComponent, DialogModule, TypographyModule } from "@bitwarden/comp
|
|||||||
})
|
})
|
||||||
export class AddExtensionLaterDialogComponent implements OnInit {
|
export class AddExtensionLaterDialogComponent implements OnInit {
|
||||||
private platformUtilsService = inject(PlatformUtilsService);
|
private platformUtilsService = inject(PlatformUtilsService);
|
||||||
|
private data: AddExtensionLaterDialogData = inject(DIALOG_DATA);
|
||||||
|
|
||||||
/** Download Url for the extension based on the browser */
|
/** Download Url for the extension based on the browser */
|
||||||
protected webStoreUrl: string = "";
|
protected webStoreUrl: string = "";
|
||||||
@@ -20,4 +31,8 @@ export class AddExtensionLaterDialogComponent implements OnInit {
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.webStoreUrl = getWebStoreUrl(this.platformUtilsService.getDevice());
|
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 { Router, RouterModule } from "@angular/router";
|
||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { DeviceType } from "@bitwarden/common/enums";
|
import { DeviceType } from "@bitwarden/common/enums";
|
||||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
|
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||||
|
|
||||||
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
|
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
|
||||||
|
|
||||||
@@ -21,11 +23,13 @@ describe("SetupExtensionComponent", () => {
|
|||||||
const getFeatureFlag = jest.fn().mockResolvedValue(false);
|
const getFeatureFlag = jest.fn().mockResolvedValue(false);
|
||||||
const navigate = jest.fn().mockResolvedValue(true);
|
const navigate = jest.fn().mockResolvedValue(true);
|
||||||
const openExtension = jest.fn().mockResolvedValue(true);
|
const openExtension = jest.fn().mockResolvedValue(true);
|
||||||
|
const update = jest.fn().mockResolvedValue(true);
|
||||||
const extensionInstalled$ = new BehaviorSubject<boolean | null>(null);
|
const extensionInstalled$ = new BehaviorSubject<boolean | null>(null);
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
navigate.mockClear();
|
navigate.mockClear();
|
||||||
openExtension.mockClear();
|
openExtension.mockClear();
|
||||||
|
update.mockClear();
|
||||||
getFeatureFlag.mockClear().mockResolvedValue(true);
|
getFeatureFlag.mockClear().mockResolvedValue(true);
|
||||||
window.matchMedia = jest.fn().mockReturnValue(false);
|
window.matchMedia = jest.fn().mockReturnValue(false);
|
||||||
|
|
||||||
@@ -36,6 +40,14 @@ describe("SetupExtensionComponent", () => {
|
|||||||
{ provide: ConfigService, useValue: { getFeatureFlag } },
|
{ provide: ConfigService, useValue: { getFeatureFlag } },
|
||||||
{ provide: WebBrowserInteractionService, useValue: { extensionInstalled$, openExtension } },
|
{ provide: WebBrowserInteractionService, useValue: { extensionInstalled$, openExtension } },
|
||||||
{ provide: PlatformUtilsService, useValue: { getDevice: () => DeviceType.UnknownBrowser } },
|
{ provide: PlatformUtilsService, useValue: { getDevice: () => DeviceType.UnknownBrowser } },
|
||||||
|
{
|
||||||
|
provide: AccountService,
|
||||||
|
useValue: { activeAccount$: new BehaviorSubject({ account: { id: "account-id" } }) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: StateProvider,
|
||||||
|
useValue: { getUser: () => ({ update }) },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
@@ -120,6 +132,10 @@ describe("SetupExtensionComponent", () => {
|
|||||||
|
|
||||||
expect(openExtension).toHaveBeenCalled();
|
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 { Component, DestroyRef, inject, OnDestroy, OnInit } from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { Router, RouterModule } from "@angular/router";
|
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 { 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
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 { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||||
import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url";
|
import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url";
|
||||||
import {
|
import {
|
||||||
@@ -20,9 +23,13 @@ import {
|
|||||||
} from "@bitwarden/components";
|
} from "@bitwarden/components";
|
||||||
import { VaultIcons } from "@bitwarden/vault";
|
import { VaultIcons } from "@bitwarden/vault";
|
||||||
|
|
||||||
|
import { SETUP_EXTENSION_DISMISSED } from "../../guards/setup-extension-redirect.guard";
|
||||||
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
|
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";
|
import { AddExtensionVideosComponent } from "./add-extension-videos.component";
|
||||||
|
|
||||||
const SetupExtensionState = {
|
const SetupExtensionState = {
|
||||||
@@ -53,6 +60,8 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
|
|||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
private platformUtilsService = inject(PlatformUtilsService);
|
private platformUtilsService = inject(PlatformUtilsService);
|
||||||
private dialogService = inject(DialogService);
|
private dialogService = inject(DialogService);
|
||||||
|
private stateProvider = inject(StateProvider);
|
||||||
|
private accountService = inject(AccountService);
|
||||||
private document = inject(DOCUMENT);
|
private document = inject(DOCUMENT);
|
||||||
|
|
||||||
protected SetupExtensionState = SetupExtensionState;
|
protected SetupExtensionState = SetupExtensionState;
|
||||||
@@ -96,6 +105,7 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
|
|||||||
// Extension was not installed and now it is, show success state
|
// Extension was not installed and now it is, show success state
|
||||||
if (previousState === false && currentState) {
|
if (previousState === false && currentState) {
|
||||||
this.dialogRef?.close();
|
this.dialogRef?.close();
|
||||||
|
void this.dismissExtensionPage();
|
||||||
this.state = SetupExtensionState.Success;
|
this.state = SetupExtensionState.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,17 +135,31 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
|
|||||||
const isMobile = Utils.isMobileBrowser;
|
const isMobile = Utils.isMobileBrowser;
|
||||||
|
|
||||||
if (!isFeatureEnabled || isMobile) {
|
if (!isFeatureEnabled || isMobile) {
|
||||||
|
await this.dismissExtensionPage();
|
||||||
await this.router.navigate(["/vault"]);
|
await this.router.navigate(["/vault"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens the add extension later dialog */
|
/** Opens the add extension later dialog */
|
||||||
addItLater() {
|
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 */
|
/** Opens the browser extension */
|
||||||
openExtension() {
|
openExtension() {
|
||||||
void this.webBrowserExtensionInteractionService.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);
|
expect(installed).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
tick(1500);
|
tick(150);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it("returns true when the extension is installed", (done) => {
|
it("returns true when the extension is installed", (done) => {
|
||||||
@@ -58,13 +58,13 @@ describe("WebBrowserInteractionService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// initial timeout, should emit false
|
// initial timeout, should emit false
|
||||||
tick(1500);
|
tick(26);
|
||||||
expect(results[0]).toBe(false);
|
expect(results[0]).toBe(false);
|
||||||
|
|
||||||
tick(2500);
|
tick(2500);
|
||||||
// then emit `HasBwInstalled`
|
// then emit `HasBwInstalled`
|
||||||
dispatchEvent(VaultMessages.HasBwInstalled);
|
dispatchEvent(VaultMessages.HasBwInstalled);
|
||||||
tick();
|
tick(26);
|
||||||
expect(results[1]).toBe(true);
|
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";
|
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.
|
* 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({
|
@Injectable({
|
||||||
providedIn: "root",
|
providedIn: "root",
|
||||||
@@ -63,7 +72,7 @@ export class WebBrowserInteractionService {
|
|||||||
filter((event) => event.data.command === VaultMessages.PopupOpened),
|
filter((event) => event.data.command === VaultMessages.PopupOpened),
|
||||||
map(() => true),
|
map(() => true),
|
||||||
),
|
),
|
||||||
timer(MESSAGE_RESPONSE_TIMEOUT_MS).pipe(map(() => false)),
|
timer(OPEN_RESPONSE_TIMEOUT_MS).pipe(map(() => false)),
|
||||||
)
|
)
|
||||||
.pipe(take(1))
|
.pipe(take(1))
|
||||||
.subscribe((didOpen) => {
|
.subscribe((didOpen) => {
|
||||||
@@ -85,7 +94,7 @@ export class WebBrowserInteractionService {
|
|||||||
filter((event) => event.data.command === VaultMessages.HasBwInstalled),
|
filter((event) => event.data.command === VaultMessages.HasBwInstalled),
|
||||||
map(() => true),
|
map(() => true),
|
||||||
),
|
),
|
||||||
timer(MESSAGE_RESPONSE_TIMEOUT_MS).pipe(map(() => false)),
|
timer(CHECK_FOR_EXTENSION_TIMEOUT_MS).pipe(map(() => false)),
|
||||||
).pipe(
|
).pipe(
|
||||||
tap({
|
tap({
|
||||||
subscribe: () => {
|
subscribe: () => {
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
determineLoginSuccessRoute(): Promise<string> {
|
|
||||||
return Promise.resolve("/vault");
|
|
||||||
}
|
|
||||||
|
|
||||||
async finishRegistration(
|
async finishRegistration(
|
||||||
email: string,
|
email: string,
|
||||||
passwordInputResult: PasswordInputResult,
|
passwordInputResult: PasswordInputResult,
|
||||||
|
|||||||
@@ -204,8 +204,7 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
await this.loginSuccessHandlerService.run(authenticationResult.userId);
|
await this.loginSuccessHandlerService.run(authenticationResult.userId);
|
||||||
|
|
||||||
const successRoute = await this.registrationFinishService.determineLoginSuccessRoute();
|
await this.router.navigate(["/vault"]);
|
||||||
await this.router.navigate([successRoute]);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If login errors, redirect to login page per product. Don't show error
|
// If login errors, redirect to login page per product. Don't show error
|
||||||
this.logService.error("Error logging in after registration: ", e.message);
|
this.logService.error("Error logging in after registration: ", e.message);
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ export abstract class RegistrationFinishService {
|
|||||||
*/
|
*/
|
||||||
abstract getMasterPasswordPolicyOptsFromOrgInvite(): Promise<MasterPasswordPolicyOptions | null>;
|
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.
|
* 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 AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
|
||||||
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");
|
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");
|
||||||
export const NUDGES_DISK = new StateDefinition("nudges", "disk", { web: "disk-local" });
|
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(
|
export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition(
|
||||||
"vaultBrowserIntroCarousel",
|
"vaultBrowserIntroCarousel",
|
||||||
"disk",
|
"disk",
|
||||||
|
|||||||
Reference in New Issue
Block a user