-
+
{{ "yourDataIsInLetsPutItToWork" | i18n }}
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/post-import-modal-dialog.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/post-import-modal-dialog.component.spec.ts
new file mode 100644
index 000000000000..a9c1f2b3c1a7
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/post-import-modal-dialog.component.spec.ts
@@ -0,0 +1,75 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { of } from "rxjs";
+
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { OrganizationId } from "@bitwarden/common/types/guid";
+import {
+ ButtonModule,
+ DialogModule,
+ DialogRef,
+ DialogService,
+ TypographyModule,
+ DIALOG_DATA,
+} from "@bitwarden/components";
+import { LogService } from "@bitwarden/logging";
+
+import { PostImportModalDialogComponent } from "./post-import-modal-dialog.component";
+import { OnboardingService } from "./services/onboarding.service";
+
+const mockOrganizationId = "test-org-id" as OrganizationId;
+
+const mockDialogRef = {
+ close: jest.fn().mockResolvedValue(undefined),
+ afterClosed: jest.fn().mockReturnValue(of(undefined)),
+ closed: of(undefined),
+} as unknown as DialogRef
;
+
+const mockDialogService = {
+ open: jest.fn().mockReturnValue(mockDialogRef),
+};
+
+const mockOnboardingService = {
+ setPostImportDialogAcknowledged: jest.fn().mockResolvedValue(undefined),
+ isPostImportDialogAcknowledged: jest.fn().mockResolvedValue(false),
+};
+
+const mockLogger = {
+ error: jest.fn(),
+};
+
+describe("PostImportModalDialogComponent", () => {
+ let component: PostImportModalDialogComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+
+ await TestBed.configureTestingModule({
+ imports: [PostImportModalDialogComponent, TypographyModule, ButtonModule, DialogModule],
+ providers: [
+ { provide: I18nService, useValue: { t: (key: string) => key } },
+ { provide: OnboardingService, useValue: mockOnboardingService },
+ { provide: DialogRef, useValue: mockDialogRef },
+ { provide: DialogService, useValue: mockDialogService },
+ { provide: DIALOG_DATA, useValue: { organizationId: mockOrganizationId } },
+ { provide: LogService, useValue: mockLogger },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(PostImportModalDialogComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe("onSkip", () => {
+ it("calls setPostImportDialogAcknowledged and closes the dialog", async () => {
+ await component["onSkip"]();
+ expect(mockOnboardingService.setPostImportDialogAcknowledged).toHaveBeenCalled();
+ expect(mockDialogRef.close).toHaveBeenCalled();
+ });
+ });
+});
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/post-import-modal-dialog.component.ts
similarity index 53%
rename from bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.ts
rename to bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/post-import-modal-dialog.component.ts
index c24197789d57..4d8a28b3e27a 100644
--- 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/post-import-modal-dialog.component.ts
@@ -6,6 +6,7 @@ import {
runInInjectionContext,
} from "@angular/core";
+import { OrganizationId } from "@bitwarden/common/types/guid";
import {
ButtonModule,
DialogModule,
@@ -18,38 +19,48 @@ import { I18nPipe } from "@bitwarden/ui-common";
import { OnboardingService } from "./services/onboarding.service";
+export type WelcomeModalDialogData = {
+ organizationId: OrganizationId;
+};
+
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
- selector: "app-welcome-modal-dialog",
+ selector: "app-post-import-modal-dialog",
imports: [ButtonModule, TypographyModule, DialogModule, I18nPipe],
- templateUrl: "./welcome-modal-dialog.component.html",
+ templateUrl: "./post-import-modal-dialog.component.html",
})
-export class WelcomeModalDialogComponent {
- private readonly dialogRef = inject(DialogRef);
+export class PostImportModalDialogComponent {
+ private readonly dialogRef = inject(DialogRef);
private readonly onboardingService = inject(OnboardingService);
+ private readonly logger = inject(LogService);
- protected async onStartTour() {
- // invoke the dialog here
+ protected async onStartTour(): Promise {
await this.dialogRef.close();
}
- protected async onSkip() {
+ protected async onSkip(): Promise {
await this.onboardingService
- .setWelcomeDialogAcknowledged()
+ .setPostImportDialogAcknowledged()
.then(() => {
return this.dialogRef.close();
})
- .catch(() => {});
+ .catch((error: unknown) => {
+ this.logger.error(
+ "[Post Import Modal Dialog] Error acknowledging post-import dialog",
+ error,
+ );
+ });
}
- static async showWelcomeDialog(
+ static async showDialog(
injector: Injector,
dialogService: DialogService,
- ): Promise | undefined> {
+ organizationId: OrganizationId,
+ ): Promise | undefined> {
return runInInjectionContext(injector, async () => {
const logger = inject(LogService);
const onboardingService = inject(OnboardingService);
- const acknowledged = await onboardingService.isWelcomeDialogAcknowledged();
+ const acknowledged = await onboardingService.isPostImportDialogAcknowledged();
if (acknowledged) {
logger.info(
"[Access Intelligence Onboarding] Welcome dialog already acknowledged, skipping dialog display.",
@@ -57,7 +68,8 @@ export class WelcomeModalDialogComponent {
return;
}
- const dialog = dialogService.open(WelcomeModalDialogComponent, {
+ const dialog = dialogService.open(PostImportModalDialogComponent, {
+ data: { organizationId } satisfies WelcomeModalDialogData,
width: "600px",
disableClose: true,
});
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/services/onboarding.service.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/services/onboarding.service.spec.ts
new file mode 100644
index 000000000000..d5e3c4e44b40
--- /dev/null
+++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/services/onboarding.service.spec.ts
@@ -0,0 +1,99 @@
+import { TestBed } from "@angular/core/testing";
+import { of } from "rxjs";
+
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { StateProvider } from "@bitwarden/state";
+
+import { OnboardingService } from "./onboarding.service";
+
+const mockAccount = { id: "test-user-id-123" };
+
+describe("OnboardingService", () => {
+ let service: OnboardingService;
+ let mockStateProvider: { getUserState$: jest.Mock; setUserState: jest.Mock };
+
+ beforeEach(async () => {
+ mockStateProvider = {
+ getUserState$: jest.fn().mockReturnValue(of(null)),
+ setUserState: jest.fn().mockResolvedValue(undefined),
+ };
+
+ await TestBed.configureTestingModule({
+ providers: [
+ OnboardingService,
+ { provide: AccountService, useValue: { activeAccount$: of(mockAccount) } },
+ { provide: StateProvider, useValue: mockStateProvider },
+ ],
+ });
+
+ service = TestBed.inject(OnboardingService);
+ });
+
+ describe("isNewAdminWelcomeDialogAcknowledged", () => {
+ it("returns false when state is null", async () => {
+ mockStateProvider.getUserState$.mockReturnValue(of(null));
+ const result = await service.isNewAdminWelcomeDialogAcknowledged();
+ expect(result).toBe(false);
+ });
+
+ it("returns false when state is false", async () => {
+ mockStateProvider.getUserState$.mockReturnValue(of(false));
+ const result = await service.isNewAdminWelcomeDialogAcknowledged();
+ expect(result).toBe(false);
+ });
+
+ it("returns true when state is true", async () => {
+ mockStateProvider.getUserState$.mockReturnValue(of(true));
+ const result = await service.isNewAdminWelcomeDialogAcknowledged();
+ expect(result).toBe(true);
+ });
+
+ it("returns false when there is no active account", async () => {
+ TestBed.resetTestingModule();
+ await TestBed.configureTestingModule({
+ providers: [
+ OnboardingService,
+ { provide: AccountService, useValue: { activeAccount$: of(null) } },
+ { provide: StateProvider, useValue: mockStateProvider },
+ ],
+ });
+ const service = TestBed.inject(OnboardingService);
+ const result = await service.isNewAdminWelcomeDialogAcknowledged();
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("setNewAdminWelcomeDialogAcknowledged", () => {
+ it("calls setUserState with true by default", async () => {
+ await service.setNewAdminWelcomeDialogAcknowledged();
+ expect(mockStateProvider.setUserState).toHaveBeenCalledWith(
+ expect.objectContaining({ key: "accessIntelligenceNewAdminWelcomeAcknowledged" }),
+ true,
+ mockAccount.id,
+ );
+ });
+
+ it("calls setUserState with the provided value", async () => {
+ await service.setNewAdminWelcomeDialogAcknowledged(false);
+ expect(mockStateProvider.setUserState).toHaveBeenCalledWith(
+ expect.objectContaining({ key: "accessIntelligenceNewAdminWelcomeAcknowledged" }),
+ false,
+ mockAccount.id,
+ );
+ });
+
+ it("does not call setUserState when there is no active account", async () => {
+ TestBed.resetTestingModule();
+ await TestBed.configureTestingModule({
+ providers: [
+ OnboardingService,
+ { provide: AccountService, useValue: { activeAccount$: of(null) } },
+ { provide: StateProvider, useValue: mockStateProvider },
+ ],
+ });
+ const onboardingSvc = TestBed.inject(OnboardingService);
+ await onboardingSvc.setNewAdminWelcomeDialogAcknowledged();
+ expect(mockStateProvider.setUserState).not.toHaveBeenCalled();
+ });
+ });
+});
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
index 9675c338f78e..51fc499dd553 100644
--- 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
@@ -8,12 +8,21 @@ import {
UserKeyDefinition,
} from "@bitwarden/state";
-const ACCESS_INTELLIGENCE_WELCOME_DIALOG_ACKNOWLEDGED_KEY = new UserKeyDefinition(
+const ACCESS_INTELLIGENCE_POST_IMPORT_DIALOG_ACKNOWLEDGED_KEY = new UserKeyDefinition(
ACCESS_INTELLIGENCE_WELCOME_DIALOG_DISK,
- "accessIntelligenceWelcomeDialogCompleted",
+ "accessIntelligencePostImportDialogCompleted",
{
deserializer: (value) => value,
- clearOn: [],
+ clearOn: [], // Post-import dialog acknowledged state should persist across lock/logout so the dialog is not reshown
+ },
+);
+
+const ACCESS_INTELLIGENCE_NEW_ADMIN_WELCOME_ACKNOWLEDGED_KEY = new UserKeyDefinition(
+ ACCESS_INTELLIGENCE_WELCOME_DIALOG_DISK,
+ "accessIntelligenceNewAdminWelcomeAcknowledged",
+ {
+ deserializer: (value) => value,
+ clearOn: [], // New admin welcome acknowledged state should persist across lock/logout so the tour is not reshown
},
);
@@ -22,29 +31,37 @@ export class OnboardingService {
private accountService = inject(AccountService);
private stateProvider = inject(StateProvider);
- async isWelcomeDialogAcknowledged(): Promise {
+ async isPostImportDialogAcknowledged(): Promise {
+ return this.isAcknowledged(ACCESS_INTELLIGENCE_POST_IMPORT_DIALOG_ACKNOWLEDGED_KEY);
+ }
+
+ async setPostImportDialogAcknowledged(value = true) {
+ await this.setAcknowledged(ACCESS_INTELLIGENCE_POST_IMPORT_DIALOG_ACKNOWLEDGED_KEY, value);
+ }
+
+ async isNewAdminWelcomeDialogAcknowledged(): Promise {
+ return this.isAcknowledged(ACCESS_INTELLIGENCE_NEW_ADMIN_WELCOME_ACKNOWLEDGED_KEY);
+ }
+
+ async setNewAdminWelcomeDialogAcknowledged(value = true) {
+ await this.setAcknowledged(ACCESS_INTELLIGENCE_NEW_ADMIN_WELCOME_ACKNOWLEDGED_KEY, value);
+ }
+
+ private async isAcknowledged(key: UserKeyDefinition): 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 await firstValueFrom(
+ this.stateProvider.getUserState$(key, account.id).pipe(map((v) => v ?? false)),
);
-
- return acknowledged;
}
- async setWelcomeDialogAcknowledged(value = true) {
+ private async setAcknowledged(key: UserKeyDefinition, value = true) {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (account) {
- await this.stateProvider.setUserState(
- ACCESS_INTELLIGENCE_WELCOME_DIALOG_ACKNOWLEDGED_KEY,
- value,
- account.id,
- );
+ await this.stateProvider.setUserState(key, value, account.id);
}
}
}
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
deleted file mode 100644
index 8de15435558d..000000000000
--- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/onboarding/welcome-modal-dialog.component.spec.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-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/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html
index fc2eb27c7500..45e4a868808b 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
@@ -109,7 +109,8 @@
@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 320f5ac64e7a..5abfd2455bf2 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
@@ -7,15 +7,16 @@ import {
DestroyRef,
OnDestroy,
OnInit,
+ afterNextRender,
inject,
signal,
ChangeDetectionStrategy,
isDevMode,
Injector,
} from "@angular/core";
-import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
+import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
-import { concat, EMPTY, firstValueFrom, of } from "rxjs";
+import { combineLatest, concat, EMPTY, firstValueFrom, of } from "rxjs";
import {
concatMap,
delay,
@@ -24,6 +25,7 @@ import {
map,
skip,
switchMap,
+ take,
tap,
} from "rxjs/operators";
@@ -59,7 +61,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 { NewAdminWelcomeDialogComponent } from "./onboarding/new-admin-welcome-dialog.component";
+import { PostImportModalDialogComponent } from "./onboarding/post-import-modal-dialog.component";
import { DevMenuComponent } from "./shared/dev-menu.component";
import { PageLoadingComponent } from "./shared/page-loading.component";
import { ReportLoadingComponent } from "./shared/report-loading.component";
@@ -99,6 +102,7 @@ type ProgressStep = ReportProgress | null;
})
export class RiskInsightsComponent implements OnInit, OnDestroy {
private destroyRef = inject(DestroyRef);
+ private readonly mustBeginPostImportTour = signal(false);
protected ReportStatusEnum = ReportStatus;
protected milestone11Enabled: boolean = false;
protected adoptionUxImprovementsEnabled: boolean = false;
@@ -198,17 +202,38 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
this.dataLastUpdated = report?.creationDate ?? null;
});
- // Show error toast when report generation or save fails
- this.dataService.reportStatus$
+ // Show error toast or begin post-import tour based on report status and loading state.
+ // combineLatest naturally waits for both conditions: status must be Error/Complete,
+ // and the progress loader must be done (currentProgressStep === null) before the tour opens.
+ // distinctUntilChanged on status prevents duplicate firings if currentProgressStep keeps
+ // changing while status stays the same (e.g. Error).
+ combineLatest([
+ this.dataService.reportStatus$,
+ toObservable(this.currentProgressStep, { injector: this.injector }),
+ ])
.pipe(
- filter((status) => status === ReportStatus.Error),
+ filter(
+ ([reportStatus, step]) =>
+ reportStatus === ReportStatus.Error ||
+ (reportStatus === ReportStatus.Complete && step === null),
+ ),
+ distinctUntilChanged(
+ ([prevReportStatus], [currentReportStatus]) => prevReportStatus === currentReportStatus,
+ ),
takeUntilDestroyed(this.destroyRef),
)
- .subscribe(() => {
- this.toastService.showToast({
- message: this.i18nService.t("reportGenerationFailed"),
- variant: "error",
- });
+ .subscribe(([status]) => {
+ if (status === ReportStatus.Error) {
+ this.toastService.showToast({
+ message: this.i18nService.t("reportGenerationFailed"),
+ variant: "error",
+ });
+ } else if (this.mustBeginPostImportTour()) {
+ this.mustBeginPostImportTour.set(false);
+
+ // open the dialog only after the rendering of the report is complete
+ afterNextRender(() => void this.beginPostImportTour(), { injector: this.injector });
+ }
});
// Subscribe to drawer state changes
@@ -277,6 +302,19 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
this.currentProgressStep.set(step);
});
+ combineLatest([this.dataService.hasReportData$, this.dataService.hasCiphers$])
+ .pipe(
+ takeUntilDestroyed(this.destroyRef),
+ filter(([hasReportData, hasCiphers]) => hasReportData !== null && hasCiphers !== null),
+ filter(([hasReportData, hasCiphers]) => !hasReportData && !hasCiphers),
+ take(1),
+ )
+ .subscribe(() => {
+ void this.beginNewAdminWelcomeTour().catch((error: unknown) => {
+ this.logService.error("Failed to launch onboarding welcome", error);
+ });
+ });
+
if (this.invokedFrom()?.source && this.invokedFrom()?.status) {
await this.handleReturnParams(this.invokedFrom()?.source, this.invokedFrom()?.status);
}
@@ -390,10 +428,9 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
): Promise {
if (source === "import" && status === "success") {
this.generateReport();
- await this.beginOnboardingTour();
+ this.mustBeginPostImportTour.set(true);
}
- await this.beginOnboardingTour();
this.clearQueryParams(this.router, this.route, ["source", "status"]);
}
@@ -407,9 +444,23 @@ export class RiskInsightsComponent implements OnInit, OnDestroy {
});
}
- protected async beginOnboardingTour(): Promise {
+ protected async beginPostImportTour(): Promise {
+ if (this.adoptionUxImprovementsEnabled) {
+ await PostImportModalDialogComponent.showDialog(
+ this.injector,
+ this.dialogService,
+ this.organizationId,
+ );
+ }
+ }
+
+ protected async beginNewAdminWelcomeTour(): Promise {
if (this.adoptionUxImprovementsEnabled) {
- await WelcomeModalDialogComponent.showWelcomeDialog(this.injector, this.dialogService);
+ await NewAdminWelcomeDialogComponent.showDialog(
+ this.injector,
+ this.dialogService,
+ this.organizationId,
+ );
}
}
}
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
index c6fbdef99c58..9f9cbddb15b8 100644
--- 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
@@ -16,31 +16,60 @@
Import Data from Access Intelligence
+
- Begin Tour
+ Begin New Admin Welcome Tour
- Undo Welcome Dialog Acknowledgement
+ Undo New Admin Welcome Dialog Acknowledgement
- Acknowledged? {{ welcomeDialogAcked() }}
+ New Admin Welcome Acknowledged? {{ newAdminWelcomeDialogAcked() }}
+ Click to refresh
+
+
+
+
+ Begin Post Import Tour
+
+
+
+
+ Undo Post Import Dialog Acknowledgement
+
+
+
+
+ Post Import Dialog Acknowledged? {{ postImportDialogAcked() }}
Click to refresh
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
index 89c182d92503..5d5a83fd5928 100644
--- 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
@@ -30,15 +30,21 @@ 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);
+ protected readonly postImportDialogAcked = signal(false);
+ protected readonly newAdminWelcomeDialogAcked = signal(false);
- readonly beginTour = output
();
+ readonly beginNewAdminWelcomeTour = output();
+ readonly beginPostImportTour = output();
readonly importData = output();
protected readonly isOpen = signal(false);
async ngOnInit(): Promise {
- const isAck = await this.onboardingService.isWelcomeDialogAcknowledged();
- this.welcomeDialogAcked.set(isAck);
+ const isPostImportDialogAcked = await this.onboardingService.isPostImportDialogAcknowledged();
+ this.postImportDialogAcked.set(isPostImportDialogAcked);
+
+ const newAdminWelcomeDialogAcked =
+ await this.onboardingService.isNewAdminWelcomeDialogAcknowledged();
+ this.newAdminWelcomeDialogAcked.set(newAdminWelcomeDialogAcked);
}
@HostListener("document:keydown", ["$event"])
@@ -63,9 +69,14 @@ export class DevMenuComponent implements OnInit {
}
}
- protected onBeginTour(): void {
+ protected onBeginPostImportTour(): void {
+ this.isOpen.set(false);
+ this.beginPostImportTour.emit();
+ }
+
+ protected onBeginNewAdminWelcomeTour(): void {
this.isOpen.set(false);
- this.beginTour.emit();
+ this.beginNewAdminWelcomeTour.emit();
}
protected onImportData(): void {
@@ -73,27 +84,55 @@ export class DevMenuComponent implements OnInit {
this.importData.emit();
}
- protected async onResetWelcomeDialogAck(): Promise {
+ protected async onResetPostImportDialogAck(): Promise {
try {
- await this.onboardingService.setWelcomeDialogAcknowledged(false);
- this.welcomeDialogAcked.set(false);
- this.logger.info("Reset Access Intelligence welcome dialog acknowledged state.");
+ await this.onboardingService.setPostImportDialogAcknowledged(false);
+ this.postImportDialogAcked.set(false);
+ this.logger.info("Reset Access Intelligence post import dialog acknowledged state.");
} catch (error) {
this.logger.error(
- "Failed to reset Access Intelligence welcome dialog acknowledged state.",
+ "Failed to reset Access Intelligence post import dialog acknowledged state.",
error,
);
}
}
- protected async onShowWelcomeDialogAckState(): Promise {
+ protected async onShowPostImportDialogAckState(): Promise {
try {
- const isAck = await this.onboardingService.isWelcomeDialogAcknowledged();
- this.welcomeDialogAcked.set(isAck);
- this.logger.info(`Access Intelligence welcome dialog acknowledged state: ${isAck}.`);
+ const isAck = await this.onboardingService.isPostImportDialogAcknowledged();
+ this.postImportDialogAcked.set(isAck);
+ this.logger.info(`Access Intelligence post import dialog acknowledged state: ${isAck}.`);
+ } catch (error) {
+ this.logger.error(
+ "Failed to get Access Intelligence post import dialog acknowledged state.",
+ error,
+ );
+ }
+ }
+
+ protected async onResetNewAdminWelcomeDialogAck(): Promise {
+ try {
+ await this.onboardingService.setNewAdminWelcomeDialogAcknowledged(false);
+ this.newAdminWelcomeDialogAcked.set(false);
+ this.logger.info("Reset Access Intelligence new admin welcome dialog acknowledged state.");
+ } catch (error) {
+ this.logger.error(
+ "Failed to reset Access Intelligence new admin welcome dialog acknowledged state.",
+ error,
+ );
+ }
+ }
+
+ protected async onShowNewAdminWelcomeDialogAckState(): Promise {
+ try {
+ const isAck = await this.onboardingService.isNewAdminWelcomeDialogAcknowledged();
+ this.newAdminWelcomeDialogAcked.set(isAck);
+ this.logger.info(
+ `Access Intelligence new admin welcome dialog acknowledged state: ${isAck}.`,
+ );
} catch (error) {
this.logger.error(
- "Failed to get Access Intelligence welcome dialog acknowledged state.",
+ "Failed to get Access Intelligence new admin 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 c6e56c33efd2..d7c517b1d2cd 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
@@ -107,7 +107,8 @@
@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 6b1c43e34298..45fa8cf9433f 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
@@ -11,11 +11,13 @@ import {
ChangeDetectionStrategy,
Injector,
isDevMode,
+ effect,
+ afterNextRender,
} from "@angular/core";
import { toObservable, toSignal, takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, concat, distinctUntilChanged, filter, map, of, switchMap } from "rxjs";
-import { concatMap, delay, finalize, skip } from "rxjs/operators";
+import { concatMap, delay, finalize, skip, take } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -47,7 +49,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 { NewAdminWelcomeDialogComponent } from "../../onboarding/new-admin-welcome-dialog.component";
+import { PostImportModalDialogComponent } from "../../onboarding/post-import-modal-dialog.component";
import { DevMenuComponent } from "../../shared/dev-menu.component";
import { PageLoadingComponent } from "../../shared/page-loading.component";
import { ReportLoadingComponent } from "../../shared/report-loading.component";
@@ -101,6 +104,7 @@ type ProgressStep = ReportProgress | null;
})
export class AccessIntelligencePageComponent implements OnInit, OnDestroy {
private readonly destroyRef = inject(DestroyRef);
+ private readonly mustBeginPostImportTour = signal(false);
protected readonly tabIndex = signal(RiskInsightsTabType.AllActivity);
@@ -213,6 +217,18 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy {
.subscribe((step) => {
this.currentProgressStep.set(step);
});
+
+ effect(() => {
+ // determine if we need to begin the post import tour
+ // to be launched when report generation is complete
+ // and mustBeginPostImportTour is true (set when user is navigated from import page after successful import
+ if (this.currentProgressStep() === null && this.mustBeginPostImportTour()) {
+ this.mustBeginPostImportTour.set(false);
+
+ // open the dialog only after the rendering of the report is complete
+ afterNextRender(() => void this.beginPostImportTour(), { injector: this.injector });
+ }
+ });
}
async ngOnInit() {
@@ -236,6 +252,25 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy {
// Close any open dialogs (happens when navigating between orgs)
void this.currentDialogRef()?.close();
+ // determine if we need to launch the new admin welcome tour
+ // launch when there are no reports and no ciphers.
+ combineLatest([
+ toObservable(this.hasReportData, { injector: this.injector }),
+ toObservable(this.hasCiphers, { injector: this.injector }),
+ toObservable(this.initializing, { injector: this.injector }),
+ ])
+ .pipe(
+ takeUntilDestroyed(this.destroyRef),
+ filter(([_, __, initializing]) => !initializing), // Wait until initialization is complete
+ filter(([hasReportData, hasCiphers]) => !hasReportData && !hasCiphers),
+ take(1),
+ )
+ .subscribe(() => {
+ void this.beginNewAdminWelcomeTour().catch((error: unknown) => {
+ this.logService.error("Failed to launch onboarding welcome", error);
+ });
+ });
+
if (this.invokedFrom()?.source && this.invokedFrom()?.status) {
await this.handleReturnParams(this.invokedFrom()?.source, this.invokedFrom()?.status);
}
@@ -430,7 +465,7 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy {
): Promise {
if (source === "import" && status === "success") {
this.generateReport();
- await this.beginOnboardingTour();
+ this.mustBeginPostImportTour.set(true);
}
this.clearQueryParams(this.router, this.route, ["source", "status"]);
@@ -446,9 +481,24 @@ export class AccessIntelligencePageComponent implements OnInit, OnDestroy {
});
}
- protected async beginOnboardingTour(): Promise {
+ protected async beginPostImportTour(): Promise {
+ if (this.adoptionUxImprovementsEnabled()) {
+ this.mustBeginPostImportTour.set(false);
+ await PostImportModalDialogComponent.showDialog(
+ this.injector,
+ this.dialogService,
+ this.organizationId(),
+ );
+ }
+ }
+
+ protected async beginNewAdminWelcomeTour(): Promise {
if (this.adoptionUxImprovementsEnabled()) {
- await WelcomeModalDialogComponent.showWelcomeDialog(this.injector, this.dialogService);
+ await NewAdminWelcomeDialogComponent.showDialog(
+ this.injector,
+ this.dialogService,
+ this.organizationId(),
+ );
}
}
}
diff --git a/libs/vault/src/components/carousel/carousel.component.html b/libs/vault/src/components/carousel/carousel.component.html
index 81689e9cb00b..25a82e6a2c85 100644
--- a/libs/vault/src/components/carousel/carousel.component.html
+++ b/libs/vault/src/components/carousel/carousel.component.html
@@ -7,15 +7,19 @@
>
-
+
+ @if (!hideArrows()) {
+
+ }
+
}
-
= slides.length - 1"
- label="{{ 'next' | i18n }}"
- >
+
+ @if (!hideArrows()) {
+ = slides.length - 1"
+ label="{{ 'next' | i18n }}"
+ >
+ }
+
+
@if (minHeight === null) {
diff --git a/libs/vault/src/components/carousel/carousel.component.spec.ts b/libs/vault/src/components/carousel/carousel.component.spec.ts
index eb9480398e9b..7f058d9ebb2e 100644
--- a/libs/vault/src/components/carousel/carousel.component.spec.ts
+++ b/libs/vault/src/components/carousel/carousel.component.spec.ts
@@ -100,3 +100,63 @@ describe("VaultCarouselComponent", () => {
expect(component.slideChange.emit).toHaveBeenCalledWith(0);
});
});
+
+@Component({
+ selector: "app-test-carousel-hide-arrows",
+ imports: [VaultCarouselComponent, VaultCarouselSlideComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ Content 1
+ Content 2
+
+ Custom Action
+
+
+ `,
+})
+class TestCarouselHideArrowsComponent {}
+
+describe("VaultCarouselComponent with hideArrows", () => {
+ let fixture: ComponentFixture
;
+ let carouselComponent: VaultCarouselComponent;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [VaultCarouselComponent, VaultCarouselSlideComponent],
+ providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestCarouselHideArrowsComponent);
+ fixture.detectChanges();
+ carouselComponent = fixture.debugElement.query(
+ By.directive(VaultCarouselComponent),
+ ).componentInstance;
+ });
+
+ it("hides navigation arrow buttons when hideArrows is true", () => {
+ const iconButtons = fixture.debugElement.queryAll(By.css("[bitIconButton]"));
+ expect(iconButtons.length).toBe(0);
+ });
+
+ it("renders carouselActions slot content", () => {
+ const actionBtn = fixture.debugElement.query(By.css("[data-testid='action-btn']"));
+ expect(actionBtn).not.toBeNull();
+ });
+
+ it("nextSlide advances to the next slide", () => {
+ const slideChangeSpy = jest.spyOn(carouselComponent.slideChange, "emit");
+ carouselComponent.nextSlide();
+ fixture.detectChanges();
+ expect(slideChangeSpy).toHaveBeenCalledWith(1);
+ });
+
+ it("prevSlide goes back to the previous slide after advancing", () => {
+ carouselComponent.nextSlide();
+ fixture.detectChanges();
+ const slideChangeSpy = jest.spyOn(carouselComponent.slideChange, "emit");
+ carouselComponent.prevSlide();
+ fixture.detectChanges();
+ expect(slideChangeSpy).toHaveBeenCalledWith(0);
+ });
+});
diff --git a/libs/vault/src/components/carousel/carousel.component.ts b/libs/vault/src/components/carousel/carousel.component.ts
index c622f2e5d855..9b756404bdcd 100644
--- a/libs/vault/src/components/carousel/carousel.component.ts
+++ b/libs/vault/src/components/carousel/carousel.component.ts
@@ -10,6 +10,7 @@ import {
ElementRef,
EventEmitter,
inject,
+ input,
Input,
NgZone,
Output,
@@ -54,6 +55,12 @@ export class VaultCarouselComponent implements AfterViewInit {
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) label = "";
+ /**
+ * When true, hides the previous/next arrow navigation buttons.
+ * Use with the `carouselActions` content slot to provide custom navigation controls.
+ */
+ protected readonly hideArrows = input(false);
+
/**
* Emits the index of the newly selected slide.
*/
@@ -118,13 +125,13 @@ export class VaultCarouselComponent implements AfterViewInit {
this.slideChange.emit(index);
}
- protected nextSlide() {
+ nextSlide() {
if (this.selectedIndex < this.slides.length - 1) {
this.selectSlide(this.selectedIndex + 1);
}
}
- protected prevSlide() {
+ prevSlide() {
if (this.selectedIndex > 0) {
this.selectSlide(this.selectedIndex - 1);
}
diff --git a/libs/vault/src/components/carousel/carousel.stories.ts b/libs/vault/src/components/carousel/carousel.stories.ts
index 1e393779a6a1..a839502fe6d4 100644
--- a/libs/vault/src/components/carousel/carousel.stories.ts
+++ b/libs/vault/src/components/carousel/carousel.stories.ts
@@ -76,3 +76,29 @@ export const KeyboardNavigation: Story = {
`,
}),
};
+
+export const HideArrowsWithActions: Story = {
+ render: (args: any) => ({
+ props: args,
+ template: `
+
+
+
+
Slide Without Arrows
+
Dots only — no navigation arrows
+
+
+
+
+
Second Slide
+
Buttons projected below the dots via carouselActions slot
+
+
+
+ Back
+ Next
+
+
+ `,
+ }),
+};