mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
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:
co-authored by
Claude Opus 5
parent
3069486549
commit
396ccf56bb
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }]
|
||||
: [],
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user