[PM-35058]welcome modal post scan state (#20672)

This commit is contained in:
Vijay Oommen 2026-05-19 11:28:27 -05:00 committed by GitHub
parent 90d13593d4
commit cdbe896d63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 431 additions and 5 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -208,6 +208,15 @@
}
}
},
"yourDataIsInLetsPutItToWork": {
"message": "Your data is in. Let's put it to work."
},
"takeAQuickTourOfAccessIntelligence": {
"message": "Take a quick tour of Access Intelligence and see exactly how to turn your org's data into action."
},
"startTour": {
"message": "Start tour"
},
"noDataInOrgTitle": {
"message": "No data found"
},

View File

@ -22,6 +22,7 @@ import { DefaultAdminTaskService } from "../../vault/services/default-admin-task
import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module";
import { NewApplicationsDialogComponent } from "./activity/application-review-dialog/new-applications-dialog.component";
import { OnboardingService } from "./onboarding/services/onboarding.service";
import { RiskInsightsComponent } from "./risk-insights.component";
import { AccessIntelligencePageComponent } from "./v2/access-intelligence-page/access-intelligence-page.component";
@ -69,6 +70,7 @@ import { AccessIntelligencePageComponent } from "./v2/access-intelligence-page/a
LogService,
],
}),
safeProvider(OnboardingService),
],
})
export class AccessIntelligenceModule {}

View File

@ -0,0 +1,50 @@
import { inject, Injectable } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
ACCESS_INTELLIGENCE_WELCOME_DIALOG_DISK,
StateProvider,
UserKeyDefinition,
} from "@bitwarden/state";
const ACCESS_INTELLIGENCE_WELCOME_DIALOG_ACKNOWLEDGED_KEY = new UserKeyDefinition<boolean>(
ACCESS_INTELLIGENCE_WELCOME_DIALOG_DISK,
"accessIntelligenceWelcomeDialogCompleted",
{
deserializer: (value) => value,
clearOn: [],
},
);
@Injectable()
export class OnboardingService {
private accountService = inject(AccountService);
private stateProvider = inject(StateProvider);
async isWelcomeDialogAcknowledged(): Promise<boolean> {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (!account) {
return false;
}
const acknowledged = await firstValueFrom(
this.stateProvider
.getUserState$(ACCESS_INTELLIGENCE_WELCOME_DIALOG_ACKNOWLEDGED_KEY, account.id)
.pipe(map((v) => v ?? false)),
);
return acknowledged;
}
async setWelcomeDialogAcknowledged(value = true) {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (account) {
await this.stateProvider.setUserState(
ACCESS_INTELLIGENCE_WELCOME_DIALOG_ACKNOWLEDGED_KEY,
value,
account.id,
);
}
}
}

View File

@ -0,0 +1,21 @@
<bit-dialog dialogSize="large">
<div bitDialogContent class="tw-flex tw-flex-col tw-text-center tw-gap-4">
<div class="tw-flex tw-justify-center tw-items-center tw-mb-4 tw-w-full">
<img src="/images/access-intelligence/data-is-in.svg" class="tw-w-full tw-mx-auto" />
</div>
<div class="tw-flex tw-flex-col tw-w-[65%] tw-mx-auto">
<h2 bitTypography="h2">{{ "yourDataIsInLetsPutItToWork" | i18n }}</h2>
<p bitTypography="body1">
{{ "takeAQuickTourOfAccessIntelligence" | i18n }}
</p>
<div class="tw-flex tw-justify-center tw-gap-6 tw-m-4">
<button type="button" bitButton buttonType="secondary" (click)="onSkip()">
{{ "skip" | i18n }}
</button>
<button type="button" bitButton buttonType="primary" (click)="onStartTour()">
{{ "startTour" | i18n }}
</button>
</div>
</div>
</div>
</bit-dialog>

View File

