+
+ );
+}
diff --git a/ui/components/onboarding/onboarding-invite-step.tsx b/ui/components/onboarding/onboarding-invite-step.tsx
new file mode 100644
index 0000000000..6f7fc2e7a9
--- /dev/null
+++ b/ui/components/onboarding/onboarding-invite-step.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { useState } from "react";
+
+import { getOnboardingInviteRoles } from "@/actions/onboarding/invite";
+import { useMountEffect } from "@/hooks/use-mount-effect";
+import {
+ dispatchOnboardingInviteStep,
+ ONBOARDING_STEP_OUTCOME,
+} from "@/lib/onboarding/onboarding-events";
+import type { InvitationRoleOption } from "@/types/onboarding-invite";
+
+import { OnboardingInviteDialog } from "./onboarding-invite-dialog";
+
+interface OnboardingInviteStepProps {
+ onDone: () => void;
+}
+
+// Roles that have not arrived by then count as unavailable, so a request
+// that never answers cannot hold the checkpoint behind an empty step.
+const ROLES_TIMEOUT_MS = 5_000;
+
+// Mounted only while the step is showing: loads the roles once, announces
+// the impression once, and resolves through a sent invitation or a skip.
+export function OnboardingInviteStep({ onDone }: OnboardingInviteStepProps) {
+ // `null` until the roles settle: the invitation form takes its default
+ // role from the list at mount, so the dialog renders once the list is known.
+ const [roles, setRoles] = useState(null);
+
+ useMountEffect(() => {
+ dispatchOnboardingInviteStep({ outcome: ONBOARDING_STEP_OUTCOME.SHOWN });
+ let active = true;
+ let timer: ReturnType | undefined;
+ // First answer wins: a late response or a timer after it is ignored.
+ const settle = (loaded: InvitationRoleOption[]) => {
+ if (!active) return;
+ active = false;
+ clearTimeout(timer);
+ setRoles(loaded);
+ };
+ // Without roles the dialog offers only the skip, so the checkpoint is
+ // never blocked: not by a failed read, not by one that never answers.
+ timer = setTimeout(() => settle([]), ROLES_TIMEOUT_MS);
+ getOnboardingInviteRoles()
+ .then(settle)
+ .catch(() => settle([]));
+ return () => {
+ active = false;
+ clearTimeout(timer);
+ };
+ });
+
+ if (roles === null) return null;
+
+ return (
+ {
+ dispatchOnboardingInviteStep({
+ outcome: ONBOARDING_STEP_OUTCOME.SUBMITTED,
+ });
+ onDone();
+ }}
+ onSkip={() => {
+ dispatchOnboardingInviteStep({
+ outcome: ONBOARDING_STEP_OUTCOME.SKIPPED,
+ });
+ onDone();
+ }}
+ />
+ );
+}
diff --git a/ui/lib/onboarding/README.md b/ui/lib/onboarding/README.md
index 99f632e2bf..390466e429 100644
--- a/ui/lib/onboarding/README.md
+++ b/ui/lib/onboarding/README.md
@@ -21,6 +21,7 @@ server answer, not localStorage, decides whether it is still owed.
| 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
@@ -95,3 +96,18 @@ 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.`)
+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`).
diff --git a/ui/lib/onboarding/invite-marker.ts b/ui/lib/onboarding/invite-marker.ts
new file mode 100644
index 0000000000..4fac2c5acb
--- /dev/null
+++ b/ui/lib/onboarding/invite-marker.ts
@@ -0,0 +1,48 @@
+// Durable "this browser already saw the invite step" memory, mirroring the
+// checkpoint marker: the step is offered once per tenant onboarding.
+//
+// The key carries the tenant, like the profile marker, because the offer is
+// tenant-scoped: a user who saw it for one tenant must still see it when they
+// onboard another.
+const ONBOARDING_INVITE_MARKER_PREFIX = "prowler.onboarding.invite";
+
+// Tenant ids are UUIDs; anything else is refused rather than concatenated
+// into a storage key.
+const TENANT_ID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export function onboardingInviteMarkerKey(
+ tenantId: string | null | undefined,
+): string | null {
+ if (!tenantId || !TENANT_ID_PATTERN.test(tenantId)) return null;
+ return `${ONBOARDING_INVITE_MARKER_PREFIX}.${tenantId.toLowerCase()}`;
+}
+
+export function isOnboardingInviteHandled(
+ tenantId: string | null | undefined,
+): boolean {
+ if (typeof window === "undefined") return true;
+ const key = onboardingInviteMarkerKey(tenantId);
+ // Without a usable tenant the step cannot be attributed to an onboarding,
+ // so it is not offered rather than offered to everyone.
+ if (!key) return true;
+ try {
+ return window.localStorage.getItem(key) !== null;
+ } catch {
+ // Unreadable storage must not re-open the step forever: treat as handled.
+ return true;
+ }
+}
+
+export function markOnboardingInviteHandled(
+ tenantId: string | null | undefined,
+): void {
+ if (typeof window === "undefined") return;
+ const key = onboardingInviteMarkerKey(tenantId);
+ if (!key) return;
+ try {
+ window.localStorage.setItem(key, "true");
+ } catch {
+ // Non-fatal: a re-shown step beats a thrown render.
+ }
+}
diff --git a/ui/types/onboarding-invite.ts b/ui/types/onboarding-invite.ts
new file mode 100644
index 0000000000..e1bcdec4d6
--- /dev/null
+++ b/ui/types/onboarding-invite.ts
@@ -0,0 +1,11 @@
+// Query param the API can read to tell where an invitation was sent from.
+export const INVITATION_SOURCE_PARAM = "source";
+
+export const INVITATION_SOURCE = {
+ ONBOARDING: "onboarding",
+} as const;
+
+export interface InvitationRoleOption {
+ id: string;
+ name: string;
+}