feat(ui): send empty tenants to the add-provider wizard on first run

This commit is contained in:
alejandrobailo
2026-09-21 16:45:48 +02:00
parent 590b6360a6
commit 793cc32a1d
18 changed files with 410 additions and 359 deletions
+15 -14
View File
@@ -59,6 +59,10 @@ export default async function RootLayout({
// Skip Cloud-only onboarding fetches and orchestrators in OSS.
const cloudEnabled = isCloud();
// Every deployment needs the provider count: it drives the first-run redirect
// and the sidebar's Add Provider action.
const providersPromise = getProviders({ page: 1, pageSize: 1 });
// One-time server-side Registry gate per request: only an ELIGIBLE answer
// shows the sidebar entry; UNKNOWN and INELIGIBLE both hide it. Started
// here so it resolves in parallel with the Cloud onboarding fetches.
@@ -69,29 +73,27 @@ export default async function RootLayout({
// Fail-open: unknown scan state is treated as "has data" so the banner never blocks
// progression on a fetch error.
let hasCompletedScan = true;
// Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open).
let hasProviders: boolean | undefined = false;
// Scopes the onboarding steps' local markers, so resolving them for one
// tenant does not silence them for another.
let tenantId: string | null = null;
if (cloudEnabled) {
const [providersData, scansByState] = await Promise.all([
getProviders({ page: 1, pageSize: 1 }),
getScansByState(),
]);
const scansByState = await getScansByState();
hasCompletedScan = Array.isArray(scansByState?.data)
? scansByState.data.some(
(scan: { attributes?: { state?: string } }) =>
scan.attributes?.state === SCAN_STATES.COMPLETED,
)
: true;
hasProviders = Array.isArray(providersData?.data)
? providersData.data.length > 0
: undefined;
tenantId = (await auth())?.tenantId ?? null;
}
const providersData = await providersPromise;
// Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open).
const hasProviders: boolean | undefined = Array.isArray(providersData?.data)
? providersData.data.length > 0
: undefined;
const registryEligible =
(await registryAccessPromise).status === REGISTRY_ACCESS.ELIGIBLE;
@@ -114,13 +116,12 @@ export default async function RootLayout({
<Suspense>
<NavigationProgress />
</Suspense>
{/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */}
<StoreInitializer
values={{ hasProviders: hasProviders ?? false, registryEligible }}
/>
{/* Tri-state for both: an unknown count leaves the store unresolved and the gate closed. */}
<StoreInitializer values={{ hasProviders, registryEligible }} />
{/* Every deployment: an empty tenant lands on the add-provider wizard once. */}
<OnboardingGate hasProviders={hasProviders} />
{cloudEnabled && (
<>
<OnboardingGate hasProviders={hasProviders} />
{/* Single mount point so the watcher survives post-connect navigation. */}
<OnboardingCheckpointWatcher tenantId={tenantId} />
{/* Persistent banner shown only while a guided sequence is active. */}
@@ -0,0 +1 @@
New tenants without providers land on the Add Provider wizard on first sign-in instead of a welcome modal
@@ -1,18 +1,23 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isFirstRunHandled } from "@/lib/onboarding/first-run-marker";
import { addProviderTour } from "@/lib/tours/add-provider.tour";
import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter";
import { OnboardingGate } from "../onboarding-gate";
const pushMock = vi.fn();
const replaceMock = vi.fn();
const armMock = vi.fn();
const pathnameMock = vi.fn();
const permissionsMock = vi.fn();
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ permissions: permissionsMock() }),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, replace: vi.fn() }),
useRouter: () => ({ push: vi.fn(), replace: replaceMock }),
usePathname: () => pathnameMock(),
}));
@@ -27,20 +32,28 @@ const addProviderTourId = {
version: addProviderTour.version,
};
const CLOUD_FIRST_RUN_HREF =
"/providers?addProvider=true&addProviderSource=first_run&onboarding=add-provider";
const OSS_FIRST_RUN_HREF =
"/providers?addProvider=true&addProviderSource=first_run";
describe("OnboardingGate", () => {
beforeEach(() => {
window.localStorage.clear();
pushMock.mockClear();
replaceMock.mockClear();
armMock.mockClear();
pathnameMock.mockReturnValue("/");
permissionsMock.mockReturnValue({ manage_providers: true });
vi.stubEnv("UI_CLOUD_ENABLED", "true");
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it.each(["/billing", "/billing/", "/billing/checkout"])(
"defers onboarding on %s without resolving it",
"defers the first run on %s without resolving it",
(pathname) => {
// Given
pathnameMock.mockReturnValue(pathname);
@@ -49,16 +62,13 @@ describe("OnboardingGate", () => {
render(<OnboardingGate hasProviders={false} />);
// Then
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
expect(localStorageAdapter.get(addProviderTourId)).toBeNull();
expect(replaceMock).not.toHaveBeenCalled();
expect(armMock).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
expect(isFirstRunHandled()).toBe(false);
},
);
it("offers onboarding after leaving billing without remounting the gate", async () => {
it("sends the user to add a provider after leaving billing, without remounting the gate", async () => {
// Given
pathnameMock.mockReturnValue("/billing");
const { rerender } = render(<OnboardingGate hasProviders={false} />);
@@ -68,170 +78,127 @@ describe("OnboardingGate", () => {
rerender(<OnboardingGate hasProviders={false} />);
// Then
expect(
await screen.findByRole("button", { name: /get started/i }),
).toBeInTheDocument();
expect(localStorageAdapter.get(addProviderTourId)).toBeNull();
expect(armMock).not.toHaveBeenCalled();
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(CLOUD_FIRST_RUN_HREF),
);
});
it("does not suppress onboarding on a route that only shares the billing prefix", async () => {
it("does not defer on a route that only shares the billing prefix", async () => {
// Given
pathnameMock.mockReturnValue("/billing-settings");
pathnameMock.mockReturnValue("/billing-history");
// When
render(<OnboardingGate hasProviders={false} />);
// Then
expect(
await screen.findByRole("button", { name: /get started/i }),
).toBeInTheDocument();
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
});
describe("when the user has no providers and no completion record", () => {
it("shows the Welcome modal", async () => {
describe("when a Cloud tenant has no providers and never went through the first run", () => {
it("opens the add-provider wizard with its tour and arms the checkpoint", async () => {
// Given / When
render(<OnboardingGate hasProviders={false} />);
expect(
await screen.findByRole("button", { name: /get started/i }),
).toBeInTheDocument();
});
});
describe("when the user already has providers", () => {
it("does not show the Welcome modal", async () => {
render(<OnboardingGate hasProviders={true} />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
describe("when a completion record already exists in this browser", () => {
it("does not show the Welcome modal", async () => {
localStorageAdapter.set(addProviderTourId, {
tourId: addProviderTour.id,
version: addProviderTour.version,
state: "dismissed",
completedAt: new Date().toISOString(),
});
render(<OnboardingGate hasProviders={false} />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
describe("when the gate flow is dismissed but later sequence flows are incomplete", () => {
it("does not show the Welcome modal for a later flow", async () => {
// Later flows are only reachable via the checkpoint/sequence, never the gate.
localStorageAdapter.set(addProviderTourId, {
tourId: addProviderTour.id,
version: addProviderTour.version,
state: "dismissed",
completedAt: new Date().toISOString(),
});
render(<OnboardingGate hasProviders={false} />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
describe("when hasProviders is undefined (fail-open)", () => {
it("does not show the Welcome modal", async () => {
// `undefined` mirrors the tri-state layout forwards on a failed provider fetch.
render(<OnboardingGate hasProviders={undefined} />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
it("can be mounted with the prop omitted entirely (fail-open)", async () => {
render(<OnboardingGate />);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
describe("when the user accepts the Welcome modal", () => {
it("navigates to the flow route with the onboarding query param and writes no record", async () => {
const user = userEvent.setup();
render(<OnboardingGate hasProviders={false} />);
const getStarted = await screen.findByRole("button", {
name: /get started/i,
});
await user.click(getStarted);
expect(pushMock).toHaveBeenCalledWith(
"/providers?onboarding=add-provider",
// Then
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(
CLOUD_FIRST_RUN_HREF,
),
);
expect(localStorageAdapter.get(addProviderTourId)).toBeNull();
expect(armMock).toHaveBeenCalledOnce();
});
it("arms the onboarding checkpoint", async () => {
const user = userEvent.setup();
it("happens only once per browser", async () => {
// Given
const { unmount } = render(<OnboardingGate hasProviders={false} />);
await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
unmount();
replaceMock.mockClear();
// When
render(<OnboardingGate hasProviders={false} />);
const getStarted = await screen.findByRole("button", {
name: /get started/i,
});
await user.click(getStarted);
expect(armMock).toHaveBeenCalledTimes(1);
// Then
expect(isFirstRunHandled()).toBe(true);
expect(replaceMock).not.toHaveBeenCalled();
});
});
describe("when the user dismisses the Welcome modal", () => {
it("writes a dismissed record and stops showing the modal", async () => {
const user = userEvent.setup();
describe("when a self-hosted deployment has no providers", () => {
it("opens the add-provider wizard without the Cloud-only tour or checkpoint", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
// When
render(<OnboardingGate hasProviders={false} />);
const skip = await screen.findByRole("button", {
name: /skip for now/i,
});
await user.click(skip);
await waitFor(() => {
expect(
screen.queryByRole("button", { name: /skip for now/i }),
).not.toBeInTheDocument();
});
const record = localStorageAdapter.get(addProviderTourId);
expect(record).not.toBeNull();
expect(record?.state).toBe("dismissed");
});
it("does NOT arm the onboarding checkpoint", async () => {
const user = userEvent.setup();
render(<OnboardingGate hasProviders={false} />);
const skip = await screen.findByRole("button", {
name: /skip for now/i,
});
await user.click(skip);
// Skipping must never arm the checkpoint (user opted out).
// Then
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledExactlyOnceWith(OSS_FIRST_RUN_HREF),
);
expect(armMock).not.toHaveBeenCalled();
});
});
describe("when the user cannot add providers", () => {
it("leaves the user where they are, since an empty list may just be limited visibility", () => {
// Given
permissionsMock.mockReturnValue({ manage_providers: false });
// When
render(<OnboardingGate hasProviders={false} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(isFirstRunHandled()).toBe(false);
});
});
describe("when the tenant already has providers", () => {
it("leaves the user where they are", () => {
// Given / When
render(<OnboardingGate hasProviders />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(isFirstRunHandled()).toBe(false);
});
});
describe("when the add-provider tour was already resolved in this browser", () => {
it("leaves the user where they are", () => {
// Given
localStorageAdapter.set(addProviderTourId, {
tourId: addProviderTour.id,
version: addProviderTour.version,
state: "dismissed",
completedAt: new Date().toISOString(),
});
// When
render(<OnboardingGate hasProviders={false} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
expect(armMock).not.toHaveBeenCalled();
});
});
describe("when the provider count is unknown (fail-open)", () => {
it("leaves the user where they are when the fetch failed", () => {
// Given / When
render(<OnboardingGate hasProviders={undefined} />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
});
it("can be mounted with the prop omitted entirely", () => {
// Given / When
render(<OnboardingGate />);
// Then
expect(replaceMock).not.toHaveBeenCalled();
});
});
});
@@ -118,6 +118,24 @@ describe("OnboardingTrigger", () => {
);
});
it("starts at the step the page asks for, skipping the ones before it", async () => {
// Given
searchParamsValue = new URLSearchParams("onboarding=add-provider");
// When
render(
<OnboardingTrigger
flow={addProviderFlow}
startAtTarget="provider-type"
/>,
);
// Then
await waitFor(() =>
expect(startMock).toHaveBeenCalledExactlyOnceWith("provider-type"),
);
});
it("strips only the onboarding param and preserves other query params", async () => {
searchParamsValue = new URLSearchParams(
"scanId=scan-1&onboarding=add-provider&tab=completed",
@@ -1,83 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { OnboardingWelcomeModal } from "../onboarding-welcome-modal";
describe("OnboardingWelcomeModal", () => {
describe("when open is true", () => {
it("renders the flow title and description", () => {
render(
<OnboardingWelcomeModal
open
flowTitle="Add your first provider"
flowDescription="Connect a cloud account so Prowler has something to scan."
onAccept={vi.fn()}
onDismiss={vi.fn()}
/>,
);
expect(screen.getByText("Add your first provider")).toBeInTheDocument();
expect(
screen.getByText(
"Connect a cloud account so Prowler has something to scan.",
),
).toBeInTheDocument();
});
it("calls onAccept when the primary action is clicked", async () => {
const user = userEvent.setup();
const onAccept = vi.fn();
const onDismiss = vi.fn();
render(
<OnboardingWelcomeModal
open
flowTitle="Add your first provider"
onAccept={onAccept}
onDismiss={onDismiss}
/>,
);
await user.click(screen.getByRole("button", { name: /get started/i }));
expect(onAccept).toHaveBeenCalledTimes(1);
expect(onDismiss).not.toHaveBeenCalled();
});
it("calls onDismiss when the skip action is clicked", async () => {
const user = userEvent.setup();
const onAccept = vi.fn();
const onDismiss = vi.fn();
render(
<OnboardingWelcomeModal
open
flowTitle="Add your first provider"
onAccept={onAccept}
onDismiss={onDismiss}
/>,
);
await user.click(screen.getByRole("button", { name: /skip for now/i }));
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(onAccept).not.toHaveBeenCalled();
});
});
describe("when open is false", () => {
it("does not render the modal content", () => {
render(
<OnboardingWelcomeModal
open={false}
flowTitle="Add your first provider"
onAccept={vi.fn()}
onDismiss={vi.fn()}
/>,
);
expect(
screen.queryByRole("button", { name: /get started/i }),
).not.toBeInTheDocument();
});
});
});
+54 -50
View File
@@ -1,26 +1,35 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { getOrderedFlows, shouldStartOnboarding } from "@/lib/onboarding";
import { useAuth } from "@/hooks/use-auth";
import { useMountEffect } from "@/hooks/use-mount-effect";
import {
getOrderedFlows,
type OnboardingFlow,
shouldStartOnboarding,
} from "@/lib/onboarding";
import {
isFirstRunHandled,
markFirstRunHandled,
} from "@/lib/onboarding/first-run-marker";
import { WIZARD_OPEN_SOURCE } from "@/lib/provider-funnel/provider-funnel-events";
import { buildAddProviderHref } from "@/lib/providers-navigation";
import { isCloud } from "@/lib/shared/env";
import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter";
import { TOUR_COMPLETION_STATES } from "@/lib/tours/tour-types";
import { useTourCompletion } from "@/lib/tours/use-tour-completion";
import { useOnboardingCheckpointStore } from "@/store/onboarding-checkpoint";
import { OnboardingWelcomeModal } from "./onboarding-welcome-modal";
interface OnboardingGateProps {
// `undefined` = fetch failed/ambiguous; fail-open (never force the modal).
// `undefined` = fetch failed/ambiguous; fail-open (never force the first run).
hasProviders?: boolean;
}
// Mandatory new-user gate. Mounted once in the layout; decision derived during render
// via useSyncExternalStore — server renders nothing, no hydration mismatch.
// New-tenant gate. Mounted once in the layout: an empty tenant is sent straight to
// the add-provider wizard, once per browser. Renders nothing.
export function OnboardingGate({ hasProviders }: OnboardingGateProps) {
const router = useRouter();
const pathname = usePathname();
const { permissions } = useAuth();
// Billing must stay usable before onboarding; leaving it keeps the gate eligible.
const isBillingRoute =
pathname === "/billing" || pathname?.startsWith("/billing/");
@@ -28,52 +37,47 @@ export function OnboardingGate({ hasProviders }: OnboardingGateProps) {
// Gate forces only the first flow (`add-provider`); remaining flows come via checkpoint/replay.
const flow = getOrderedFlows()[0] ?? null;
// Returns null on server/first render — gate stays closed until resolved client-side.
// Returns null on server/first render; the redirect re-reads storage before acting.
const completionRecord = useTourCompletion(flow?.tour ?? null);
// Session flag prevents the gate re-opening after accept/dismiss within this mount.
const [resolvedThisSession, setResolvedThisSession] = useState(false);
const activeFlow =
flow &&
const shouldRedirect =
flow !== null &&
!isBillingRoute &&
!resolvedThisSession &&
shouldStartOnboarding({ hasProviders, completionRecord })
? flow
: null;
shouldStartOnboarding({
hasProviders,
canManageProviders: permissions.manage_providers === true,
completionRecord,
});
if (!activeFlow) return null;
if (!shouldRedirect) return null;
const handleAccept = () => {
// Arm checkpoint only on explicit accept — skip must never arm it.
return <FirstRunRedirect flow={flow} />;
}
interface FirstRunRedirectProps {
flow: OnboardingFlow;
}
function FirstRunRedirect({ flow }: FirstRunRedirectProps) {
const router = useRouter();
useMountEffect(() => {
// Hydration renders with an empty completion snapshot, so decide from storage here.
const tourId = { id: flow.tour.id, version: flow.tour.version };
if (isFirstRunHandled() || localStorageAdapter.get(tourId) !== null) return;
markFirstRunHandled();
const addProviderHref = buildAddProviderHref(WIZARD_OPEN_SOURCE.FIRST_RUN);
if (!isCloud()) {
router.replace(addProviderHref);
return;
}
// Tours and the post-connect checkpoint are Cloud-only.
useOnboardingCheckpointStore.getState().arm();
setResolvedThisSession(true);
// Routes may already carry a query string, so pick the right separator.
const separator = activeFlow.route.includes("?") ? "&" : "?";
router.push(`${activeFlow.route}${separator}onboarding=${activeFlow.id}`);
};
const handleDismiss = () => {
// Persist dismissal so the gate silently skips on future visits.
localStorageAdapter.set(
{ id: activeFlow.tour.id, version: activeFlow.tour.version },
{
tourId: activeFlow.tour.id,
version: activeFlow.tour.version,
state: TOUR_COMPLETION_STATES.DISMISSED,
completedAt: new Date().toISOString(),
},
);
setResolvedThisSession(true);
};
router.replace(`${addProviderHref}&onboarding=${flow.id}`);
});
return (
<OnboardingWelcomeModal
open
flowTitle={activeFlow.title}
flowDescription={activeFlow.description}
onAccept={handleAccept}
onDismiss={handleDismiss}
/>
);
return null;
}
@@ -34,6 +34,8 @@ interface OnboardingTriggerProps<TTarget extends string = string> {
flow: OnboardingFlow; // force-started when the sequence names it or `?onboarding=<id>` matches
stepHandlers?: { [K in TTarget]?: TourStepHandlers<TTarget> };
configOverrides?: Partial<Config>;
// Step to begin from when the page already did what the earlier steps ask for.
startAtTarget?: TTarget;
}
// Latched per-trigger: `key` mounts a fresh runner on each re-trigger; `mode` drives param-strip logic.
@@ -50,6 +52,7 @@ export function OnboardingTrigger<TTarget extends string = string>({
flow,
stepHandlers,
configOverrides,
startAtTarget,
}: OnboardingTriggerProps<TTarget>) {
const searchParams = useSearchParams();
const param = searchParams?.get(ONBOARDING_PARAM) ?? null; // null outside Suspense context
@@ -108,6 +111,7 @@ export function OnboardingTrigger<TTarget extends string = string>({
queryString={request.queryString}
stepHandlers={stepHandlers}
configOverrides={configOverrides}
startAtTarget={startAtTarget}
/>
);
}
@@ -118,6 +122,7 @@ interface OnboardingTourRunnerProps<TTarget extends string> {
queryString: string;
stepHandlers?: { [K in TTarget]?: TourStepHandlers<TTarget> };
configOverrides?: Partial<Config>;
startAtTarget?: TTarget;
}
function OnboardingTourRunner<TTarget extends string>({
@@ -126,6 +131,7 @@ function OnboardingTourRunner<TTarget extends string>({
queryString,
stepHandlers,
configOverrides,
startAtTarget,
}: OnboardingTourRunnerProps<TTarget>) {
// onClosed is intentionally inert — the banner owns advance/exit for both modes.
const { start } = useDriverTour(flow.tour, {
@@ -144,7 +150,7 @@ function OnboardingTourRunner<TTarget extends string>({
queueMicrotask(() => {
if (cancelled) return;
start();
start(startAtTarget);
if (mode === "replay") {
// Only strip when the param actually started this replay; a same-route
// in-memory request leaves the URL untouched (no replaceState needed).
@@ -1,42 +0,0 @@
"use client";
import { Button } from "@/components/shadcn";
import { DialogFooter } from "@/components/shadcn/dialog";
import { Modal } from "@/components/shadcn/modal/modal";
interface OnboardingWelcomeModalProps {
open: boolean;
flowTitle?: string;
flowDescription?: string;
onAccept: () => void;
onDismiss: () => void;
}
export function OnboardingWelcomeModal({
open,
flowTitle,
flowDescription,
onAccept,
onDismiss,
}: OnboardingWelcomeModalProps) {
return (
<Modal
open={open}
title={flowTitle}
description={flowDescription}
size="lg"
// Overlay/Escape/X counts as dismiss — gate persists the record once.
onOpenChange={(next) => {
if (!next) onDismiss();
}}
>
<DialogFooter>
{/* Outline matches the app's modal secondary action (e.g. Launch Scan's Cancel). */}
<Button variant="outline" onClick={onDismiss}>
Skip for now
</Button>
<Button onClick={onAccept}>Get started</Button>
</DialogFooter>
</Modal>
);
}
+20 -1
View File
@@ -19,11 +19,30 @@ posts to the API.
| Per-route trigger | `ui/components/onboarding/onboarding-trigger.tsx` |
| Ephemeral sequence slice | `ui/store/onboarding-sequence.ts` |
| Checkpoint watcher + dialog | `ui/components/onboarding/onboarding-checkpoint-{watcher,dialog}.tsx` |
| Mandatory new-user gate | `ui/components/onboarding/onboarding-gate.tsx` |
| New-tenant gate (first-run redirect) | `ui/components/onboarding/onboarding-gate.tsx` |
| First-run marker (once per browser) | `ui/lib/onboarding/first-run-marker.ts` |
| Step outcome events (window) | `ui/lib/onboarding/onboarding-events.ts` |
| Invite step before the checkpoint | `ui/components/onboarding/onboarding-invite-{step,dialog}.tsx` |
| Manual replay list | `ui/components/ui/user-nav/user-nav.tsx` |
## First run
The gate is mounted in every deployment. When the tenant provably has no
providers (`hasProviders === false`), the user holds `manage_providers` and
neither the first-run marker (`prowler.onboarding.first-run`) nor an
add-provider completion record exists, it replaces the route once with
`/providers?addProvider=true&addProviderSource=first_run`, so the add-provider
wizard is already open. Billing routes defer it; an unknown provider count or a
user without the permission (an empty list may only mean limited visibility)
never triggers it.
In Cloud the URL also carries `&onboarding=add-provider` and the checkpoint is
armed. Because the wizard is already open, the providers page passes
`startAtTarget="provider-type"` to its `<OnboardingTrigger />`, which skips the
tour's welcome and "open the wizard" steps. A navbar replay with the wizard
closed still starts from the first step. Self-hosted deployments get the
redirect only: tours and the checkpoint stay Cloud-only.
## How the guided sequence works
1. The `(prowler)/layout.tsx` derives a tri-state `hasProviders` on every
@@ -18,14 +18,26 @@ describe("shouldStartOnboarding", () => {
it("returns true for a zero-provider user with no completion record", () => {
const result = shouldStartOnboarding({
hasProviders: false,
canManageProviders: true,
completionRecord: null,
});
expect(result).toBe(true);
});
it("returns false when the user cannot add providers, even in an empty tenant", () => {
// Limited-visibility users see zero providers without the tenant being empty.
const result = shouldStartOnboarding({
hasProviders: false,
canManageProviders: false,
completionRecord: null,
});
expect(result).toBe(false);
});
it("returns false when the user already has providers", () => {
const result = shouldStartOnboarding({
hasProviders: true,
canManageProviders: true,
completionRecord: null,
});
expect(result).toBe(false);
@@ -34,6 +46,7 @@ describe("shouldStartOnboarding", () => {
it("returns false when a dismissed record exists", () => {
const result = shouldStartOnboarding({
hasProviders: false,
canManageProviders: true,
completionRecord: recordWithState(TOUR_COMPLETION_STATES.DISMISSED),
});
expect(result).toBe(false);
@@ -42,6 +55,7 @@ describe("shouldStartOnboarding", () => {
it("returns false when a completed record exists", () => {
const result = shouldStartOnboarding({
hasProviders: false,
canManageProviders: true,
completionRecord: recordWithState(TOUR_COMPLETION_STATES.COMPLETED),
});
expect(result).toBe(false);
@@ -50,6 +64,7 @@ describe("shouldStartOnboarding", () => {
it("returns false when a skipped record exists", () => {
const result = shouldStartOnboarding({
hasProviders: false,
canManageProviders: true,
completionRecord: recordWithState(TOUR_COMPLETION_STATES.SKIPPED),
});
expect(result).toBe(false);
@@ -59,6 +74,7 @@ describe("shouldStartOnboarding", () => {
// strict === false check rejects non-false values; don't force onboarding on unknown state
const result = shouldStartOnboarding({
hasProviders: undefined,
canManageProviders: true,
completionRecord: null,
});
expect(result).toBe(false);
@@ -67,6 +83,7 @@ describe("shouldStartOnboarding", () => {
it("fails open when hasProviders is null", () => {
const result = shouldStartOnboarding({
hasProviders: null as unknown as boolean,
canManageProviders: true,
completionRecord: null,
});
expect(result).toBe(false);
+24
View File
@@ -0,0 +1,24 @@
// Durable "this browser already went through the first-run redirect" memory.
// Self-hosted deployments run no tour, so no completion record would ever be
// written there; without this marker an empty tenant would be redirected on
// every page load.
const FIRST_RUN_MARKER_KEY = "prowler.onboarding.first-run";
export function isFirstRunHandled(): boolean {
if (typeof window === "undefined") return true;
try {
return window.localStorage.getItem(FIRST_RUN_MARKER_KEY) !== null;
} catch {
// Unreadable storage must not redirect forever: treat as handled.
return true;
}
}
export function markFirstRunHandled(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(FIRST_RUN_MARKER_KEY, "true");
} catch {
// Non-fatal: a repeated redirect beats a thrown render.
}
}
+6 -2
View File
@@ -3,15 +3,19 @@ import type { TourCompletionRecord } from "@/lib/tours/tour-types";
export interface GateDecisionInput {
// `undefined` allowed; strict `=== false` check below fails open on ambiguous signals.
hasProviders: boolean | undefined;
// Limited-visibility users list zero providers in a tenant that is not empty.
canManageProviders: boolean;
completionRecord: TourCompletionRecord | null;
}
// Only forces onboarding when providers are provably absent and no record exists.
// Only forces onboarding when providers are provably absent, the user can add one
// and no record exists.
export function shouldStartOnboarding({
hasProviders,
canManageProviders,
completionRecord,
}: GateDecisionInput): boolean {
const hasNoRecord =
completionRecord === null || completionRecord === undefined;
return hasProviders === false && hasNoRecord;
return hasProviders === false && canManageProviders && hasNoRecord;
}
@@ -195,4 +195,66 @@ describe("useDriverTour lifecycle", () => {
// ...but no completion record was persisted, so the tour can reappear later.
expect(store.get({ id: tour.id, version: tour.version })).toBeNull();
});
describe("when asked to start at an anchored step", () => {
const anchoredTour = {
id: "anchored-tour",
version: 1,
coversFiles: [],
steps: [
{ title: "Welcome", description: "Intro" },
{ target: "late", title: "Late anchor", description: "Inside a modal" },
],
} satisfies TourDefinition;
function AnchoredProbe({
onResult,
}: {
onResult: (result: UseDriverTourResult) => void;
}) {
onResult(
useDriverTour(anchoredTour, { autoOpen: false, store: createStore() }),
);
return null;
}
afterEach(() => {
document.body.innerHTML = "";
});
it("skips the earlier steps once the anchor is in the DOM", async () => {
// Given
let latestResult: UseDriverTourResult | undefined;
render(<AnchoredProbe onResult={(result) => (latestResult = result)} />);
const anchor = document.createElement("div");
anchor.setAttribute("data-tour-id", "anchored-tour-late");
document.body.appendChild(anchor);
// When
await act(async () => {
latestResult?.start("late");
});
// Then
expect(driverHarness.instances[0].drive).toHaveBeenCalledExactlyOnceWith(
1,
);
});
it("starts from the first step when the target is not part of the tour", async () => {
// Given
let latestResult: UseDriverTourResult | undefined;
render(<AnchoredProbe onResult={(result) => (latestResult = result)} />);
// When
await act(async () => {
latestResult?.start("unknown");
});
// Then
expect(
driverHarness.instances[0].drive,
).toHaveBeenCalledExactlyOnceWith();
});
});
});
+23 -4
View File
@@ -102,7 +102,8 @@ export interface UseDriverTourOptions<TTarget extends string = string> {
}
export interface UseDriverTourResult {
start: () => void;
/** Optional step `target` to begin from, skipping the steps before it. */
start: (startAtTarget?: string) => void;
stop: () => void;
/** True if a completion record exists for `(tour.id, tour.version)`. */
hasCompleted: boolean;
@@ -394,11 +395,29 @@ export function useDriverTour<TTarget extends string>(
}, [autoOpen, enabled, hasCompleted, tourId, tourVersion]);
return {
start: () => {
start: (startAtTarget) => {
const instance = driverRef.current;
if (!instance) return;
activeTourInstance = instance;
instance.drive();
const startIndex = startAtTarget
? tour.steps.findIndex((step) => step.target === startAtTarget)
: -1;
if (!startAtTarget || startIndex <= 0) {
activeTourInstance = instance;
instance.drive();
return;
}
// The anchor may mount right after the caller (e.g. a modal opening), so wait for it.
waitForElement(getTourTargetSelector(tourId, startAtTarget))
.then(() => {
if (driverRef.current !== instance || instance.isActive()) return;
activeTourInstance = instance;
instance.drive(startIndex);
})
.catch(() => {
// Anchor never appeared (e.g. the modal was dismissed); skip the tour.
});
},
stop: () => driverRef.current?.destroy(),
hasCompleted,
+24 -1
View File
@@ -7,7 +7,11 @@ import { StoreInitializer } from "./store-initializer";
describe("StoreInitializer", () => {
beforeEach(() => {
localStorage.clear();
useUIStore.setState({ hasProviders: false, registryEligible: false });
useUIStore.setState({
hasProviders: false,
hasProvidersResolved: false,
registryEligible: false,
});
});
it("keeps Registry hidden when the server sends no eligibility decision", () => {
@@ -33,4 +37,23 @@ describe("StoreInitializer", () => {
expect(persisted.state?.hasProviders).toBe(true);
expect(persisted.state).not.toHaveProperty("registryEligible");
});
it("leaves the provider count unresolved when the server could not determine it", () => {
// Given / When
render(<StoreInitializer values={{ hasProviders: undefined }} />);
// Then
expect(useUIStore.getState().hasProvidersResolved).toBe(false);
});
it("resolves a confirmed empty tenant without persisting the resolution", () => {
// Given / When
render(<StoreInitializer values={{ hasProviders: false }} />);
// Then
expect(useUIStore.getState().hasProviders).toBe(false);
expect(useUIStore.getState().hasProvidersResolved).toBe(true);
const persisted = JSON.parse(localStorage.getItem("ui-store") ?? "{}");
expect(persisted.state).not.toHaveProperty("hasProvidersResolved");
});
});
+8 -4
View File
@@ -4,6 +4,8 @@ import { persist } from "zustand/middleware";
interface UIStoreState {
isSideMenuOpen: boolean;
hasProviders: boolean;
// True once the server reported a definitive provider count for this session.
hasProvidersResolved: boolean;
registryEligible: boolean;
openSideMenu: () => void;
@@ -17,17 +19,19 @@ export const useUIStore = create<UIStoreState>()(
(set) => ({
isSideMenuOpen: false,
hasProviders: false,
hasProvidersResolved: false,
registryEligible: false,
openSideMenu: () => set({ isSideMenuOpen: true }),
closeSideMenu: () => set({ isSideMenuOpen: false }),
setHasProviders: (value: boolean) => set({ hasProviders: value }),
setHasProviders: (value: boolean) =>
set({ hasProviders: value, hasProvidersResolved: true }),
setRegistryEligible: (value: boolean) => set({ registryEligible: value }),
}),
{
name: "ui-store",
// Registry eligibility is a per-request server decision; persisting it
// would resurface a stale entry on the next session before the server
// seed corrects it.
// Registry eligibility and the provider-count resolution are per-request
// server decisions; persisting them would resurface a stale entry on the
// next session before the server seed corrects it.
partialize: ({ isSideMenuOpen, hasProviders }) => ({
isSideMenuOpen,
hasProviders,
+3 -2
View File
@@ -33,13 +33,14 @@
### Expected Result
- Sign-up succeeds and redirects to Login.
- User can log in successfully using the created credentials and reach the home page.
- User can log in successfully using the created credentials.
- Because the new tenant has no providers, the first run lands on `/providers` with the add-provider wizard already open (instead of the home page).
### Key verification points
- After submitting sign-up, the URL changes to `/sign-in`.
- The newly created credentials can be used to sign in successfully.
- After login, the user lands on the home (`/`) and main content is visible.
- After login, the user lands on `/providers` and the "Adding A Provider" wizard is visible.
### Notes
+8 -2
View File
@@ -1,6 +1,7 @@
import { test } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { SignUpPage } from "./sign-up-page";
import { SignInPage } from "../sign-in-base/sign-in-base-page";
import { ProvidersPage } from "../providers/providers-page";
import { makeSuffix } from "../helpers";
test.describe("Sign Up Flow", () => {
@@ -45,7 +46,12 @@ test.describe("Sign Up Flow", () => {
email: uniqueEmail,
password: password,
});
await signInPage.verifySuccessfulLogin();
// A brand-new tenant has no providers, so the first run lands on the
// add-provider wizard instead of the Overview.
const providersPage = new ProvidersPage(page);
await expect(page).toHaveURL(/\/providers/);
await providersPage.verifyWizardModalOpen();
},
);
});