@ -0,0 +1,46 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { of } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule, DialogModule, DialogRef, TypographyModule } from "@bitwarden/components";
import { OnboardingService } from "./services/onboarding.service";
import { WelcomeModalDialogComponent } from "./welcome-modal-dialog.component";
const mockDialogRef = {
close: jest.fn(),
afterClosed: jest.fn().mockReturnValue(of(undefined)),
closed: of(undefined),
} as unknown as import("@bitwarden/components").DialogRef<any, any>;
describe("WelcomeModalDialogComponent", () => {
let component: WelcomeModalDialogComponent;
let fixture: ComponentFixture<WelcomeModalDialogComponent>;
beforeEach(async () => {
const mockI18nService = {
t: jest.fn((key: string) => key),
};
const mockOnboardingService = {
setWelcomeDialogAcknowledged: jest.fn().mockResolvedValue(undefined),
isWelcomeDialogAcknowledged: jest.fn().mockResolvedValue(false),
};
await TestBed.configureTestingModule({
imports: [WelcomeModalDialogComponent, TypographyModule, ButtonModule, DialogModule],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{ provide: OnboardingService, useValue: mockOnboardingService },
{ provide: DialogRef, useValue: mockDialogRef },
],
}).compileComponents();
fixture = TestBed.createComponent(WelcomeModalDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,67 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Injector,
runInInjectionContext,
} from "@angular/core";
import {
ButtonModule,
DialogModule,
DialogRef,
DialogService,
TypographyModule,
} from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { I18nPipe } from "@bitwarden/ui-common";
import { OnboardingService } from "./services/onboarding.service";
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-welcome-modal-dialog",
imports: [ButtonModule, TypographyModule, DialogModule, I18nPipe],
templateUrl: "./welcome-modal-dialog.component.html",
})
export class WelcomeModalDialogComponent {
private readonly dialogRef = inject(DialogRef<WelcomeModalDialogComponent>);
private readonly onboardingService = inject(OnboardingService);
protected async onStartTour() {
// invoke the dialog here
await this.dialogRef.close();
}
protected async onSkip() {
await this.onboardingService
.setWelcomeDialogAcknowledged()
.then(() => {
return this.dialogRef.close();
})
.catch(() => {});
}
static async showWelcomeDialog(
injector: Injector,
dialogService: DialogService,
): Promise<DialogRef<unknown, WelcomeModalDialogComponent> | undefined> {
return runInInjectionContext(injector, async () => {
const logger = inject(LogService);
const onboardingService = inject(OnboardingService);
const acknowledged = await onboardingService.isWelcomeDialogAcknowledged();
if (acknowledged) {
logger.info(
"[Access Intelligence Onboarding] Welcome dialog already acknowledged, skipping dialog display.",
);
return;
}
const dialog = dialogService.open(WelcomeModalDialogComponent, {
width: "600px",
disableClose: true,
});
return dialog;
});
}
}

View File

@ -106,4 +106,11 @@
}
}
}
@if (isDevMode || adoptionUxImprovementsEnabled) {
<dirt-dev-menu
(beginTour)="beginOnboardingTour()"
(importData)="goToImportPage()"
></dirt-dev-menu>
}
</ng-container>

View File

@ -10,6 +10,8 @@ import {
inject,
signal,
ChangeDetectionStrategy,
isDevMode,
Injector,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
@ -57,6 +59,8 @@ import { ApplicationsComponent } from "./all-applications/applications.component
import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component";
import { EmptyStateCardComponent } from "./empty-state-card.component";
import { RiskInsightsTabType } from "./models/risk-insights.models";
import { WelcomeModalDialogComponent } from "./onboarding/welcome-modal-dialog.component";
import { DevMenuComponent } from "./shared/dev-menu.component";
import { PageLoadingComponent } from "./shared/page-loading.component";
import { ReportLoadingComponent } from "./shared/report-loading.component";
import { RiskInsightsDrawerDialogComponent } from "./shared/risk-insights-drawer-dialog.component";
@ -73,6 +77,7 @@ type ProgressStep = ReportProgress | null;
AsyncActionsModule,
ButtonModule,
CommonModule,
DevMenuComponent,
IconModule,
CriticalApplicationsComponent,
EmptyStateCardComponent,
@ -96,6 +101,8 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
private destroyRef = inject(DestroyRef);
protected ReportStatusEnum = ReportStatus;
protected milestone11Enabled: boolean = false;
protected adoptionUxImprovementsEnabled: boolean = false;
protected isDevMode = isDevMode();
tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllActivity;
@ -137,6 +144,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
private logService: LogService,
private configService: ConfigService,
private toastService: ToastService,
private injector: Injector,
) {
this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef))
@ -176,6 +184,10 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
FeatureFlag.Milestone11AppPageImprovements,
);
this.adoptionUxImprovementsEnabled = await this.configService.getFeatureFlag(
FeatureFlag.AccessIntelligenceAdoptionUxImprovements,
);
// Subscribe to report data updates
// This declarative pattern ensures proper cleanup and prevents memory leaks
this.dataService.enrichedReportData$
@ -266,7 +278,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
});
if (this.invokedFrom()?.source && this.invokedFrom()?.status) {
this.handleReturnParams(this.invokedFrom()?.source, this.invokedFrom()?.status);
await this.handleReturnParams(this.invokedFrom()?.source, this.invokedFrom()?.status);
}
}
@ -372,11 +384,16 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
}
};
private handleReturnParams(source: string | undefined, status: string | undefined): void {
private async handleReturnParams(
source: string | undefined,
status: string | undefined,
): Promise<void> {
if (source === "import" && status === "success") {
this.generateReport();
await this.beginOnboardingTour();
}
await this.beginOnboardingTour();
this.clearQueryParams(this.router, this.route, ["source", "status"]);
}
@ -389,4 +406,10 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
replaceUrl: true,
});
}
protected async beginOnboardingTour(): Promise<void> {
if (this.adoptionUxImprovementsEnabled) {
await WelcomeModalDialogComponent.showWelcomeDialog(this.injector, this.dialogService);
}
}
}

