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
@@ -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();
}}
/>
);
}