From 396ccf56bbb58653239bf77160c592a0a35a0f34 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:58:53 +0200 Subject: [PATCH] feat(ui): offer an invite-your-team step before the onboarding checkpoint (#12819) Co-authored-by: Claude Opus 5 --- ui/actions/invitations/invitation.test.ts | 72 ++++++ ui/actions/invitations/invitation.ts | 5 + ui/actions/onboarding/invite.ts | 21 ++ ui/app/(prowler)/layout.tsx | 14 +- .../onboarding-invite-step.added.md | 1 + .../forms/send-invitation-form.test.tsx | 112 +++++++++ .../workflow/forms/send-invitation-form.tsx | 41 +++- ...oarding-checkpoint-watcher-invite.test.tsx | 132 +++++++++++ .../onboarding-checkpoint-watcher.test.tsx | 5 + .../__tests__/onboarding-invite-step.test.tsx | 223 ++++++++++++++++++ .../onboarding-checkpoint-watcher.tsx | 43 +++- .../onboarding/onboarding-invite-dialog.tsx | 78 ++++++ .../onboarding/onboarding-invite-step.tsx | 73 ++++++ ui/lib/onboarding/README.md | 16 ++ ui/lib/onboarding/invite-marker.ts | 48 ++++ ui/types/onboarding-invite.ts | 11 + 16 files changed, 879 insertions(+), 16 deletions(-) create mode 100644 ui/actions/invitations/invitation.test.ts create mode 100644 ui/actions/onboarding/invite.ts create mode 100644 ui/changelog.d/onboarding-invite-step.added.md create mode 100644 ui/components/invitations/workflow/forms/send-invitation-form.test.tsx create mode 100644 ui/components/onboarding/__tests__/onboarding-checkpoint-watcher-invite.test.tsx create mode 100644 ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx create mode 100644 ui/components/onboarding/onboarding-invite-dialog.tsx create mode 100644 ui/components/onboarding/onboarding-invite-step.tsx create mode 100644 ui/lib/onboarding/invite-marker.ts create mode 100644 ui/types/onboarding-invite.ts diff --git a/ui/actions/invitations/invitation.test.ts b/ui/actions/invitations/invitation.test.ts new file mode 100644 index 0000000000..4f9635a590 --- /dev/null +++ b/ui/actions/invitations/invitation.test.ts @@ -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" }); + }); +}); diff --git a/ui/actions/invitations/invitation.ts b/ui/actions/invitations/invitation.ts index 72f591705a..620a6e1a1b 100644 --- a/ui/actions/invitations/invitation.ts +++ b/ui/actions/invitations/invitation.ts @@ -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: { diff --git a/ui/actions/onboarding/invite.ts b/ui/actions/onboarding/invite.ts new file mode 100644 index 0000000000..d7e9221056 --- /dev/null +++ b/ui/actions/onboarding/invite.ts @@ -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 }] + : [], + ); +}; diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx index 623cc416f6..bd58df359b 100644 --- a/ui/app/(prowler)/layout.tsx +++ b/ui/app/(prowler)/layout.tsx @@ -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. */} - + {/* Persistent banner shown only while a guided sequence is active. */} diff --git a/ui/changelog.d/onboarding-invite-step.added.md b/ui/changelog.d/onboarding-invite-step.added.md new file mode 100644 index 0000000000..c2220888b5 --- /dev/null +++ b/ui/changelog.d/onboarding-invite-step.added.md @@ -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` diff --git a/ui/components/invitations/workflow/forms/send-invitation-form.test.tsx b/ui/components/invitations/workflow/forms/send-invitation-form.test.tsx new file mode 100644 index 0000000000..a653a31692 --- /dev/null +++ b/ui/components/invitations/workflow/forms/send-invitation-form.test.tsx @@ -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; + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + SelectItem: ({ + value, + children, + }: { + value: string; + children: React.ReactNode; + }) => , +})); + +const ROLES = [{ id: "22222222-2222-4222-8222-222222222222", name: "admin" }]; + +const fillAndSubmit = async (user: ReturnType) => { + 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(); + + // 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( + , + ); + + // 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"); + }); +}); diff --git a/ui/components/invitations/workflow/forms/send-invitation-form.tsx b/ui/components/invitations/workflow/forms/send-invitation-form.tsx index ce98cfdc97..cbbbdac225 100644 --- a/ui/components/invitations/workflow/forms/send-invitation-form.tsx +++ b/ui/components/invitations/workflow/forms/send-invitation-form.tsx @@ -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; +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) { diff --git a/ui/components/onboarding/__tests__/onboarding-checkpoint-watcher-invite.test.tsx b/ui/components/onboarding/__tests__/onboarding-checkpoint-watcher-invite.test.tsx new file mode 100644 index 0000000000..82893beab4 --- /dev/null +++ b/ui/components/onboarding/__tests__/onboarding-checkpoint-watcher-invite.test.tsx @@ -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 }) => ( + + ), +})); + +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(); + // 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(); + + // 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(); + + // Then + expect(screen.getByText(CHECKPOINT_TITLE)).toBeInTheDocument(); + }); + + it("renders nothing for the step while the checkpoint is not requested", () => { + // Given + checkpointOpenState = false; + + // When + render(); + + // 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(); + + // Then + expect( + await screen.findByRole("button", { name: "Resolve invite step" }), + ).toBeInTheDocument(); + }); + + it("does not offer the step without a usable tenant", () => { + // When + render(); + + // Then + expect(screen.getByText(CHECKPOINT_TITLE)).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Resolve invite step" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/onboarding/__tests__/onboarding-checkpoint-watcher.test.tsx b/ui/components/onboarding/__tests__/onboarding-checkpoint-watcher.test.tsx index f30fca862d..98e80744ab 100644 --- a/ui/components/onboarding/__tests__/onboarding-checkpoint-watcher.test.tsx +++ b/ui/components/onboarding/__tests__/onboarding-checkpoint-watcher.test.tsx @@ -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( diff --git a/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx b/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx new file mode 100644 index 0000000000..403bee8e63 --- /dev/null +++ b/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx @@ -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()), + 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; + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + SelectItem: ({ + value, + children, + }: { + value: string; + children: React.ReactNode; + }) => , +})); + +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).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(); + + // 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + } + }); +}); diff --git a/ui/components/onboarding/onboarding-checkpoint-watcher.tsx b/ui/components/onboarding/onboarding-checkpoint-watcher.tsx index 1339b17f8c..6612b23993 100644 --- a/ui/components/onboarding/onboarding-checkpoint-watcher.tsx +++ b/ui/components/onboarding/onboarding-checkpoint-watcher.tsx @@ -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 ( + { + markOnboardingInviteHandled(tenantId); + setInviteResolved(true); + }} + /> + ); + } + return ( 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 ( + { + if (!next) onSkip(); + }} + > +
+ {hasRoles ? ( + + ) : ( +

+ Roles could not be loaded right now. You can invite your team later + from the Invitations page. +

+ )} + + {/* Outline matches the app's modal secondary action (e.g. Launch Scan's Cancel). */} + + +
+
+ ); +} 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; +}