View File

@ -0,0 +1,51 @@
@if (isOpen()) {
<div
class="tw-fixed tw-top-16 tw-right-4 tw-z-50 tw-min-w-[180px] tw-overflow-hidden tw-rounded-lg tw-border tw-border-secondary-100 tw-bg-background tw-shadow-lg"
>
<div class="tw-border-b tw-border-secondary-100 tw-bg-background-alt tw-px-3 tw-py-1.5">
<span class="tw-text-[10px] tw-font-semibold tw-uppercase tw-tracking-widest tw-text-muted">
Dev Tools
</span>
</div>
<div>
<button
type="button"
class="tw-block tw-w-full tw-cursor-pointer tw-border-none tw-bg-background tw-px-3 tw-py-1.5 tw-text-left !tw-text-main hover:tw-bg-hover-default"
(click)="onImportData()"
>
Import Data from Access Intelligence
</button>
</div>
<div>
<button
type="button"
class="tw-block tw-w-full tw-cursor-pointer tw-border-none tw-bg-background tw-px-3 tw-py-1.5 tw-text-left !tw-text-main hover:tw-bg-hover-default"
(click)="onBeginTour()"
>
Begin Tour
</button>
</div>
<div>
<button
type="button"
class="tw-block tw-w-full tw-cursor-pointer tw-border-none tw-bg-background tw-px-3 tw-py-1.5 tw-text-left !tw-text-main hover:tw-bg-hover-default"
(click)="onResetWelcomeDialogAck()"
>
Undo Welcome Dialog Acknowledgement
</button>
</div>
<div>
<button
type="button"
class="tw-block tw-w-full tw-cursor-pointer tw-border-none tw-bg-background tw-px-3 tw-py-1.5 tw-text-left !tw-text-main hover:tw-bg-hover-default"
(click)="onShowWelcomeDialogAckState()"
>
Acknowledged? {{ welcomeDialogAcked() }}
<bit-badge variant="secondary">Click to refresh</bit-badge>
</button>
</div>
<div class="tw-border-t tw-border-secondary-100 tw-px-3 tw-py-1.5">
<span class="tw-text-[10px] tw-text-muted">Press Esc or click outside to close</span>
</div>
</div>
}

View File

