[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-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-task
[title]="'createServiceAccount' | i18n"

View File

@ -10,11 +10,13 @@ import {
startWith,
distinctUntilChanged,
take,
share,
} from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { DialogService } from "@bitwarden/components";
import { ProjectListView } from "../models/view/project-list.view";
@ -45,6 +47,10 @@ import {
import { ServiceAccountService } from "../service-accounts/service-account.service";
type Tasks = {
[organizationId: string]: OrganizationTasks;
};
type OrganizationTasks = {
importSecrets: boolean;
createSecret: boolean;
createProject: boolean;
@ -62,13 +68,14 @@ export class OverviewComponent implements OnInit, OnDestroy {
protected organizationName: string;
protected userIsAdmin: boolean;
protected showOnboarding = false;
protected loading = true;
protected view$: Observable<{
allProjects: ProjectListView[];
allSecrets: SecretListView[];
latestProjects: ProjectListView[];
latestSecrets: SecretListView[];
tasks: Tasks;
tasks: OrganizationTasks;
}>;
constructor(
@ -78,6 +85,7 @@ export class OverviewComponent implements OnInit, OnDestroy {
private serviceAccountService: ServiceAccountService,
private dialogService: DialogService,
private organizationService: OrganizationService,
private stateService: StateService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService
) {}
@ -97,37 +105,47 @@ export class OverviewComponent implements OnInit, OnDestroy {
this.organizationId = org.id;
this.organizationName = org.name;
this.userIsAdmin = org.isAdmin;
this.loading = true;
});
const projects$ = combineLatest([
orgId$,
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(
switchMap(([orgId]) => this.secretService.getSecrets(orgId))
switchMap(([orgId]) => this.secretService.getSecrets(orgId)),
share()
);
const serviceAccounts$ = combineLatest([
orgId$,
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(
map(([projects, secrets, serviceAccounts]) => {
return {
latestProjects: this.getRecentItems(projects, this.tableSize),
latestSecrets: this.getRecentItems(secrets, this.tableSize),
allProjects: projects,
allSecrets: secrets,
tasks: {
importSecrets: secrets.length > 0,
createSecret: secrets.length > 0,
createProject: projects.length > 0,
createServiceAccount: serviceAccounts.length > 0,
},
};
})
this.view$ = orgId$.pipe(
switchMap((orgId) =>
combineLatest([projects$, secrets$, serviceAccounts$]).pipe(
switchMap(async ([projects, secrets, serviceAccounts]) => ({
latestProjects: this.getRecentItems(projects, this.tableSize),
latestSecrets: this.getRecentItems(secrets, this.tableSize),
allProjects: projects,
allSecrets: secrets,
tasks: await this.saveCompletedTasks(orgId, {
importSecrets: secrets.length > 0,
createSecret: secrets.length > 0,
createProject: projects.length > 0,
createServiceAccount: serviceAccounts.length > 0,
}),
}))
)
)
);
// 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) => {
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;
}
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 ---
openEditProject(projectId: string) {

View File

@ -357,4 +357,12 @@ export abstract class StateService<T extends Account = Account> {
getAvatarColor: (options?: StorageOptions) => Promise<string | null | undefined>;
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;
approveLoginRequests?: boolean;
avatarColor?: string;
smOnboardingTasks?: Record<string, Record<string, boolean>>;
static fromJSON(obj: Jsonify<AccountSettings>): AccountSettings {
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> {
let globals: TGlobalState;
if (this.useMemory(options.storageLocation)) {