diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx
index 4a6244ffec..495fc83906 100644
--- a/ui/app/(prowler)/layout.tsx
+++ b/ui/app/(prowler)/layout.tsx
@@ -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({
- {/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */}
-
+ {/* Tri-state for both: an unknown count leaves the store unresolved and the gate closed. */}
+
+ {/* Every deployment: an empty tenant lands on the add-provider wizard once. */}
+
{cloudEnabled && (
<>
-
{/* Single mount point so the watcher survives post-connect navigation. */}
{/* Persistent banner shown only while a guided sequence is active. */}
diff --git a/ui/changelog.d/first-run-add-provider.changed.md b/ui/changelog.d/first-run-add-provider.changed.md
new file mode 100644
index 0000000000..a40ae414e3
--- /dev/null
+++ b/ui/changelog.d/first-run-add-provider.changed.md
@@ -0,0 +1 @@
+New tenants without providers land on the Add Provider wizard on first sign-in instead of a welcome modal
diff --git a/ui/components/onboarding/__tests__/onboarding-gate.test.tsx b/ui/components/onboarding/__tests__/onboarding-gate.test.tsx
index 9a5f7cf88e..81fffc83dd 100644
--- a/ui/components/onboarding/__tests__/onboarding-gate.test.tsx
+++ b/ui/components/onboarding/__tests__/onboarding-gate.test.tsx
@@ -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();
// 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();
@@ -68,170 +78,127 @@ describe("OnboardingGate", () => {
rerender();
// 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();
// 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();
- 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();
-
- 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();
-
- 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();
-
- 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();
-
- 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();
-
- 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();
- 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();
+ await waitFor(() => expect(replaceMock).toHaveBeenCalledOnce());
+ unmount();
+ replaceMock.mockClear();
+
+ // When
render();
- 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();
- 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();
- 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();
+
+ // 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();
+
+ // 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();
+
+ // 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();
+
+ // Then
+ expect(replaceMock).not.toHaveBeenCalled();
+ });
+
+ it("can be mounted with the prop omitted entirely", () => {
+ // Given / When
+ render();
+
+ // Then
+ expect(replaceMock).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx b/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx
index e0f040f832..fdf884d257 100644
--- a/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx
+++ b/ui/components/onboarding/__tests__/onboarding-trigger.test.tsx
@@ -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(
+ ,
+ );
+
+ // 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",
diff --git a/ui/components/onboarding/__tests__/onboarding-welcome-modal.test.tsx b/ui/components/onboarding/__tests__/onboarding-welcome-modal.test.tsx
deleted file mode 100644
index a3665031c2..0000000000
--- a/ui/components/onboarding/__tests__/onboarding-welcome-modal.test.tsx
+++ /dev/null
@@ -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(
- ,
- );
-
- 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(
- ,
- );
-
- 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(
- ,
- );
-
- 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(
- ,
- );
-
- expect(
- screen.queryByRole("button", { name: /get started/i }),
- ).not.toBeInTheDocument();
- });
- });
-});
diff --git a/ui/components/onboarding/onboarding-gate.tsx b/ui/components/onboarding/onboarding-gate.tsx
index f32d411938..d686fdd3a0 100644
--- a/ui/components/onboarding/onboarding-gate.tsx
+++ b/ui/components/onboarding/onboarding-gate.tsx
@@ -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 ;
+}
+
+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 (
-
- );
+ return null;
}
diff --git a/ui/components/onboarding/onboarding-trigger.tsx b/ui/components/onboarding/onboarding-trigger.tsx
index 2a1854f3c8..270afbd721 100644
--- a/ui/components/onboarding/onboarding-trigger.tsx
+++ b/ui/components/onboarding/onboarding-trigger.tsx
@@ -34,6 +34,8 @@ interface OnboardingTriggerProps {
flow: OnboardingFlow; // force-started when the sequence names it or `?onboarding=` matches
stepHandlers?: { [K in TTarget]?: TourStepHandlers };
configOverrides?: Partial;
+ // 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({
flow,
stepHandlers,
configOverrides,
+ startAtTarget,
}: OnboardingTriggerProps) {
const searchParams = useSearchParams();
const param = searchParams?.get(ONBOARDING_PARAM) ?? null; // null outside Suspense context
@@ -108,6 +111,7 @@ export function OnboardingTrigger({
queryString={request.queryString}
stepHandlers={stepHandlers}
configOverrides={configOverrides}
+ startAtTarget={startAtTarget}
/>
);
}
@@ -118,6 +122,7 @@ interface OnboardingTourRunnerProps {
queryString: string;
stepHandlers?: { [K in TTarget]?: TourStepHandlers };
configOverrides?: Partial;
+ startAtTarget?: TTarget;
}
function OnboardingTourRunner({
@@ -126,6 +131,7 @@ function OnboardingTourRunner({
queryString,
stepHandlers,
configOverrides,
+ startAtTarget,
}: OnboardingTourRunnerProps) {
// onClosed is intentionally inert — the banner owns advance/exit for both modes.
const { start } = useDriverTour(flow.tour, {
@@ -144,7 +150,7 @@ function OnboardingTourRunner({
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).
diff --git a/ui/components/onboarding/onboarding-welcome-modal.tsx b/ui/components/onboarding/onboarding-welcome-modal.tsx
deleted file mode 100644
index 566db34cba..0000000000
--- a/ui/components/onboarding/onboarding-welcome-modal.tsx
+++ /dev/null
@@ -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 (
- {
- if (!next) onDismiss();
- }}
- >
-
- {/* Outline matches the app's modal secondary action (e.g. Launch Scan's Cancel). */}
-
-
-
-
- );
-}
diff --git a/ui/lib/onboarding/README.md b/ui/lib/onboarding/README.md
index 0b76ec6ced..29f187f729 100644
--- a/ui/lib/onboarding/README.md
+++ b/ui/lib/onboarding/README.md
@@ -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 ``, 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
diff --git a/ui/lib/onboarding/__tests__/gate-decision.test.ts b/ui/lib/onboarding/__tests__/gate-decision.test.ts
index 833987be7e..dbe551c2dc 100644
--- a/ui/lib/onboarding/__tests__/gate-decision.test.ts
+++ b/ui/lib/onboarding/__tests__/gate-decision.test.ts
@@ -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);
diff --git a/ui/lib/onboarding/first-run-marker.ts b/ui/lib/onboarding/first-run-marker.ts
new file mode 100644
index 0000000000..0d61482cff
--- /dev/null
+++ b/ui/lib/onboarding/first-run-marker.ts
@@ -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.
+ }
+}
diff --git a/ui/lib/onboarding/gate-decision.ts b/ui/lib/onboarding/gate-decision.ts
index 656a3b0cb4..39f7437a69 100644
--- a/ui/lib/onboarding/gate-decision.ts
+++ b/ui/lib/onboarding/gate-decision.ts
@@ -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;
}
diff --git a/ui/lib/tours/__tests__/use-driver-tour.lifecycle.test.tsx b/ui/lib/tours/__tests__/use-driver-tour.lifecycle.test.tsx
index 1c300787d4..e3a7382b83 100644
--- a/ui/lib/tours/__tests__/use-driver-tour.lifecycle.test.tsx
+++ b/ui/lib/tours/__tests__/use-driver-tour.lifecycle.test.tsx
@@ -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( (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( (latestResult = result)} />);
+
+ // When
+ await act(async () => {
+ latestResult?.start("unknown");
+ });
+
+ // Then
+ expect(
+ driverHarness.instances[0].drive,
+ ).toHaveBeenCalledExactlyOnceWith();
+ });
+ });
});
diff --git a/ui/lib/tours/use-driver-tour.ts b/ui/lib/tours/use-driver-tour.ts
index d7228cd303..c6d06a07ad 100644
--- a/ui/lib/tours/use-driver-tour.ts
+++ b/ui/lib/tours/use-driver-tour.ts
@@ -102,7 +102,8 @@ export interface UseDriverTourOptions {
}
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(
}, [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,
diff --git a/ui/store/ui/store-initializer.test.tsx b/ui/store/ui/store-initializer.test.tsx
index 01a3545bb9..77428af1c5 100644
--- a/ui/store/ui/store-initializer.test.tsx
+++ b/ui/store/ui/store-initializer.test.tsx
@@ -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();
+
+ // Then
+ expect(useUIStore.getState().hasProvidersResolved).toBe(false);
+ });
+
+ it("resolves a confirmed empty tenant without persisting the resolution", () => {
+ // Given / When
+ render();
+
+ // 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");
+ });
});
diff --git a/ui/store/ui/store.ts b/ui/store/ui/store.ts
index 5aa321e0b1..522658995a 100644
--- a/ui/store/ui/store.ts
+++ b/ui/store/ui/store.ts
@@ -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()(
(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,
diff --git a/ui/tests/sign-up/sign-up.md b/ui/tests/sign-up/sign-up.md
index 542d1c0778..00b4639dbf 100644
--- a/ui/tests/sign-up/sign-up.md
+++ b/ui/tests/sign-up/sign-up.md
@@ -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
diff --git a/ui/tests/sign-up/sign-up.spec.ts b/ui/tests/sign-up/sign-up.spec.ts
index 3c732c6497..62336db7c6 100644
--- a/ui/tests/sign-up/sign-up.spec.ts
+++ b/ui/tests/sign-up/sign-up.spec.ts
@@ -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();
},
);
});