Files
prowler/ui/lib/onboarding/README.md
T

6.9 KiB
Raw Blame History

Onboarding system (developer guide)

The onboarding system runs short, anchored driver.js tours and orchestrates a cross-route guided sequence after a user connects their first provider. The tours and the sequence live entirely in client state and localStorage — no backend coupling. The profile step described below is the exception: it persists the tenant's answer through POST /onboarding-profiles, and the server answer, not localStorage, decides whether it is still owed.

Building blocks

Concern File
Flow registry (single source of truth) ui/lib/onboarding/registry.ts
Flow type (OnboardingFlow) ui/lib/onboarding/onboarding-types.ts
Tour definitions (*.tour.ts) ui/lib/tours/
Driver primitive (useDriverTour) ui/lib/tours/use-driver-tour.ts
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
Profile step in front of the gate ui/components/onboarding/onboarding-profile-{gate,modal}.tsx
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

How the guided sequence works

  1. The (prowler)/layout.tsx derives a tri-state hasProviders on every navigation and mounts <OnboardingCheckpointWatcher /> (sibling to the gate).
  2. When the watcher observes a concrete false → true hasProviders flip (the user actually connected a provider), it opens the checkpoint dialog once. An undefined → true (user already had providers) never fires. A localStorage marker (prowler.onboarding.checkpoint) prevents re-appearance.
  3. "Continue the tour" calls startSequence(nextFlowId) on the ephemeral useOnboardingSequenceStore and navigates to that flow's route.
  4. Each route mounts an <OnboardingTrigger flow={...} />. The trigger force starts the flow when slice.currentFlowId === flow.id (sequence) or when the ?onboarding=<id> param matches (replay). The StrictMode-safe latch / keyed runner / empty-deps force-start is preserved verbatim.
  5. On tour close, useDriverTour's onClosed(state) reports the outcome: completed → advance() (navigate to the next flow), skipped/dismissed → stop() (the sequence ends; closing any tour ends the sequence).
  6. The slice is ephemeral (plain Zustand create, no persist). It carries currentFlowId across client navigations but resets on a hard reload, so a mid-sequence refresh never re-fires.

attack-paths is special: its page already owns a driver, so its registry entry sets ownsAutoOpen: true, the trigger does not mount a runner for it, and the page wires onClosed to the slice itself (single-fire).

Add a new flow (the extensibility contract)

A new flow is one registry entry + one tour file + its anchors + a trigger mount — no gate, modal, or nav edits.

  1. Tour file — ui/lib/tours/<flow-id>.tour.ts via defineTour<Target> and the assets/tour-template.ts. Keep it shallow: a centered welcome step plus 1–2 anchored steps. coversFiles scopes the drift check.
  2. Anchors — add data-tour-id="<flow-id>-<target>" on the page-specific client component for each anchored step (never the shared Navbar). The tour file and its anchors MUST ship in the SAME PR (tour:check hard-fails a tour target with no matching anchor).
  3. Registry entry — add { id, order, title, description, route, tour } to onboardingFlows in registry.ts. Ordering is data (order).
  4. Trigger mount — render <OnboardingTrigger flow={getFlowById("<id>")!} /> inside the route's client host. Pass stepHandlers/configOverrides only if the flow needs them (e.g. add-provider opens the wizard).

The avatar "Product tour" submenu and advance() both derive from getOrderedFlows(), so a new flow appears in the replay list and participates in the sequence automatically.

CI gates

  • pnpm run tour:check (ui/scripts/check-tour-alignment.mjs) — every tour target must resolve to a real data-tour-id anchor within its coversFiles.
  • pnpm exec vitest run --project unit — pure logic (slice, helpers, registry, tour shapes). The driver primitive short-circuits in NODE_ENV==="test".

Profile step

Before the mandatory gate offers the first tour, OnboardingProfileGate asks a new tenant three closed questions (cloud accounts, area of work and position) and records the answer, or the skip, through POST /onboarding-profiles. The API keeps the first answer per tenant, so the layout only reads isOnboardingProfileRecorded() for tenants without providers and the gate fails open on any doubt (shouldStartOnboardingProfile). A per-tenant localStorage marker (prowler.onboarding.profile.<tenantId>) spares the flash on later renders; a submission or skip the API did not store writes no marker, so the step returns next login.

Each resolution (shown, submitted with the answers, skipped) is announced as a prowler:onboarding-profile-step window event (dispatchOnboardingProfileStep). The step has no listener of its own: a deployment that wants to observe it subscribes from outside, so the onboarding stays free of tracking dependencies.

Invite step

The first time the checkpoint opens (right after the first provider is connected), OnboardingCheckpointWatcher renders OnboardingInviteStep before the checkpoint dialog: the members-page SendInvitationForm, tagged source=onboarding for the API, plus a "Skip for now" action. If the roles cannot be loaded, or have not arrived after five seconds, only the skip is offered, so the checkpoint is never blocked. The store stays open while the step shows, so the checkpoint dialog follows unchanged once it resolves. A per-tenant localStorage marker (prowler.onboarding.invite.<tenantId>) keeps it to one offer; without a usable tenantId the step is not offered.

Outcomes (shown, submitted, skipped) are announced as the prowler:onboarding-invite-step window event (dispatchOnboardingInviteStep).