diff --git a/apps/web/src/images/access-intelligence/data-is-in.svg b/apps/web/src/images/access-intelligence/data-is-in.svg new file mode 100644 index 00000000000..49dff21b40d --- /dev/null +++ b/apps/web/src/images/access-intelligence/data-is-in.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d6372cfa23c..76507b1ca5f 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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" }, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts index 8101dcbc1d0..8cf84674433 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/access-intelligence.module.ts @@ -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 {} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/services/onboarding.service.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/services/onboarding.service.ts new file mode 100644 index 00000000000..9675c338f78 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/services/onboarding.service.ts @@ -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( + 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 { + 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, + ); + } + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.html new file mode 100644 index 00000000000..3b235844094 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.html @@ -0,0 +1,21 @@ + + + + + + + {{ "yourDataIsInLetsPutItToWork" | i18n }} + + {{ "takeAQuickTourOfAccessIntelligence" | i18n }} + + + + {{ "skip" | i18n }} + + + {{ "startTour" | i18n }} + + + + + diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.spec.ts new file mode 100644 index 00000000000..8de15435558 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.spec.ts @@ -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; + +describe("WelcomeModalDialogComponent", () => { + let component: WelcomeModalDialogComponent; + let fixture: ComponentFixture; + + 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(); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.ts new file mode 100644 index 00000000000..c24197789d5 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.ts @@ -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); + 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 | 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; + }); + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index 4fafe4d4aac..fc2eb27c750 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -106,4 +106,11 @@ } } } + + @if (isDevMode || adoptionUxImprovementsEnabled) { + + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index 314cb55b5bb..320f5ac64e7 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -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 { 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 { + if (this.adoptionUxImprovementsEnabled) { + await WelcomeModalDialogComponent.showWelcomeDialog(this.injector, this.dialogService); + } + } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.html new file mode 100644 index 00000000000..c6fbdef99c5 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.html @@ -0,0 +1,51 @@ +@if (isOpen()) { + + + + Dev Tools + + + + + Import Data from Access Intelligence + + + + + Begin Tour + + + + + Undo Welcome Dialog Acknowledgement + + + + + Acknowledged? {{ welcomeDialogAcked() }} + Click to refresh + + + + Press Esc or click outside to close + + +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.ts new file mode 100644 index 00000000000..89c182d9250 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/dev-menu.component.ts @@ -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(); + readonly importData = output(); + protected readonly isOpen = signal(false); + + async ngOnInit(): Promise { + 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 { + 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 { + 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, + ); + } + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.html index cea200fe851..c6e56c33efd 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.html @@ -104,4 +104,11 @@ } } + + @if (isDevMode() || adoptionUxImprovementsEnabled()) { + + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.ts index c45157a62d6..6b1c43e3429 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/v2/access-intelligence-page/access-intelligence-page.component.ts @@ -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( + this.configService.getFeatureFlag$(FeatureFlag.AccessIntelligenceAdoptionUxImprovements), + ); + + protected readonly isDevMode = signal(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 { 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 { + if (this.adoptionUxImprovementsEnabled()) { + await WelcomeModalDialogComponent.showWelcomeDialog(this.injector, this.dialogService); + } + } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index c705eefabb0..9a9eee3c082 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -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, diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 7f9c1931eb2..1b3f2c9f4b7 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -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
+ {{ "takeAQuickTourOfAccessIntelligence" | i18n }} +