6.9 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 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
- 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".
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).