Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
7.2 KiB
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 guided sequence run on client state (the sequence slice
is ephemeral and resets on a hard reload); tour completion and the one-time
markers persist in localStorage. Server input is the tri-state hasProviders
the layout derives from getProviders(), plus the invitation the invite step
posts to the API.
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 |
| New-tenant gate (first-run redirect) | ui/components/onboarding/onboarding-gate.tsx |
| First-run marker (once per tenant) | 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.<tenantId>, so a
first run in one tenant never silences it for another on the same browser; the
bare prowler.onboarding.first-run key is a browser-wide opt-out, which is what
the e2e storage state sets) 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
- The
(prowler)/layout.tsxderives a tri-statehasProviderson every navigation and mounts<OnboardingCheckpointWatcher />(sibling to the gate). - When the watcher observes a concrete
false → truehasProvidersflip (the user actually connected a provider), it opens the checkpoint dialog once. Anundefined → true(user already had providers) never fires. A localStorage marker (prowler.onboarding.checkpoint) prevents re-appearance. - "Continue the tour" calls
startSequence(nextFlowId)on the ephemeraluseOnboardingSequenceStoreand navigates to that flow's route. - Each route mounts an
<OnboardingTrigger flow={...} />. The trigger force starts the flow whenslice.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. - On tour close,
useDriverTour'sonClosed(state)reports the outcome:completed→advance()(navigate to the next flow),skipped/dismissed→stop()(the sequence ends; closing any tour ends the sequence). - The slice is ephemeral (plain Zustand
create, nopersist). It carriescurrentFlowIdacross 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.
- Tour file —
ui/lib/tours/<flow-id>.tour.tsviadefineTour<Target>and theassets/tour-template.ts. Keep it shallow: a centered welcome step plus 1–2 anchored steps.coversFilesscopes the drift check. - 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:checkhard-fails a tour target with no matching anchor). - Registry entry — add
{ id, order, title, description, route, tour }toonboardingFlowsinregistry.ts. Ordering is data (order). - Trigger mount — render
<OnboardingTrigger flow={getFlowById("<id>")!} />inside the route's client host. PassstepHandlers/configOverridesonly 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 tourtargetmust resolve to a realdata-tour-idanchor within itscoversFiles.pnpm exec vitest run --project unit— pure logic (slice, helpers, registry, tour shapes). The driver primitive short-circuits inNODE_ENV==="test".
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).