mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-22 19:34:22 +00:00
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
117 lines
7.2 KiB
Markdown
117 lines
7.2 KiB
Markdown
# 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
|
||
|
||
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"`.
|
||
|
||
## 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`).
|