@ -0,0 +1,101 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
HostListener,
inject,
isDevMode,
OnInit,
output,
signal,
} from "@angular/core";
import { BadgeModule } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { OnboardingService } from "../onboarding/services/onboarding.service";
/*This component is a dev menu only.
* It is not intended for production use and will be removed before release a
* after the feature flag is removed. It is only intended for use in development and testing.
* No language translations are required and therefore no use of i18n pipe or service.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "dirt-dev-menu",
templateUrl: "./dev-menu.component.html",
imports: [BadgeModule],
})
export class DevMenuComponent implements OnInit {
private readonly elementRef = inject(ElementRef);
private readonly onboardingService = inject(OnboardingService);
private readonly logger = inject(LogService);
protected readonly welcomeDialogAcked = signal(false);
readonly beginTour = output<void>();
readonly importData = output<void>();
protected readonly isOpen = signal(false);
async ngOnInit(): Promise<void> {
const isAck = await this.onboardingService.isWelcomeDialogAcknowledged();
this.welcomeDialogAcked.set(isAck);
}
@HostListener("document:keydown", ["$event"])
onKeyDown(event: KeyboardEvent): void {
if (!isDevMode()) {
return;
}
if (event.shiftKey && event.key === "?") {
this.isOpen.update((open) => !open);
} else if (event.key === "Escape") {
this.isOpen.set(false);
}
}
@HostListener("document:click", ["$event"])
onDocumentClick(event: MouseEvent): void {
if (!isDevMode()) {
return;
}
if (this.isOpen() && !this.elementRef.nativeElement.contains(event.target)) {
this.isOpen.set(false);
}
}
protected onBeginTour(): void {
this.isOpen.set(false);
this.beginTour.emit();
}
protected onImportData(): void {
this.isOpen.set(false);
this.importData.emit();
}
protected async onResetWelcomeDialogAck(): Promise<void> {
try {
await this.onboardingService.setWelcomeDialogAcknowledged(false);
this.welcomeDialogAcked.set(false);
this.logger.info("Reset Access Intelligence welcome dialog acknowledged state.");
} catch (error) {
this.logger.error(
"Failed to reset Access Intelligence welcome dialog acknowledged state.",
error,
);
}
}
protected async onShowWelcomeDialogAckState(): Promise<void> {
try {
const isAck = await this.onboardingService.isWelcomeDialogAcknowledged();
this.welcomeDialogAcked.set(isAck);
this.logger.info(`Access Intelligence welcome dialog acknowledged state: ${isAck}.`);
} catch (error) {
this.logger.error(
"Failed to get Access Intelligence welcome dialog acknowledged state.",
error,
);
}
}
}

View File

@ -104,4 +104,11 @@
</div>
}
}
@if (isDevMode() || adoptionUxImprovementsEnabled()) {
<dirt-dev-menu
(beginTour)="beginOnboardingTour()"
(importData)="goToImportPage()"
></dirt-dev-menu>
}
</ng-container>

View File

@ -9,6 +9,8 @@ import {
OnInit,
signal,
ChangeDetectionStrategy,
Injector,
isDevMode,
} from "@angular/core";
import { toObservable, toSignal, takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
@ -45,6 +47,8 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod
import { EmptyStateCardComponent } from "../../empty-state-card.component";
import { RiskInsightsTabType } from "../../models/risk-insights.models";
import { WelcomeModalDialogComponent } from "../../onboarding/welcome-modal-dialog.component";
import { DevMenuComponent } from "../../shared/dev-menu.component";
import { PageLoadingComponent } from "../../shared/page-loading.component";
import { ReportLoadingComponent } from "../../shared/report-loading.component";
import { ActivityTabComponent } from "../activity-tab/activity-tab.component";
@ -84,6 +88,7 @@ type ProgressStep = ReportProgress | null;
PageLoadingComponent,
TabsModule,
ReportLoadingComponent,
DevMenuComponent,
],
animations: [
trigger("fadeIn", [
@ -155,6 +160,12 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy {
protected readonly invokedFrom = signal<{ source: string; status: string } | null>(null);
readonly adoptionUxImprovementsEnabled = toSignal<boolean>(
this.configService.getFeatureFlag$(FeatureFlag.AccessIntelligenceAdoptionUxImprovements),
);
protected readonly isDevMode = signal<boolean>(isDevMode());
constructor(
private readonly route: ActivatedRoute,
private readonly router: Router,
@ -164,6 +175,7 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy {
private readonly dialogService: DialogService,
private readonly logService: LogService,
private readonly configService: ConfigService,
private readonly injector: Injector,
) {
this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef))
@ -203,7 +215,7 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy {
});
}
ngOnInit() {
async ngOnInit() {
this.route.paramMap
.pipe(
takeUntilDestroyed(this.destroyRef),
@ -225,7 +237,7 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy {
void this.currentDialogRef()?.close();
if (this.invokedFrom()?.source && this.invokedFrom()?.status) {
this.handleReturnParams(this.invokedFrom()?.source, this.invokedFrom()?.status);
await this.handleReturnParams(this.invokedFrom()?.source, this.invokedFrom()?.status);
}
}
@ -412,9 +424,13 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy {
}));
}
private handleReturnParams(source: string | undefined, status: string | undefined): void {
private async handleReturnParams(
source: string | undefined,
status: string | undefined,
): Promise<void> {
if (source === "import" && status === "success") {
this.generateReport();
await this.beginOnboardingTour();
}
this.clearQueryParams(this.router, this.route, ["source", "status"]);
@ -429,4 +445,10 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy {
replaceUrl: true,
});
}
protected async beginOnboardingTour(): Promise<void> {
if (this.adoptionUxImprovementsEnabled()) {
await WelcomeModalDialogComponent.showWelcomeDialog(this.injector, this.dialogService);
}
}
}

View File

@ -71,6 +71,7 @@ export enum FeatureFlag {
Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements",
AccessIntelligenceTrendChart = "pm-26961-access-intelligence-trend-chart",
AccessIntelligenceNewArchitecture = "pm-31936-access-intelligence-new-architecture",
AccessIntelligenceAdoptionUxImprovements = "pm-34723-access-intelligence-adoption-ux-improvements",
/* Vault */
PM32009NewItemTypes = "pm-32009-new-item-types",
@ -149,6 +150,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.Milestone11AppPageImprovements]: FALSE,
[FeatureFlag.AccessIntelligenceTrendChart]: FALSE,
[FeatureFlag.AccessIntelligenceNewArchitecture]: FALSE,
[FeatureFlag.AccessIntelligenceAdoptionUxImprovements]: FALSE,
/* Vault */
[FeatureFlag.PM32009NewItemTypes]: FALSE,

View File

@ -240,6 +240,13 @@ export const WELCOME_EXTENSION_DIALOG_DISK = new StateDefinition(
web: "disk-local",
},
);
export const ACCESS_INTELLIGENCE_WELCOME_DIALOG_DISK = new StateDefinition(
"accessIntelligenceWelcomeDialog",
"disk",
{
web: "disk-local",
},
);
// KM