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(); }, ); });