feat(ui): offer an invite-your-team step before the onboarding checkpoint (#12819)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Bailo
2026-09-17 11:58:53 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 3069486549
commit 396ccf56bb
16 changed files with 879 additions and 16 deletions
+72
View File
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}),
);
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: vi.fn(),
handleApiResponse: handleApiResponseMock,
}));
import { sendInvite } from "./invitation";
const inviteFormData = (source?: string) => {
const formData = new FormData();
formData.append("email", "teammate@company.com");
formData.append("role", "22222222-2222-4222-8222-222222222222");
if (source) formData.append("source", source);
return formData;
};
describe("sendInvite", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockReset().mockResolvedValue(new Response("{}"));
getAuthHeadersMock.mockReset().mockResolvedValue({});
handleApiResponseMock
.mockReset()
.mockResolvedValue({ data: { id: "inv" } });
});
it("posts to the invitations endpoint without a source by default", async () => {
// When
await sendInvite(inviteFormData());
// Then
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/v1/tenants/invitations",
expect.objectContaining({ method: "POST" }),
);
});
it("forwards the invitation source as a query param", async () => {
// When
await sendInvite(inviteFormData("onboarding"));
// Then
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/v1/tenants/invitations?source=onboarding",
expect.objectContaining({ method: "POST" }),
);
const body = JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string);
expect(body.data.attributes).toEqual({ email: "teammate@company.com" });
});
});
+5
View File
@@ -51,7 +51,12 @@ export const sendInvite = async (formData: FormData) => {
const email = formData.get("email");
const role = formData.get("role");
const source = formData.get("source");
const url = new URL(`${apiBaseUrl}/tenants/invitations`);
// Origin of the invitation (e.g. `onboarding`); the API may record it.
if (typeof source === "string" && source) {
url.searchParams.set("source", source);
}
const body = JSON.stringify({
data: {
+21
View File
@@ -0,0 +1,21 @@
"use server";
import { getRoles } from "@/actions/roles";
import type { InvitationRoleOption } from "@/types/onboarding-invite";
const ROLES_PAGE_SIZE = 50;
// Roles the onboarding invite step can offer; empty when the read fails so
// the step can fall back to skipping rather than blocking the checkpoint.
export const getOnboardingInviteRoles = async (): Promise<
InvitationRoleOption[]
> => {
const rolesData = await getRoles({ pageSize: ROLES_PAGE_SIZE });
const roles: unknown = rolesData?.data;
if (!Array.isArray(roles)) return [];
return roles.flatMap((role) =>
typeof role?.id === "string" && typeof role?.attributes?.name === "string"
? [{ id: role.id, name: role.attributes.name }]
: [],
);
};
+5 -9
View File
@@ -76,8 +76,8 @@ export default async function RootLayout({
let hasProviders: boolean | undefined = false;
// Same tri-state for the onboarding profile step; only new tenants pay the read.
let profileRecorded: boolean | undefined = true;
// Scopes the step's local marker, so answering for one tenant does not
// silence it for another.
// Scopes the onboarding steps' local markers, so resolving them for one
// tenant does not silence them for another.
let tenantId: string | null = null;
if (cloudEnabled) {
@@ -94,13 +94,9 @@ export default async function RootLayout({
hasProviders = Array.isArray(providersData?.data)
? providersData.data.length > 0
: undefined;
tenantId = (await auth())?.tenantId ?? null;
if (hasProviders === false) {
const [recorded, session] = await Promise.all([
isOnboardingProfileRecorded(),
auth(),
]);
profileRecorded = recorded;
tenantId = session?.tenantId ?? null;
profileRecorded = await isOnboardingProfileRecorded();
}
}
@@ -139,7 +135,7 @@ export default async function RootLayout({
tenantId={tenantId}
/>
{/* Single mount point so the watcher survives post-connect navigation. */}
<OnboardingCheckpointWatcher />
<OnboardingCheckpointWatcher tenantId={tenantId} />
{/* Persistent banner shown only while a guided sequence is active. */}
<OnboardingSequenceBanner hasCompletedScan={hasCompletedScan} />
</>
@@ -0,0 +1 @@
"Invite your team" step offered once after the first provider is connected, before the onboarding checkpoint, reusing the invitation form tagged with `source=onboarding`
@@ -0,0 +1,112 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SendInvitationForm } from "./send-invitation-form";
const { pushMock, sendInviteMock } = vi.hoisted(() => ({
pushMock: vi.fn(),
sendInviteMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
vi.mock("@/actions/invitations/invitation", () => ({
sendInvite: sendInviteMock,
}));
// Radix Select does not open in jsdom; a native select keeps the test on the
// form's behaviour rather than the dropdown's.
vi.mock("@/components/shadcn/select/select", () => ({
Select: ({
value,
onValueChange,
disabled,
children,
}: {
value?: string;
onValueChange: (value: string) => void;
disabled?: boolean;
children: React.ReactNode;
}) => (
<select
aria-label="Select a role"
value={value ?? ""}
disabled={disabled}
onChange={(event) => onValueChange(event.target.value)}
>
<option value="">Select a role</option>
{children}
</select>
),
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
SelectItem: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => <option value={value}>{children}</option>,
}));
const ROLES = [{ id: "22222222-2222-4222-8222-222222222222", name: "admin" }];
const fillAndSubmit = async (user: ReturnType<typeof userEvent.setup>) => {
await user.type(screen.getByLabelText(/email/i), "teammate@company.com");
await user.selectOptions(screen.getByRole("combobox"), ROLES[0].id);
await user.click(screen.getByRole("button", { name: /send invitation/i }));
};
describe("SendInvitationForm", () => {
beforeEach(() => {
pushMock.mockReset();
sendInviteMock.mockReset().mockResolvedValue({ data: { id: "inv-1" } });
});
it("navigates to the invitation details by default", async () => {
// Given
const user = userEvent.setup();
render(<SendInvitationForm roles={ROLES} isSelectorDisabled={false} />);
// When
await fillAndSubmit(user);
// Then
await waitFor(() =>
expect(pushMock).toHaveBeenCalledWith(
"/invitations/check-details/?id=inv-1",
),
);
const formData = sendInviteMock.mock.calls[0]?.[0] as FormData;
expect(formData.get("source")).toBeNull();
});
it("hands the new invitation to onSuccess and tags the source when given", async () => {
// Given
const user = userEvent.setup();
const onSuccess = vi.fn();
render(
<SendInvitationForm
roles={ROLES}
isSelectorDisabled={false}
source="onboarding"
onSuccess={onSuccess}
/>,
);
// When
await fillAndSubmit(user);
// Then
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith("inv-1"));
expect(pushMock).not.toHaveBeenCalled();
const formData = sendInviteMock.mock.calls[0]?.[0] as FormData;
expect(formData.get("source")).toBe("onboarding");
});
});
@@ -18,6 +18,7 @@ import {
SelectValue,
} from "@/components/shadcn/select/select";
import { ApiError } from "@/types";
import type { InvitationRoleOption } from "@/types/onboarding-invite";
const sendInvitationFormSchema = z.object({
email: z.email({ error: "Please enter a valid email" }),
@@ -26,15 +27,24 @@ const sendInvitationFormSchema = z.object({
export type FormValues = z.infer<typeof sendInvitationFormSchema>;
interface SendInvitationFormProps {
roles: InvitationRoleOption[];
defaultRole?: string;
isSelectorDisabled: boolean;
// Where the invitation was sent from, forwarded to the API as `?source=`
// so the origin can be told apart (e.g. the onboarding invite step).
source?: string;
// Replaces the default navigation to the invitation details page.
onSuccess?: (invitationId: string) => void;
}
export const SendInvitationForm = ({
roles = [],
defaultRole = "admin",
isSelectorDisabled = false,
}: {
roles: Array<{ id: string; name: string }>;
defaultRole?: string;
isSelectorDisabled: boolean;
}) => {
source,
onSuccess,
}: SendInvitationFormProps) => {
const { toast } = useToast();
const router = useRouter();
@@ -52,6 +62,7 @@ export const SendInvitationForm = ({
const formData = new FormData();
formData.append("email", values.email);
formData.append("role", values.roleId);
if (source) formData.append("source", source);
try {
const data = await sendInvite(formData);
@@ -82,7 +93,25 @@ export const SendInvitationForm = ({
}
});
} else {
const invitationId = data?.data?.id || "";
const invitationId = data?.data?.id;
if (!invitationId) {
// A transport failure returns nothing and a rejection can come
// back as a bare `error` without an `errors` array; neither
// created an invitation, so neither is a success.
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description:
typeof data?.error === "string"
? data.error
: "The invitation could not be sent. Please try again.",
});
return;
}
if (onSuccess) {
onSuccess(invitationId);
return;
}
router.push(`/invitations/check-details/?id=${invitationId}`);
}
} catch (_error) {
@@ -0,0 +1,132 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { onboardingInviteMarkerKey } from "@/lib/onboarding/invite-marker";
import { OnboardingCheckpointWatcher } from "../onboarding-checkpoint-watcher";
// Tests set this before render to control the store `open` flag.
let checkpointOpenState = false;
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/store/onboarding-sequence", () => ({
useOnboardingSequenceStore: {
getState: () => ({ startSequence: vi.fn() }),
},
}));
vi.mock("@/store/onboarding-checkpoint", () => ({
CHECKPOINT_MARKER: "prowler.onboarding.checkpoint",
useOnboardingCheckpointStore: Object.assign(
(selector: (state: { open: boolean }) => unknown) =>
selector({ open: checkpointOpenState }),
{
getState: () => ({ close: vi.fn() }),
},
),
}));
vi.mock("../onboarding-invite-step", () => ({
OnboardingInviteStep: ({ onDone }: { onDone: () => void }) => (
<button type="button" onClick={onDone}>
Resolve invite step
</button>
),
}));
const TENANT_ID = "3f6c2f1e-7b0a-4d5c-9a21-0c9f4f2a7b10";
const OTHER_TENANT_ID = "8a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d";
const MARKER_KEY = onboardingInviteMarkerKey(TENANT_ID) as string;
const CHECKPOINT_TITLE = "Provider added — keep exploring?";
describe("OnboardingCheckpointWatcher invite step", () => {
beforeEach(() => {
window.localStorage.clear();
checkpointOpenState = true;
});
it("offers the invite step before the checkpoint dialog and keeps the store open", async () => {
// Given
const user = userEvent.setup();
render(<OnboardingCheckpointWatcher tenantId={TENANT_ID} />);
// The step is loaded on demand, so it arrives a tick after render.
expect(
await screen.findByRole("button", { name: "Resolve invite step" }),
).toBeInTheDocument();
expect(screen.queryByText(CHECKPOINT_TITLE)).not.toBeInTheDocument();
// When
await user.click(
screen.getByRole("button", { name: "Resolve invite step" }),
);
// Then
expect(await screen.findByText(CHECKPOINT_TITLE)).toBeInTheDocument();
expect(window.localStorage.getItem(MARKER_KEY)).toBe("true");
});
it("is off unless a deployment opts in", () => {
// When
render(<OnboardingCheckpointWatcher />);
// Then
expect(screen.getByText(CHECKPOINT_TITLE)).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Resolve invite step" }),
).not.toBeInTheDocument();
});
it("does not offer the step again once this browser saw it", () => {
// Given
window.localStorage.setItem(MARKER_KEY, "true");
// When
render(<OnboardingCheckpointWatcher tenantId={TENANT_ID} />);
// Then
expect(screen.getByText(CHECKPOINT_TITLE)).toBeInTheDocument();
});
it("renders nothing for the step while the checkpoint is not requested", () => {
// Given
checkpointOpenState = false;
// When
render(<OnboardingCheckpointWatcher tenantId={TENANT_ID} />);
// Then
expect(
screen.queryByRole("button", { name: "Resolve invite step" }),
).not.toBeInTheDocument();
expect(screen.queryByText(CHECKPOINT_TITLE)).not.toBeInTheDocument();
});
it("offers the step again to another tenant of the same browser", async () => {
// Given — this browser already saw it for one tenant.
window.localStorage.setItem(MARKER_KEY, "true");
// When
render(<OnboardingCheckpointWatcher tenantId={OTHER_TENANT_ID} />);
// Then
expect(
await screen.findByRole("button", { name: "Resolve invite step" }),
).toBeInTheDocument();
});
it("does not offer the step without a usable tenant", () => {
// When
render(<OnboardingCheckpointWatcher tenantId={null} />);
// Then
expect(screen.getByText(CHECKPOINT_TITLE)).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Resolve invite step" }),
).not.toBeInTheDocument();
});
});
@@ -25,6 +25,11 @@ vi.mock("@/store/onboarding-sequence", () => ({
},
}));
// The invite step is exercised in its own test; keep this one on the dialog.
vi.mock("../onboarding-invite-step", () => ({
OnboardingInviteStep: () => null,
}));
vi.mock("@/store/onboarding-checkpoint", () => ({
CHECKPOINT_MARKER: "prowler.onboarding.checkpoint",
useOnboardingCheckpointStore: Object.assign(
@@ -0,0 +1,223 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
ONBOARDING_INVITE_STEP_EVENT,
type OnboardingInviteStepDetail,
} from "@/lib/onboarding/onboarding-events";
import { OnboardingInviteStep } from "../onboarding-invite-step";
const { getRolesMock, sendInviteMock, toastMock } = vi.hoisted(() => ({
getRolesMock: vi.fn(),
sendInviteMock: vi.fn(),
toastMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/actions/onboarding/invite", () => ({
getOnboardingInviteRoles: getRolesMock,
}));
vi.mock("@/actions/invitations/invitation", () => ({
sendInvite: sendInviteMock,
}));
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/shadcn")>()),
useToast: () => ({ toast: toastMock }),
}));
// Radix Select does not open in jsdom; a native select keeps the test on the
// form's behaviour rather than the dropdown's.
vi.mock("@/components/shadcn/select/select", () => ({
Select: ({
value,
onValueChange,
disabled,
children,
}: {
value?: string;
onValueChange: (value: string) => void;
disabled?: boolean;
children: React.ReactNode;
}) => (
<select
aria-label="Select a role"
value={value ?? ""}
disabled={disabled}
onChange={(event) => onValueChange(event.target.value)}
>
<option value="">Select a role</option>
{children}
</select>
),
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
SelectItem: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => <option value={value}>{children}</option>,
}));
const ROLES = [
{ id: "11111111-1111-4111-8111-111111111111", name: "member" },
{ id: "22222222-2222-4222-8222-222222222222", name: "admin" },
];
describe("OnboardingInviteStep", () => {
const outcomes: OnboardingInviteStepDetail[] = [];
const recordOutcome = (event: Event) => {
outcomes.push((event as CustomEvent<OnboardingInviteStepDetail>).detail);
};
beforeEach(() => {
outcomes.length = 0;
window.addEventListener(ONBOARDING_INVITE_STEP_EVENT, recordOutcome);
getRolesMock.mockReset().mockResolvedValue(ROLES);
sendInviteMock.mockReset().mockResolvedValue({ data: { id: "inv-1" } });
toastMock.mockReset();
});
afterEach(() => {
window.removeEventListener(ONBOARDING_INVITE_STEP_EVENT, recordOutcome);
});
it("announces the impression and renders the invitation form once roles load", async () => {
// When
render(<OnboardingInviteStep onDone={vi.fn()} />);
// Then
expect(outcomes).toEqual([{ outcome: "shown" }]);
expect(await screen.findByText("Invite your team")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /send invitation/i }),
).toBeInTheDocument();
// The admin role is listed first so it is the natural pick.
const options = screen.getAllByRole("option").map((o) => o.textContent);
expect(options).toEqual(["Select a role", "admin", "member"]);
});
it("sends the invitation tagged for onboarding and resolves the step", async () => {
// Given
const user = userEvent.setup();
const onDone = vi.fn();
render(<OnboardingInviteStep onDone={onDone} />);
await screen.findByText("Invite your team");
// When
await user.type(screen.getByLabelText(/email/i), "teammate@company.com");
await user.selectOptions(screen.getByRole("combobox"), ROLES[1].id);
await user.click(screen.getByRole("button", { name: /send invitation/i }));
// Then
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1));
const formData = sendInviteMock.mock.calls[0]?.[0] as FormData;
expect(formData.get("email")).toBe("teammate@company.com");
expect(formData.get("role")).toBe(ROLES[1].id);
expect(formData.get("source")).toBe("onboarding");
expect(outcomes).toEqual([{ outcome: "shown" }, { outcome: "submitted" }]);
});
it("keeps the step open when the API rejects the invitation", async () => {
// Given
const user = userEvent.setup();
const onDone = vi.fn();
sendInviteMock.mockResolvedValue({
errors: [
{
detail: "This email has already been invited.",
source: { pointer: "/data/attributes/email" },
},
],
});
render(<OnboardingInviteStep onDone={onDone} />);
await screen.findByText("Invite your team");
// When
await user.type(screen.getByLabelText(/email/i), "teammate@company.com");
await user.selectOptions(screen.getByRole("combobox"), ROLES[1].id);
await user.click(screen.getByRole("button", { name: /send invitation/i }));
// Then
expect(
await screen.findByText("This email has already been invited."),
).toBeInTheDocument();
expect(onDone).not.toHaveBeenCalled();
expect(outcomes).toEqual([{ outcome: "shown" }]);
});
it("records a skip and resolves the step", async () => {
// Given
const user = userEvent.setup();
const onDone = vi.fn();
render(<OnboardingInviteStep onDone={onDone} />);
await screen.findByText("Invite your team");
// When
await user.click(screen.getByRole("button", { name: "Skip for now" }));
// Then
expect(outcomes.at(-1)).toEqual({ outcome: "skipped" });
expect(onDone).toHaveBeenCalledTimes(1);
expect(sendInviteMock).not.toHaveBeenCalled();
});
it("still lets the user skip when roles cannot be loaded", async () => {
// Given
const user = userEvent.setup();
const onDone = vi.fn();
getRolesMock.mockRejectedValue(new Error("roles unavailable"));
render(<OnboardingInviteStep onDone={onDone} />);
await screen.findByText("Invite your team");
// Then
expect(screen.getByText(/Roles could not be loaded/)).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /send invitation/i }),
).not.toBeInTheDocument();
// When
await user.click(screen.getByRole("button", { name: "Skip for now" }));
// Then
expect(onDone).toHaveBeenCalledTimes(1);
});
it("falls back to the skip when the roles never arrive", async () => {
// Given — a request that never settles.
vi.useFakeTimers();
try {
getRolesMock.mockReturnValue(new Promise(() => {}));
render(<OnboardingInviteStep onDone={vi.fn()} />);
expect(screen.queryByText("Invite your team")).not.toBeInTheDocument();
// When — the step's own deadline passes.
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
// Then
expect(screen.getByText("Invite your team")).toBeInTheDocument();
expect(screen.getByText(/Roles could not be loaded/)).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /send invitation/i }),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Skip for now" }),
).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
});
@@ -1,8 +1,14 @@
"use client";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { getOrderedFlows } from "@/lib/onboarding";
import {
isOnboardingInviteHandled,
markOnboardingInviteHandled,
} from "@/lib/onboarding/invite-marker";
import {
CHECKPOINT_MARKER,
useOnboardingCheckpointStore,
@@ -11,6 +17,23 @@ import { useOnboardingSequenceStore } from "@/store/onboarding-sequence";
import { OnboardingCheckpointDialog } from "./onboarding-checkpoint-dialog";
// Loaded on demand: the step pulls the invitation form and its server
// actions, which this module (re-exported by the shared barrel) must not
// carry statically.
const OnboardingInviteStep = dynamic(
() =>
import("./onboarding-invite-step").then(
(module) => module.OnboardingInviteStep,
),
{ ssr: false },
);
interface OnboardingCheckpointWatcherProps {
// Scopes the invite step's local marker: the offer is per tenant, not per
// browser. Without it the step is not offered.
tenantId?: string | null;
}
// Sequence begins at the flow after `add-provider` (the gate).
const FIRST_FLOW_ID = "add-provider";
@@ -24,9 +47,14 @@ function markCheckpointHandled(): void {
}
// Layout-level watcher: renders the checkpoint dialog when the store `open` flag is set.
export function OnboardingCheckpointWatcher() {
export function OnboardingCheckpointWatcher({
tenantId = null,
}: OnboardingCheckpointWatcherProps = {}) {
const router = useRouter();
const open = useOnboardingCheckpointStore((state) => state.open);
// Session flag: the marker is written on resolve, but a state change is
// what re-renders this component into the checkpoint dialog.
const [inviteResolved, setInviteResolved] = useState(false);
const handleContinue = () => {
markCheckpointHandled(); // before navigation to prevent re-open on re-render
@@ -48,6 +76,19 @@ export function OnboardingCheckpointWatcher() {
useOnboardingCheckpointStore.getState().close();
};
// "Invite your team" goes first, once per tenant, and leaves the store
// open, so the checkpoint dialog follows unchanged once it resolves.
if (open && !inviteResolved && !isOnboardingInviteHandled(tenantId)) {
return (
<OnboardingInviteStep
onDone={() => {
markOnboardingInviteHandled(tenantId);
setInviteResolved(true);
}}
/>
);
}
return (
<OnboardingCheckpointDialog
open={open}
@@ -0,0 +1,78 @@
"use client";
import { SendInvitationForm } from "@/components/invitations/workflow/forms/send-invitation-form";
import { Button } from "@/components/shadcn";
import { DialogFooter } from "@/components/shadcn/dialog";
import { Modal } from "@/components/shadcn/modal/modal";
import {
INVITATION_SOURCE,
type InvitationRoleOption,
} from "@/types/onboarding-invite";
interface OnboardingInviteDialogProps {
open: boolean;
roles: InvitationRoleOption[];
onSent: (invitationId: string) => void;
onSkip: () => void;
}
const DEFAULT_ROLE_NAME = "admin";
// Roles are listed with the admin one first so it is the natural pick for a
// first teammate; the form itself keeps the selection required.
const orderRoles = (roles: InvitationRoleOption[]) =>
[...roles].sort((a, b) =>
a.name.toLowerCase() === DEFAULT_ROLE_NAME
? -1
: b.name.toLowerCase() === DEFAULT_ROLE_NAME
? 1
: 0,
);
// "Invite your team", offered once right after the first provider is
// connected: permissions on the cloud were just granted and the value of
// sharing the first scan is fresh. Reuses the members-page form, tagged as an
// onboarding invitation.
export function OnboardingInviteDialog({
open,
roles,
onSent,
onSkip,
}: OnboardingInviteDialogProps) {
const hasRoles = roles.length > 0;
return (
<Modal
open={open}
title="Invite your team"
description="Security is a team effort. Invite a teammate now so they see the first scan with you. You can skip this."
size="lg"
// Overlay/Escape/X counts as a skip — the caller persists the marker once.
onOpenChange={(next) => {
if (!next) onSkip();
}}
>
<div className="flex flex-col gap-4">
{hasRoles ? (
<SendInvitationForm
roles={orderRoles(roles)}
isSelectorDisabled={false}
source={INVITATION_SOURCE.ONBOARDING}
onSuccess={onSent}
/>
) : (
<p className="text-text-neutral-secondary text-sm">
Roles could not be loaded right now. You can invite your team later
from the Invitations page.
</p>
)}
<DialogFooter>
{/* Outline matches the app's modal secondary action (e.g. Launch Scan's Cancel). */}
<Button type="button" variant="outline" onClick={onSkip}>
Skip for now
</Button>
</DialogFooter>
</div>
</Modal>
);
}
@@ -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<InvitationRoleOption[] | null>(null);
useMountEffect(() => {
dispatchOnboardingInviteStep({ outcome: ONBOARDING_STEP_OUTCOME.SHOWN });
let active = true;
let timer: ReturnType<typeof setTimeout> | 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 (
<OnboardingInviteDialog
open
roles={roles}
onSent={() => {
dispatchOnboardingInviteStep({
outcome: ONBOARDING_STEP_OUTCOME.SUBMITTED,
});
onDone();
}}
onSkip={() => {
dispatchOnboardingInviteStep({
outcome: ONBOARDING_STEP_OUTCOME.SKIPPED,
});
onDone();
}}
/>
);
}
+16
View File
@@ -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.<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`).
+48
View File
@@ -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.
}
}
+11
View File
@@ -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;
}