Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
472118b
PM-35058 Created welcome dialog and dev components
voommen-livefront May 15, 2026
773e28c
PM-35058 v2 integration
voommen-livefront May 15, 2026
0762c7a
PM-35058 invoke the onboarding tour after import is done
voommen-livefront May 15, 2026
533bf04
PM-35058 fix failing test
voommen-livefront May 15, 2026
f12d283
PM-35058 renamed files and removed unwanted promise.reject
voommen-livefront May 18, 2026
f92fdbb
PM-35058 updated notes on the dev-menu
voommen-livefront May 18, 2026
271d6c2
PM-35058 fixed type test failure
voommen-livefront May 18, 2026
65048cb
PM-34724 welcome carousel dialog for new admins
voommen-livefront May 19, 2026
3d4eb52
Merge branch 'main' into dirt/pm-34724/carousel-for-first-time-admins
voommen-livefront May 19, 2026
aba9fe3
PM-34724 renamed components to be easily understood
voommen-livefront May 19, 2026
0b1425b
PM-34724 refactored for consistency
voommen-livefront May 19, 2026
d2d0fc3
PM-34724 fix build in stories
voommen-livefront May 19, 2026
8f7538a
PM-34724 updated i18n text for story
voommen-livefront May 19, 2026
f94871e
PM-34724 updated carousel to use signal and modified text for story
voommen-livefront May 20, 2026
2348eb2
PM-34724 updated with feedback from claude PR review
voommen-livefront May 22, 2026
29abcad
PM-34724 delay the post import modal until report rendering is complete
voommen-livefront May 22, 2026
8538f38
PM-34724 updated race condition
voommen-livefront May 22, 2026
5ca0bc3
PM-34724 include the afterRender to wait for the page to render
voommen-livefront May 22, 2026
cafe3db
PM-34724 wait for component to finish init before rendering the dialog
voommen-livefront May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/web/src/locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,33 @@
"takeAQuickTourOfAccessIntelligence": {
"message": "Take a quick tour of Access Intelligence and see exactly how to turn your org's data into action."
},
"yourEntireOrgsSecurityInOneView": {
"message": "Your entire org's security, in one view"
},
"accessIntelligenceGivesYouSinglePlace": {
"message": "Access Intelligence gives you a single place to see every app, password, and member that's at risk."
},
"youSetThePrioritiesWeSurfaceTheRisks": {
"message": "You set the priorities. We surface the risks."
},
"youMarkWhichAppsAreMostCritical": {
"message": "You mark which apps are most critical, and Access Intelligence automatically identifies the at-risk passwords that need attention first."
},
"trackImprovementsAcrossYourTeam": {
"message": "Track improvements across your team"
},
"membersAreAutomaticallyNotified": {
"message": "Members are automatically notified of at-risk passwords in their Bitwarden extension. No chasing required."
},
"importYourOrgDataToGetStarted": {
"message": "Import your org data to get started"
},
"onceItHasTheVaultData": {
"message": "Once it has the vault data, Access Intelligence will have everything it needs to report on your org's password health."
},
"accessIntelligenceWelcomeTour": {
"message": "Access Intelligence welcome tour"
},
"startTour": {
"message": "Start tour"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<bit-dialog dialogSize="large">
<div bitDialogContent class="tw-text-center">
<vault-carousel
[hideArrows]="true"
[label]="'accessIntelligenceWelcomeTour' | i18n"
(slideChange)="onSlideChange($event)"
#carousel
>
<!-- Slide 1 -->
<vault-carousel-slide [label]="'yourEntireOrgsSecurityInOneView' | i18n" noFocusableChildren>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-4 tw-text-center">
<img
src="/images/access-intelligence/data-is-in.svg"
class="tw-w-full tw-mx-auto"
alt=""
aria-hidden="true"
/>
<h2 bitTypography="h2">{{ "yourEntireOrgsSecurityInOneView" | i18n }}</h2>
<p bitTypography="body1">{{ "accessIntelligenceGivesYouSinglePlace" | i18n }}</p>
</div>
</vault-carousel-slide>

<!-- Slide 2 -->
<vault-carousel-slide
[label]="'youSetThePrioritiesWeSurfaceTheRisks' | i18n"
noFocusableChildren
>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-4 tw-text-center">
<img
src="/images/access-intelligence/data-is-in.svg"
class="tw-w-full tw-mx-auto"
alt=""
aria-hidden="true"
/>
<h2 bitTypography="h2">{{ "youSetThePrioritiesWeSurfaceTheRisks" | i18n }}</h2>
<p bitTypography="body1">{{ "youMarkWhichAppsAreMostCritical" | i18n }}</p>
</div>
</vault-carousel-slide>

<!-- Slide 3 -->
<vault-carousel-slide [label]="'trackImprovementsAcrossYourTeam' | i18n" noFocusableChildren>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-4 tw-text-center">
<img
src="/images/access-intelligence/data-is-in.svg"
class="tw-w-full tw-mx-auto"
alt=""
aria-hidden="true"
/>
<h2 bitTypography="h2">{{ "trackImprovementsAcrossYourTeam" | i18n }}</h2>
<p bitTypography="body1">{{ "membersAreAutomaticallyNotified" | i18n }}</p>
</div>
</vault-carousel-slide>

<!-- Slide 4 -->
<vault-carousel-slide [label]="'importYourOrgDataToGetStarted' | i18n" noFocusableChildren>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-4 tw-text-center">
<img
src="/images/access-intelligence/data-is-in.svg"
class="tw-w-full tw-mx-auto"
alt=""
aria-hidden="true"
/>
<h2 bitTypography="h2">{{ "importYourOrgDataToGetStarted" | i18n }}</h2>
<p bitTypography="body1">{{ "onceItHasTheVaultData" | i18n }}</p>
</div>
</vault-carousel-slide>

<!-- Action buttons projected below dots via carouselActions slot -->
<div carouselActions class="tw-flex tw-justify-center tw-gap-4 tw-mt-4">
@if (isFirstSlide()) {
<button
type="button"
bitButton
buttonType="secondary"
data-testid="skip-btn"
(click)="onSkip()"
>
{{ "skip" | i18n }}
</button>
} @else {
<button
type="button"
bitButton
buttonType="secondary"
data-testid="back-btn"
(click)="carousel.prevSlide()"
>
{{ "back" | i18n }}
</button>
}

@if (isLastSlide()) {
<button
type="button"
bitButton
buttonType="primary"
data-testid="import-btn"
(click)="onImportData()"
>
{{ "importData" | i18n }}
</button>
} @else {
<button
type="button"
bitButton
buttonType="primary"
data-testid="next-btn"
(click)="carousel.nextSlide()"
>
{{ "next" | i18n }}
</button>
}
</div>
</vault-carousel>
</div>
</bit-dialog>
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { of } from "rxjs";

import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { ButtonModule, DialogModule, DialogRef, DIALOG_DATA } from "@bitwarden/components";
import { VaultCarouselModule } from "@bitwarden/vault";

import { NewAdminWelcomeDialogComponent } from "./new-admin-welcome-dialog.component";
import { OnboardingService } from "./services/onboarding.service";

const mockOrganizationId = "test-org-id" as OrganizationId;

const mockDialogRef = {
close: jest.fn(),
afterClosed: jest.fn().mockReturnValue(of(undefined)),
closed: of(undefined),
} as unknown as DialogRef<any, any>;

const mockOnboardingService = {
setNewAdminWelcomeDialogAcknowledged: jest.fn().mockResolvedValue(undefined),
};

const mockRouter = {
navigate: jest.fn().mockResolvedValue(true),
};

describe("NewAdminWelcomeDialogComponent", () => {
let component: NewAdminWelcomeDialogComponent;
let fixture: ComponentFixture<NewAdminWelcomeDialogComponent>;

beforeEach(async () => {
jest.clearAllMocks();

await TestBed.configureTestingModule({
imports: [NewAdminWelcomeDialogComponent, VaultCarouselModule, DialogModule, ButtonModule],
providers: [
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: OnboardingService, useValue: mockOnboardingService },
{ provide: Router, useValue: mockRouter },
{ provide: DIALOG_DATA, useValue: { organizationId: mockOrganizationId } },
],
}).compileComponents();

fixture = TestBed.createComponent(NewAdminWelcomeDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it("should create", () => {
expect(component).toBeTruthy();
});

it("starts on the first slide", () => {
expect(component["currentSlide"]()).toBe(0);
expect(component["isFirstSlide"]()).toBe(true);
expect(component["isLastSlide"]()).toBe(false);
});

it("onSlideChange updates the currentSlide signal", () => {
component["onSlideChange"](2);
expect(component["currentSlide"]()).toBe(2);
});

it("isLastSlide is true when on slide 3 (index 3)", () => {
component["onSlideChange"](3);
expect(component["isLastSlide"]()).toBe(true);
expect(component["isFirstSlide"]()).toBe(false);
});

describe("onSkip", () => {
it("calls setNewAdminWelcomeDialogAcknowledged", async () => {
await component["onSkip"]();
expect(mockOnboardingService.setNewAdminWelcomeDialogAcknowledged).toHaveBeenCalledTimes(1);
});

it("closes the dialog", async () => {
await component["onSkip"]();
expect(mockDialogRef.close).toHaveBeenCalledTimes(1);
});
});

describe("onImportData", () => {
it("calls setNewAdminWelcomeDialogAcknowledged", async () => {
await component["onImportData"]();
expect(mockOnboardingService.setNewAdminWelcomeDialogAcknowledged).toHaveBeenCalledTimes(1);
});

it("navigates to the org import page", async () => {
await component["onImportData"]();
expect(mockRouter.navigate).toHaveBeenCalledWith(
["/organizations", mockOrganizationId, "settings", "tools", "import"],
{ queryParams: { returnTo: "access-intelligence" } },
);
});

it("closes the dialog", async () => {
await component["onImportData"]();
expect(mockDialogRef.close).toHaveBeenCalledTimes(1);
});

it("closes the dialog before navigating", async () => {
const callOrder: string[] = [];
(mockDialogRef.close as jest.Mock).mockImplementation(() => callOrder.push("close"));
mockRouter.navigate.mockImplementation(async () => {
callOrder.push("navigate");
return true;
});

await component["onImportData"]();

expect(callOrder).toEqual(["close", "navigate"]);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Router } from "@angular/router";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";

import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
ButtonModule,
DialogModule,
DialogRef,
DIALOG_DATA,
TypographyModule,
I18nMockService,
} from "@bitwarden/components";
import { VaultCarouselModule } from "@bitwarden/vault";

import { NewAdminWelcomeDialogComponent } from "./new-admin-welcome-dialog.component";
import { OnboardingService } from "./services/onboarding.service";

const mockDialogRef = { close: async () => {} };
const mockOnboardingService = {
setPostImportDialogAcknowledged: async () => {},
setNewAdminWelcomeDialogAcknowledged: async () => {},
};
const mockOrganizationId = "story-org-id" as OrganizationId;

const mockI18nService = new I18nMockService({
accessIntelligenceWelcomeTour: "Welcome to Access Intelligence!",
yourEntireOrgsSecurityInOneView: "Your entire org's security in one view",
accessIntelligenceGivesYouSinglePlace:
"Access Intelligence gives you a single place to view and manage your organization's security posture, so you can spend less time on security administration and more time on strategic initiatives.",
youSetThePrioritiesWeSurfaceTheRisks: "You set the priorities, we surface the risks.",
youMarkWhichAppsAreMostCritical:
"You mark which apps are most critical to your org, and Access Intelligence surfaces the riskiest accounts and weakest links in those apps, so you can focus on what matters most.",
trackImprovementsAcrossYourTeam: "Track improvements across your team.",
membersAreAutomaticallyNotified:
"Members are automatically notified of security risks and can take action to resolve them, making it easier than ever to maintain a strong security posture across your organization.",
importYourOrgDataToGetStarted: "Import your org data to get started",
onceItHasTheVaultData:
"Once it has the vault data, Access Intelligence can start surfacing insights and recommendations to help you improve your organization's security.",
skip: "Skip",
back: "Back",
next: "Next",
importData: "Import Data",
});

export default {
title: "Access Intelligence/NewAdminWelcomeDialog",
component: NewAdminWelcomeDialogComponent,
decorators: [
moduleMetadata({
imports: [VaultCarouselModule, DialogModule, ButtonModule, TypographyModule],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: OnboardingService, useValue: mockOnboardingService },
{ provide: DIALOG_DATA, useValue: { organizationId: mockOrganizationId } },
{ provide: Router, useValue: { navigate: async () => {} } },
],
}),
],
} as Meta;

type Story = StoryObj<NewAdminWelcomeDialogComponent>;

export const Default: Story = {};
Loading
Loading