[SM-537] add local storage persistence for onboarding tasks (#4880)

* add local storage check for tasks

* associate saved tasks with organization ID

* remove redundant parenthesis

* revert last commit

* add falsy check

* use distinctUntilChanged

* remove extra observable

* apply code review
This commit is contained in:
Will Martin 2023-03-07 14:03:51 -05:00 committed by GitHub
parent e153105774
commit 0ff3c679c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 93 additions and 20 deletions

View File

@ -2,7 +2,7 @@
<sm-new-menu></sm-new-menu> <sm-new-menu></sm-new-menu>
</sm-header> </sm-header>
<div *ngIf="view$ | async as view; else spinner"> <div *ngIf="!loading && view$ | async as view; else spinner">
<sm-onboarding [title]="'getStarted' | i18n" *ngIf="showOnboarding" (dismiss)="hideOnboarding()"> <sm-onboarding [title]="'getStarted' | i18n" *ngIf="showOnboarding" (dismiss)="hideOnboarding()">
<sm-onboarding-task <sm-onboarding-task
[title]="'createServiceAccount' | i18n" [title]="'createServiceAccount' | i18n"

View File

@ -10,11 +10,13 @@ import {
startWith, startWith,
distinctUntilChanged, distinctUntilChanged,
take, take,
share,
} from "rxjs"; } from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { ProjectListView } from "../models/view/project-list.view"; import { ProjectListView } from "../models/view/project-list.view";
@ -45,6 +47,10 @@ import {
import { ServiceAccountService } from "../service-accounts/service-account.service"; import { ServiceAccountService } from "../service-accounts/service-account.service";
type Tasks = { type Tasks = {
[organizationId: string]: OrganizationTasks;
};
type OrganizationTasks = {
importSecrets: boolean; importSecrets: boolean;
createSecret: boolean; createSecret: boolean;
createProject: boolean; createProject: boolean;
@ -62,13 +68,14 @@ export class OverviewComponent implements OnInit, OnDestroy {
protected organizationName: string; protected organizationName: string;
protected userIsAdmin: boolean; protected userIsAdmin: boolean;
protected showOnboarding = false; protected showOnboarding = false;
protected loading = true;
protected view$: Observable<{ protected view$: Observable<{
allProjects: ProjectListView[]; allProjects: ProjectListView[];
allSecrets: SecretListView[]; allSecrets: SecretListView[];
latestProjects: ProjectListView[]; latestProjects: ProjectListView[];
latestSecrets: SecretListView[]; latestSecrets: SecretListView[];
tasks: Tasks; tasks: OrganizationTasks;
}>; }>;
constructor( constructor(
@ -78,6 +85,7 @@ export class OverviewComponent implements OnInit, OnDestroy {
private serviceAccountService: ServiceAccountService, private serviceAccountService: ServiceAccountService,
private dialogService: DialogService, private dialogService: DialogService,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private stateService: StateService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService private i18nService: I18nService
) {} ) {}
@ -97,37 +105,47 @@ export class OverviewComponent implements OnInit, OnDestroy {
this.organizationId = org.id; this.organizationId = org.id;
this.organizationName = org.name; this.organizationName = org.name;
this.userIsAdmin = org.isAdmin; this.userIsAdmin = org.isAdmin;
this.loading = true;
}); });
const projects$ = combineLatest([ const projects$ = combineLatest([
orgId$, orgId$,
this.projectService.project$.pipe(startWith(null)), this.projectService.project$.pipe(startWith(null)),
]).pipe(switchMap(([orgId]) => this.projectService.getProjects(orgId))); ]).pipe(
switchMap(([orgId]) => this.projectService.getProjects(orgId)),
share()
);
const secrets$ = combineLatest([orgId$, this.secretService.secret$.pipe(startWith(null))]).pipe( const secrets$ = combineLatest([orgId$, this.secretService.secret$.pipe(startWith(null))]).pipe(
switchMap(([orgId]) => this.secretService.getSecrets(orgId)) switchMap(([orgId]) => this.secretService.getSecrets(orgId)),
share()
); );
const serviceAccounts$ = combineLatest([ const serviceAccounts$ = combineLatest([
orgId$, orgId$,
this.serviceAccountService.serviceAccount$.pipe(startWith(null)), this.serviceAccountService.serviceAccount$.pipe(startWith(null)),
]).pipe(switchMap(([orgId]) => this.serviceAccountService.getServiceAccounts(orgId))); ]).pipe(
switchMap(([orgId]) => this.serviceAccountService.getServiceAccounts(orgId)),
share()
);
this.view$ = combineLatest([projects$, secrets$, serviceAccounts$]).pipe( this.view$ = orgId$.pipe(
map(([projects, secrets, serviceAccounts]) => { switchMap((orgId) =>
return { combineLatest([projects$, secrets$, serviceAccounts$]).pipe(
latestProjects: this.getRecentItems(projects, this.tableSize), switchMap(async ([projects, secrets, serviceAccounts]) => ({
latestSecrets: this.getRecentItems(secrets, this.tableSize), latestProjects: this.getRecentItems(projects, this.tableSize),
allProjects: projects, latestSecrets: this.getRecentItems(secrets, this.tableSize),
allSecrets: secrets, allProjects: projects,
tasks: { allSecrets: secrets,
importSecrets: secrets.length > 0, tasks: await this.saveCompletedTasks(orgId, {
createSecret: secrets.length > 0, importSecrets: secrets.length > 0,
createProject: projects.length > 0, createSecret: secrets.length > 0,
createServiceAccount: serviceAccounts.length > 0, createProject: projects.length > 0,
}, createServiceAccount: serviceAccounts.length > 0,
}; }),
}) }))
)
)
); );
// Refresh onboarding status when orgId changes by fetching the first value from view$. // Refresh onboarding status when orgId changes by fetching the first value from view$.
@ -138,6 +156,7 @@ export class OverviewComponent implements OnInit, OnDestroy {
) )
.subscribe((view) => { .subscribe((view) => {
this.showOnboarding = Object.values(view.tasks).includes(false); this.showOnboarding = Object.values(view.tasks).includes(false);
this.loading = false;
}); });
} }
@ -154,6 +173,29 @@ export class OverviewComponent implements OnInit, OnDestroy {
.slice(0, length) as T; .slice(0, length) as T;
} }
private async saveCompletedTasks(
organizationId: string,
orgTasks: OrganizationTasks
): Promise<OrganizationTasks> {
const prevTasks = ((await this.stateService.getSMOnboardingTasks()) || {}) as Tasks;
const newlyCompletedOrgTasks = Object.fromEntries(
Object.entries(orgTasks).filter(([_k, v]) => v === true)
);
const nextOrgTasks = {
importSecrets: false,
createSecret: false,
createProject: false,
createServiceAccount: false,
...prevTasks[organizationId],
...newlyCompletedOrgTasks,
};
this.stateService.setSMOnboardingTasks({
...prevTasks,
[organizationId]: nextOrgTasks,
});
return nextOrgTasks as OrganizationTasks;
}
// Projects --- // Projects ---
openEditProject(projectId: string) { openEditProject(projectId: string) {

View File

@ -357,4 +357,12 @@ export abstract class StateService<T extends Account = Account> {
getAvatarColor: (options?: StorageOptions) => Promise<string | null | undefined>; getAvatarColor: (options?: StorageOptions) => Promise<string | null | undefined>;
setAvatarColor: (value: string, options?: StorageOptions) => Promise<void>; setAvatarColor: (value: string, options?: StorageOptions) => Promise<void>;
getSMOnboardingTasks: (
options?: StorageOptions
) => Promise<Record<string, Record<string, boolean>>>;
setSMOnboardingTasks: (
value: Record<string, Record<string, boolean>>,
options?: StorageOptions
) => Promise<void>;
} }

View File

@ -238,6 +238,7 @@ export class AccountSettings {
serverConfig?: ServerConfigData; serverConfig?: ServerConfigData;
approveLoginRequests?: boolean; approveLoginRequests?: boolean;
avatarColor?: string; avatarColor?: string;
smOnboardingTasks?: Record<string, Record<string, boolean>>;
static fromJSON(obj: Jsonify<AccountSettings>): AccountSettings { static fromJSON(obj: Jsonify<AccountSettings>): AccountSettings {
if (obj == null) { if (obj == null) {

View File

@ -2364,6 +2364,28 @@ export class StateService<
); );
} }
async getSMOnboardingTasks(
options?: StorageOptions
): Promise<Record<string, Record<string, boolean>>> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()))
)?.settings?.smOnboardingTasks;
}
async setSMOnboardingTasks(
value: Record<string, Record<string, boolean>>,
options?: StorageOptions
): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
account.settings.smOnboardingTasks = value;
return await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())
);
}
protected async getGlobals(options: StorageOptions): Promise<TGlobalState> { protected async getGlobals(options: StorageOptions): Promise<TGlobalState> {
let globals: TGlobalState; let globals: TGlobalState;
if (this.useMemory(options.storageLocation)) { if (this.useMemory(options.storageLocation)) {