PM-3231 Vault Onboarding Part 1 (#6905)

* Onboarding Component moved to web for sharing. Vault Onboarding Component created for new users. Still behind feature flag.
This commit is contained in:
Jason Ng 2024-02-12 11:43:43 -05:00 committed by GitHub
parent 373a865a76
commit fd8c26601a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 472 additions and 26 deletions

View File

@ -12,7 +12,7 @@
<a bitLink *ngIf="route" [routerLink]="route">
<ng-container *ngTemplateOutlet="content"></ng-container>
</a>
<button type="button" bitLink *ngIf="!route">
<button type="button" bitLink *ngIf="!route" [disabled]="isDisabled">
<ng-container *ngTemplateOutlet="content"></ng-container>
</button>
<div

View File

@ -1,7 +1,7 @@
import { Component, Input } from "@angular/core";
@Component({
selector: "sm-onboarding-task",
selector: "app-onboarding-task",
templateUrl: "./onboarding-task.component.html",
host: {
class: "tw-max-w-max",
@ -20,6 +20,9 @@ export class OnboardingTaskComponent {
@Input()
route: string | any[];
@Input()
isDisabled: boolean = false;
handleClick(ev: MouseEvent) {
/**
* If the main `ng-content` is clicked, we don't want to trigger the task's click handler.

View File

@ -3,7 +3,7 @@ import { Component, ContentChildren, EventEmitter, Input, Output, QueryList } fr
import { OnboardingTaskComponent } from "./onboarding-task.component";
@Component({
selector: "sm-onboarding",
selector: "app-onboarding",
templateUrl: "./onboarding.component.html",
})
export class OnboardingComponent {

View File

@ -1,7 +1,8 @@
import { NgModule } from "@angular/core";
import { ProgressModule } from "@bitwarden/components";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { SharedModule } from "../../shared.module";
import { OnboardingTaskComponent } from "./onboarding-task.component";
import { OnboardingComponent } from "./onboarding.component";

View File

@ -5,7 +5,8 @@ import { delay, of, startWith } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LinkModule, IconModule, ProgressModule } from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/app/core/tests";
import { PreloadedEnglishI18nModule } from "../../../core/tests";
import { OnboardingTaskComponent } from "./onboarding-task.component";
import { OnboardingComponent } from "./onboarding.component";
@ -36,8 +37,8 @@ const Template: Story = (args) => ({
...args,
},
template: `
<sm-onboarding title="Get started">
<sm-onboarding-task
<app-onboarding title="Get started">
<app-onboarding-task
[title]="'createServiceAccount' | i18n"
icon="bwi-cli"
[completed]="createServiceAccount"
@ -45,23 +46,23 @@ const Template: Story = (args) => ({
<span>
{{ "downloadThe" | i18n }} <a bitLink routerLink="">{{ "smCLI" | i18n }}</a>
</span>
</sm-onboarding-task>
<sm-onboarding-task
</app-onboarding-task>
<app-onboarding-task
[title]="'createProject' | i18n"
icon="bwi-collection"
[completed]="createProject"
></sm-onboarding-task>
<sm-onboarding-task
></app-onboarding-task>
<app-onboarding-task
[title]="'importSecrets' | i18n"
icon="bwi-download"
[completed]="importSecrets$ | async"
></sm-onboarding-task>
<sm-onboarding-task
></app-onboarding-task>
<app-onboarding-task
[title]="'createSecret' | i18n"
icon="bwi-key"
[completed]="createSecret"
></sm-onboarding-task>
</sm-onboarding>
></app-onboarding-task>
</app-onboarding>
`,
});

View File

@ -0,0 +1,8 @@
import { Observable } from "rxjs";
import { VaultOnboardingTasks } from "../vault-onboarding.service";
export abstract class VaultOnboardingService {
vaultOnboardingState$: Observable<VaultOnboardingTasks>;
abstract setVaultOnboardingTasks(newState: VaultOnboardingTasks): Promise<void>;
}

View File

@ -0,0 +1,38 @@
import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import {
ActiveUserState,
KeyDefinition,
StateProvider,
VAULT_ONBOARDING,
} from "@bitwarden/common/platform/state";
import { VaultOnboardingService as VaultOnboardingServiceAbstraction } from "./abstraction/vault-onboarding.service";
export type VaultOnboardingTasks = {
createAccount: boolean;
importData: boolean;
installExtension: boolean;
};
const VAULT_ONBOARDING_KEY = new KeyDefinition<VaultOnboardingTasks>(VAULT_ONBOARDING, "tasks", {
deserializer: (jsonData) => jsonData,
});
@Injectable()
export class VaultOnboardingService implements VaultOnboardingServiceAbstraction {
private vaultOnboardingState: ActiveUserState<VaultOnboardingTasks>;
vaultOnboardingState$: Observable<VaultOnboardingTasks>;
constructor(private stateProvider: StateProvider) {
this.vaultOnboardingState = this.stateProvider.getActive(VAULT_ONBOARDING_KEY);
this.vaultOnboardingState$ = this.vaultOnboardingState.state$;
}
async setVaultOnboardingTasks(newState: VaultOnboardingTasks): Promise<void> {
await this.vaultOnboardingState.update(() => {
return { ...newState };
});
}
}

View File

@ -0,0 +1,44 @@
<div
*ngIf="
isNewAccount && showOnboarding && (showOnboardingAccess$ | async) && onboardingTasks$
| async as onboardingTasks
"
class="tw-mb-6"
>
<app-onboarding
*ngIf="onboardingTasks"
[title]="'getStarted' | i18n"
(dismiss)="hideOnboarding()"
>
<app-onboarding-task
[title]="'createAnAccount' | i18n"
[completed]="onboardingTasks.createAccount"
[isDisabled]="true"
></app-onboarding-task>
<app-onboarding-task
[title]="'importData' | i18n"
icon="bwi-save"
[route]="['/tools/import']"
[completed]="onboardingTasks.importData"
>
<p class="tw-pl-1">
{{ "onboardingImportDataDetailsPartOne" | i18n }}
<button type="button" bitLink (click)="emitToAddCipher()">
{{ "onboardingImportDataDetailsLink" | i18n }}
</button>
{{ "onboardingImportDataDetailsPartTwo" | i18n }}
</p>
</app-onboarding-task>
<app-onboarding-task
[title]="'installBrowserExtension' | i18n"
icon="bwi-cli"
(click)="navigateToExtension()"
>
<span class="tw-pl-1">
{{ "installBrowserExtensionDetails" | i18n }}
</span>
</app-onboarding-task>
</app-onboarding>
</div>

View File

@ -0,0 +1,146 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { RouterTestingModule } from "@angular/router/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { VaultOnboardingService as VaultOnboardingServiceAbstraction } from "./services/abstraction/vault-onboarding.service";
import { VaultOnboardingComponent } from "./vault-onboarding.component";
describe("VaultOnboardingComponent", () => {
let component: VaultOnboardingComponent;
let fixture: ComponentFixture<VaultOnboardingComponent>;
let mockPlatformUtilsService: Partial<PlatformUtilsService>;
let mockApiService: Partial<ApiService>;
let mockPolicyService: MockProxy<PolicyService>;
let mockI18nService: MockProxy<I18nService>;
let mockConfigService: MockProxy<ConfigServiceAbstraction>;
let mockVaultOnboardingService: MockProxy<VaultOnboardingServiceAbstraction>;
let mockStateProvider: Partial<StateProvider>;
let setInstallExtLinkSpy: any;
let individualVaultPolicyCheckSpy: any;
beforeEach(() => {
mockPolicyService = mock<PolicyService>();
mockI18nService = mock<I18nService>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
mockApiService = {
getProfile: jest.fn(),
};
mockConfigService = mock<ConfigServiceAbstraction>();
mockVaultOnboardingService = mock<VaultOnboardingServiceAbstraction>();
mockStateProvider = {
getActive: jest.fn().mockReturnValue(
of({
vaultTasks: {
createAccount: true,
importData: false,
installExtension: false,
},
}),
),
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
declarations: [],
imports: [RouterTestingModule],
providers: [
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
{ provide: PolicyService, useValue: mockPolicyService },
{ provide: VaultOnboardingServiceAbstraction, useValue: mockVaultOnboardingService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: ApiService, useValue: mockApiService },
{ provide: ConfigServiceAbstraction, useValue: mockConfigService },
{ provide: StateProvider, useValue: mockStateProvider },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(VaultOnboardingComponent);
component = fixture.componentInstance;
setInstallExtLinkSpy = jest.spyOn(component, "setInstallExtLink");
individualVaultPolicyCheckSpy = jest
.spyOn(component, "individualVaultPolicyCheck")
.mockReturnValue(undefined);
jest.spyOn(component, "checkCreationDate").mockReturnValue(null);
(component as any).vaultOnboardingService.vaultOnboardingState$ = of({
createAccount: true,
importData: false,
installExtension: false,
});
});
it("should create", () => {
expect(component).toBeTruthy();
});
describe("ngOnInit", () => {
it("should call setInstallExtLink", async () => {
await component.ngOnInit();
expect(setInstallExtLinkSpy).toHaveBeenCalled();
});
it("should call individualVaultPolicyCheck", async () => {
await component.ngOnInit();
expect(individualVaultPolicyCheckSpy).toHaveBeenCalled();
});
});
describe("show and hide onboarding component", () => {
it("should set showOnboarding to true", async () => {
await component.ngOnInit();
expect((component as any).showOnboarding).toBe(true);
});
it("should set showOnboarding to false if dismiss is clicked", async () => {
await component.ngOnInit();
(component as any).hideOnboarding();
expect((component as any).showOnboarding).toBe(false);
});
});
describe("setInstallExtLink", () => {
it("should set extensionUrl to Chrome Web Store when isChrome is true", async () => {
jest.spyOn((component as any).platformUtilsService, "isChrome").mockReturnValue(true);
const expected =
"https://chrome.google.com/webstore/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb";
await component.ngOnInit();
expect(component.extensionUrl).toEqual(expected);
});
it("should set extensionUrl to Firefox Store when isFirefox is true", async () => {
jest.spyOn((component as any).platformUtilsService, "isFirefox").mockReturnValue(true);
const expected = "https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/";
await component.ngOnInit();
expect(component.extensionUrl).toEqual(expected);
});
it("should set extensionUrl when isSafari is true", async () => {
jest.spyOn((component as any).platformUtilsService, "isSafari").mockReturnValue(true);
const expected = "https://apps.apple.com/us/app/bitwarden/id1352778147?mt=12";
await component.ngOnInit();
expect(component.extensionUrl).toEqual(expected);
});
});
describe("individualVaultPolicyCheck", () => {
it("should set isIndividualPolicyVault to true", async () => {
individualVaultPolicyCheckSpy.mockRestore();
const spy = jest
.spyOn((component as any).policyService, "policyAppliesToActiveUser$")
.mockReturnValue(of(true));
await component.individualVaultPolicyCheck();
fixture.detectChanges();
expect(spy).toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,165 @@
import { CommonModule } from "@angular/common";
import {
Component,
OnInit,
Input,
Output,
EventEmitter,
OnDestroy,
SimpleChanges,
OnChanges,
} from "@angular/core";
import { Router } from "@angular/router";
import { Subject, takeUntil, Observable, firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LinkModule } from "@bitwarden/components";
import { OnboardingModule } from "../../../shared/components/onboarding/onboarding.module";
import { VaultOnboardingService as VaultOnboardingServiceAbstraction } from "./services/abstraction/vault-onboarding.service";
import { VaultOnboardingTasks } from "./services/vault-onboarding.service";
@Component({
standalone: true,
imports: [OnboardingModule, CommonModule, JslibModule, LinkModule],
selector: "app-vault-onboarding",
templateUrl: "vault-onboarding.component.html",
})
export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy {
@Input() ciphers: CipherView[];
@Output() onAddCipher = new EventEmitter<void>();
extensionUrl: string;
isIndividualPolicyVault: boolean;
private destroy$ = new Subject<void>();
isNewAccount: boolean;
private readonly onboardingReleaseDate = new Date("2024-01-01");
showOnboardingAccess$: Observable<boolean>;
protected currentTasks: VaultOnboardingTasks;
protected onboardingTasks$: Observable<VaultOnboardingTasks>;
protected showOnboarding = false;
constructor(
protected platformUtilsService: PlatformUtilsService,
protected policyService: PolicyService,
protected router: Router,
private apiService: ApiService,
private configService: ConfigServiceAbstraction,
private vaultOnboardingService: VaultOnboardingServiceAbstraction,
) {}
async ngOnInit() {
this.showOnboardingAccess$ = await this.configService.getFeatureFlag$<boolean>(
FeatureFlag.VaultOnboarding,
false,
);
this.onboardingTasks$ = this.vaultOnboardingService.vaultOnboardingState$;
await this.setOnboardingTasks();
this.setInstallExtLink();
this.individualVaultPolicyCheck();
}
async ngOnChanges(changes: SimpleChanges) {
if (this.showOnboarding && changes?.ciphers) {
await this.saveCompletedTasks({
createAccount: true,
importData: this.ciphers.length > 0,
installExtension: false,
});
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
async checkCreationDate() {
const userProfile = await this.apiService.getProfile();
const profileCreationDate = new Date(userProfile.creationDate);
this.isNewAccount = this.onboardingReleaseDate < profileCreationDate ? true : false;
if (!this.isNewAccount) {
await this.hideOnboarding();
}
}
protected async hideOnboarding() {
await this.saveCompletedTasks({
createAccount: true,
importData: true,
installExtension: true,
});
}
async setOnboardingTasks() {
const currentTasks = await firstValueFrom(this.onboardingTasks$);
if (currentTasks == null) {
const freshStart = {
createAccount: true,
importData: this.ciphers?.length > 0,
installExtension: false,
};
await this.saveCompletedTasks(freshStart);
} else if (currentTasks) {
this.showOnboarding = Object.values(currentTasks).includes(false);
}
if (this.showOnboarding) {
await this.checkCreationDate();
}
}
private async saveCompletedTasks(vaultTasks: VaultOnboardingTasks) {
this.showOnboarding = Object.values(vaultTasks).includes(false);
await this.vaultOnboardingService.setVaultOnboardingTasks(vaultTasks);
}
individualVaultPolicyCheck() {
this.policyService
.policyAppliesToActiveUser$(PolicyType.PersonalOwnership)
.pipe(takeUntil(this.destroy$))
.subscribe((data) => {
this.isIndividualPolicyVault = data;
});
}
emitToAddCipher() {
this.onAddCipher.emit();
}
setInstallExtLink() {
if (this.platformUtilsService.isChrome()) {
this.extensionUrl =
"https://chrome.google.com/webstore/detail/bitwarden-free-password-m/nngceckbapebfimnlniiiahkandclblb";
} else if (this.platformUtilsService.isFirefox()) {
this.extensionUrl =
"https://addons.mozilla.org/en-US/firefox/addon/bitwarden-password-manager/";
} else if (this.platformUtilsService.isSafari()) {
this.extensionUrl = "https://apps.apple.com/us/app/bitwarden/id1352778147?mt=12";
} else if (this.platformUtilsService.isOpera()) {
this.extensionUrl =
"https://addons.opera.com/extensions/details/bitwarden-free-password-manager/";
} else if (this.platformUtilsService.isEdge()) {
this.extensionUrl =
"https://microsoftedge.microsoft.com/addons/detail/jbkfoedolllekgbhcbcoahefnbanhhlh";
} else {
this.extensionUrl = "https://bitwarden.com/download/#downloads-web-browser";
}
}
navigateToExtension() {
window.open(this.extensionUrl, "_blank");
}
}

View File

@ -1,4 +1,6 @@
<div class="container page-content">
<app-vault-onboarding [ciphers]="ciphers" (onAddCipher)="addCipher()"> </app-vault-onboarding>
<div class="row">
<div class="col-3">
<div class="groupings">

View File

@ -13,6 +13,9 @@ import { OrganizationBadgeModule } from "./organization-badge/organization-badge
import { PipesModule } from "./pipes/pipes.module";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
import { VaultOnboardingService as VaultOnboardingServiceAbstraction } from "./vault-onboarding/services/abstraction/vault-onboarding.service";
import { VaultOnboardingService } from "./vault-onboarding/services/vault-onboarding.service";
import { VaultOnboardingComponent } from "./vault-onboarding/vault-onboarding.component";
import { VaultRoutingModule } from "./vault-routing.module";
import { VaultComponent } from "./vault.component";
@ -30,8 +33,15 @@ import { VaultComponent } from "./vault.component";
BreadcrumbsModule,
VaultItemsModule,
CollectionDialogModule,
VaultOnboardingComponent,
],
declarations: [VaultComponent, VaultHeaderComponent],
exports: [VaultComponent],
providers: [
{
provide: VaultOnboardingServiceAbstraction,
useClass: VaultOnboardingService,
},
],
})
export class VaultModule {}

View File

@ -1347,6 +1347,18 @@
"importData": {
"message": "Import data"
},
"onboardingImportDataDetailsPartOne": {
"message": "If you don't have any data to import, you can create a ",
"description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. You may need to wait until your administrator confirms your organization membership."
},
"onboardingImportDataDetailsLink": {
"message": "new item",
"description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. You may need to wait until your administrator confirms your organization membership."
},
"onboardingImportDataDetailsPartTwo": {
"message": " instead. You may need to wait until your administrator confirms your organization membership.",
"description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. You may need to wait until your administrator confirms your organization membership."
},
"importError": {
"message": "Import error"
},
@ -6912,6 +6924,9 @@
"message": "SDK",
"description": "Software Development Kit"
},
"createAnAccount": {
"message": "Create an account"
},
"createSecret": {
"message": "Create a secret"
},
@ -7456,6 +7471,12 @@
"message": "See detailed instructions on our help site at",
"description": "This is followed a by a hyperlink to the help website."
},
"installBrowserExtension": {
"message": "Install browser extension"
},
"installBrowserExtensionDetails": {
"message": "Use the extension to quickly save logins and auto-fill forms without opening the web app."
},
"projectAccessUpdated": {
"message": "Project access updated"
},

View File

@ -3,8 +3,8 @@
</app-header>
<div *ngIf="!loading && view$ | async as view; else spinner">
<sm-onboarding [title]="'getStarted' | i18n" *ngIf="showOnboarding" (dismiss)="hideOnboarding()">
<sm-onboarding-task
<app-onboarding [title]="'getStarted' | i18n" *ngIf="showOnboarding" (dismiss)="hideOnboarding()">
<app-onboarding-task
[title]="'createServiceAccount' | i18n"
(click)="openServiceAccountDialog()"
icon="bwi-cli"
@ -16,29 +16,29 @@
"smCLI" | i18n
}}</a>
</span>
</sm-onboarding-task>
<sm-onboarding-task
</app-onboarding-task>
<app-onboarding-task
*ngIf="userIsAdmin"
[title]="'createProject' | i18n"
(click)="openNewProjectDialog()"
icon="bwi-collection"
[completed]="view.tasks.createProject"
></sm-onboarding-task>
<sm-onboarding-task
></app-onboarding-task>
<app-onboarding-task
*ngIf="userIsAdmin"
[title]="'importSecrets' | i18n"
[route]="['settings', 'import']"
icon="bwi-download"
[completed]="view.tasks.importSecrets"
></sm-onboarding-task>
<sm-onboarding-task
></app-onboarding-task>
<app-onboarding-task
*ngIf="view.tasks.createProject"
[title]="'createSecret' | i18n"
(click)="openSecretDialog()"
icon="bwi-key"
[completed]="view.tasks.createSecret"
></sm-onboarding-task>
</sm-onboarding>
></app-onboarding-task>
</app-onboarding>
<div class="tw-mt-6 tw-flex tw-flex-col tw-gap-6">
<sm-section>

View File

@ -1,8 +1,8 @@
import { NgModule } from "@angular/core";
import { OnboardingModule } from "../../../../../../apps/web/src/app/shared/components/onboarding/onboarding.module";
import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
import { OnboardingModule } from "./onboarding.module";
import { OverviewRoutingModule } from "./overview-routing.module";
import { OverviewComponent } from "./overview.component";
import { SectionComponent } from "./section.component";

View File

@ -4,6 +4,7 @@ export enum FeatureFlag {
ItemShare = "item-share",
FlexibleCollectionsV1 = "flexible-collections-v-1", // v-1 is intentional
BulkCollectionAccess = "bulk-collection-access",
VaultOnboarding = "vault-onboarding",
GeneratorToolsModernization = "generator-tools-modernization",
KeyRotationImprovements = "key-rotation-improvements",
FlexibleCollectionsMigration = "flexible-collections-migration",

View File

@ -16,6 +16,7 @@ export class ProfileResponse extends BaseResponse {
twoFactorEnabled: boolean;
key: string;
avatarColor: string;
creationDate: string;
privateKey: string;
securityStamp: string;
forcePasswordReset: boolean;
@ -37,6 +38,7 @@ export class ProfileResponse extends BaseResponse {
this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled");
this.key = this.getResponseProperty("Key");
this.avatarColor = this.getResponseProperty("AvatarColor");
this.creationDate = this.getResponseProperty("CreationDate");
this.privateKey = this.getResponseProperty("PrivateKey");
this.securityStamp = this.getResponseProperty("SecurityStamp");
this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset") ?? false;

View File

@ -27,6 +27,10 @@ export const SSO_DISK = new StateDefinition("ssoLogin", "disk");
export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk");
export const VAULT_ONBOARDING = new StateDefinition("vaultOnboarding", "disk", {
web: "disk-local",
});
export const GENERATOR_DISK = new StateDefinition("generator", "disk");
export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");