1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +00:00

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
19 changed files with 472 additions and 26 deletions

View File

@@ -0,0 +1,25 @@
<ng-template #content>
<i class="bwi bwi-fw !tw-mr-4" [ngClass]="completed ? 'bwi-check tw-text-success' : icon"></i
><span
[ngClass]="{
'tw-text-primary-700 tw-line-through tw-decoration-primary-700 tw-opacity-50': completed
}"
>{{ title }}<i class="bwi bwi-angle-right tw-ml-1"></i
></span>
</ng-template>
<li class="tw-list-none">
<a bitLink *ngIf="route" [routerLink]="route">
<ng-container *ngTemplateOutlet="content"></ng-container>
</a>
<button type="button" bitLink *ngIf="!route" [disabled]="isDisabled">
<ng-container *ngTemplateOutlet="content"></ng-container>
</button>
<div
class="tw-ml-8 tw-mt-1 tw-text-sm"
[ngClass]="{ 'tw-opacity-50': completed }"
(click)="handleClick($event)"
>
<ng-content></ng-content>
</div>
</li>

View File

@@ -0,0 +1,32 @@
import { Component, Input } from "@angular/core";
@Component({
selector: "app-onboarding-task",
templateUrl: "./onboarding-task.component.html",
host: {
class: "tw-max-w-max",
},
})
export class OnboardingTaskComponent {
@Input()
completed = false;
@Input()
icon = "bwi-info-circle";
@Input()
title: string;
@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.
*/
ev.stopPropagation();
}
}

View File

@@ -0,0 +1,29 @@
<details #details class="tw-rounded-sm tw-bg-background-alt tw-text-main" (toggle)="toggle()" open>
<summary class="tw-list-none tw-p-2 tw-px-4">
<div class="tw-flex tw-select-none tw-items-center tw-gap-4">
<i class="bwi bwi-dashboard tw-text-3xl tw-text-primary-500" aria-hidden="true"></i>
<div class="tw-text-lg">{{ title }}</div>
<bit-progress class="tw-flex-1" [showText]="false" [barWidth]="barWidth"></bit-progress>
<span *ngIf="tasks.length > 0; else spinner">
{{ "complete" | i18n: amountCompleted : tasks.length }}
</span>
<i
class="bwi tw-my-auto"
[ngClass]="open ? 'bwi-angle-down' : 'bwi-angle-up'"
aria-hidden="true"
></i>
</div>
</summary>
<ul class="tw-mb-0 tw-ml-6 tw-flex tw-flex-col tw-gap-4">
<ng-content></ng-content>
</ul>
<div class="tw-p-4 tw-pt-0">
<button bitLink type="button" class="tw-ml-auto tw-block" (click)="dismiss.emit()">
{{ "dismiss" | i18n }}
</button>
</div>
</details>
<ng-template #spinner>
<i class="bwi bwi-spinner bwi-spin"></i>
</ng-template>

View File

@@ -0,0 +1,29 @@
import { Component, ContentChildren, EventEmitter, Input, Output, QueryList } from "@angular/core";
import { OnboardingTaskComponent } from "./onboarding-task.component";
@Component({
selector: "app-onboarding",
templateUrl: "./onboarding.component.html",
})
export class OnboardingComponent {
@ContentChildren(OnboardingTaskComponent) tasks: QueryList<OnboardingTaskComponent>;
@Input() title: string;
@Output() dismiss = new EventEmitter<void>();
protected open = true;
protected visible = false;
protected get amountCompleted(): number {
return this.tasks.filter((task) => task.completed).length;
}
protected get barWidth(): number {
return this.tasks.length === 0 ? 0 : (this.amountCompleted / this.tasks.length) * 100;
}
protected toggle() {
this.open = !this.open;
}
}

View File

@@ -0,0 +1,15 @@
import { NgModule } from "@angular/core";
import { ProgressModule } from "@bitwarden/components";
import { SharedModule } from "../../shared.module";
import { OnboardingTaskComponent } from "./onboarding-task.component";
import { OnboardingComponent } from "./onboarding.component";
@NgModule({
imports: [SharedModule, ProgressModule],
exports: [OnboardingComponent, OnboardingTaskComponent],
declarations: [OnboardingComponent, OnboardingTaskComponent],
})
export class OnboardingModule {}

View File

@@ -0,0 +1,85 @@
import { importProvidersFrom } from "@angular/core";
import { RouterModule } from "@angular/router";
import { Meta, Story, applicationConfig, moduleMetadata } from "@storybook/angular";
import { delay, of, startWith } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LinkModule, IconModule, ProgressModule } from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "../../../core/tests";
import { OnboardingTaskComponent } from "./onboarding-task.component";
import { OnboardingComponent } from "./onboarding.component";
export default {
title: "Web/Onboarding",
component: OnboardingComponent,
decorators: [
moduleMetadata({
imports: [JslibModule, RouterModule, LinkModule, IconModule, ProgressModule],
declarations: [OnboardingTaskComponent],
}),
applicationConfig({
providers: [
importProvidersFrom(RouterModule.forRoot([], { useHash: true })),
importProvidersFrom(PreloadedEnglishI18nModule),
],
}),
],
} as Meta;
const Template: Story = (args) => ({
props: {
createServiceAccount: false,
importSecrets$: of(false),
createSecret: false,
createProject: false,
...args,
},
template: `
<app-onboarding title="Get started">
<app-onboarding-task
[title]="'createServiceAccount' | i18n"
icon="bwi-cli"
[completed]="createServiceAccount"
>
<span>
{{ "downloadThe" | i18n }} <a bitLink routerLink="">{{ "smCLI" | i18n }}</a>
</span>
</app-onboarding-task>
<app-onboarding-task
[title]="'createProject' | i18n"
icon="bwi-collection"
[completed]="createProject"
></app-onboarding-task>
<app-onboarding-task
[title]="'importSecrets' | i18n"
icon="bwi-download"
[completed]="importSecrets$ | async"
></app-onboarding-task>
<app-onboarding-task
[title]="'createSecret' | i18n"
icon="bwi-key"
[completed]="createSecret"
></app-onboarding-task>
</app-onboarding>
`,
});
export const Empty = Template.bind({});
export const Partial = Template.bind({});
Partial.args = {
...Template.args,
createServiceAccount: true,
createProject: true,
};
export const Full = Template.bind({});
Full.args = {
...Template.args,
createServiceAccount: true,
createProject: true,
createSecret: true,
importSecrets$: of(true).pipe(delay(0), startWith(false)),
};

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