diff --git a/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx index db0916e4a4..f42a4db8f8 100644 --- a/docs/user-guide/tutorials/prowler-app.mdx +++ b/docs/user-guide/tutorials/prowler-app.mdx @@ -93,6 +93,10 @@ After adding your cloud account credentials, click the `Check connection` button For a single AWS account, Prowler tests the connection as part of the `Connect account` step, so the wizard moves straight to launching the scan. + +To delegate the AWS connection, select `I don't have access, invite a teammate` on the same step when you cannot create the IAM role or do not have the account credentials. Prowler App sends the invitation to the tenant and shows the link to share. Prowler Cloud also emails it. This option is available to users who can manage the account. + + ## Step 6: Scan Started After the connection check succeeds, save the provider and start your first scan with the `Launch Scan` button. The `Scans` section shows the scan in progress: diff --git a/ui/actions/invitations/invitation.adapter.test.ts b/ui/actions/invitations/invitation.adapter.test.ts new file mode 100644 index 0000000000..f0edc02918 --- /dev/null +++ b/ui/actions/invitations/invitation.adapter.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { toSentInvitation } from "./invitation.adapter"; + +const created = { + data: { + id: "inv-1", + type: "invitations", + attributes: { + email: "teammate@company.com", + token: "abc123DEF45678", + state: "pending", + expires_at: "2026-10-07T10:00:00Z", + }, + }, +}; + +describe("toSentInvitation", () => { + it("reads the id, email and token of a created invitation", () => { + expect(toSentInvitation(created)).toEqual({ + id: "inv-1", + email: "teammate@company.com", + token: "abc123DEF45678", + }); + }); + + it("returns null when the action resolved without a value", () => { + // A 5xx makes `sendInvite` resolve undefined. + expect(toSentInvitation(undefined)).toBeNull(); + }); + + it("returns null on a rejection, with or without an errors array", () => { + expect( + toSentInvitation({ errors: [{ detail: "Invalid email" }] }), + ).toBeNull(); + expect(toSentInvitation({ error: "Something went wrong" })).toBeNull(); + }); + + it("returns null when the record is missing any of the fields the link needs", () => { + expect( + toSentInvitation({ + data: { id: "inv-1", attributes: { email: "a@b.com" } }, + }), + ).toBeNull(); + expect( + toSentInvitation({ + data: { id: "inv-1", attributes: { token: "abc123DEF45678" } }, + }), + ).toBeNull(); + }); +}); diff --git a/ui/actions/invitations/invitation.adapter.ts b/ui/actions/invitations/invitation.adapter.ts new file mode 100644 index 0000000000..41fccefbb8 --- /dev/null +++ b/ui/actions/invitations/invitation.adapter.ts @@ -0,0 +1,25 @@ +import type { SentInvitation } from "@/types/onboarding-invite"; + +const readString = (value: unknown): string | null => + typeof value === "string" && value.length > 0 ? value : null; + +/** + * The created record out of `sendInvite`'s JSON:API response. Null for every + * failure shape: `undefined` (a 5xx makes the action resolve without a value), + * `{ errors }`, a bare `{ error }`, or a record missing what the link needs. + */ +export function toSentInvitation(response: unknown): SentInvitation | null { + if (!response || typeof response !== "object") return null; + const { data } = response as { data?: unknown }; + if (!data || typeof data !== "object") return null; + const { id, attributes } = data as { id?: unknown; attributes?: unknown }; + const fields = + attributes && typeof attributes === "object" + ? (attributes as Record) + : {}; + const invitationId = readString(id); + const email = readString(fields.email); + const token = readString(fields.token); + if (!invitationId || !email || !token) return null; + return { id: invitationId, email, token }; +} diff --git a/ui/actions/onboarding/invite.ts b/ui/actions/invitations/roles.ts similarity index 67% rename from ui/actions/onboarding/invite.ts rename to ui/actions/invitations/roles.ts index d7e9221056..d90695ef91 100644 --- a/ui/actions/onboarding/invite.ts +++ b/ui/actions/invitations/roles.ts @@ -5,11 +5,9 @@ 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[] -> => { +// Roles an invitation can grant; empty when the read fails so a caller can +// fall back (skip, disable) rather than block. +export const getInvitationRoles = async (): Promise => { const rolesData = await getRoles({ pageSize: ROLES_PAGE_SIZE }); const roles: unknown = rolesData?.data; if (!Array.isArray(roles)) return []; diff --git a/ui/changelog.d/aws-invite-teammate.added.md b/ui/changelog.d/aws-invite-teammate.added.md new file mode 100644 index 0000000000..150bec96c1 --- /dev/null +++ b/ui/changelog.d/aws-invite-teammate.added.md @@ -0,0 +1 @@ +Option to invite a teammate from the AWS connect step when the user cannot access the account credentials diff --git a/ui/components/invitations/invitation-details.tsx b/ui/components/invitations/invitation-details.tsx index 259a1de0a4..55f1878140 100644 --- a/ui/components/invitations/invitation-details.tsx +++ b/ui/components/invitations/invitation-details.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet"; import { DateWithTime } from "@/components/shadcn/entities"; +import { buildInvitationAcceptLink } from "@/lib/invitations/accept-link"; import { AddIcon } from "../icons"; import { Button, Card, CardContent, CardHeader } from "../shadcn"; @@ -54,7 +55,7 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => { ? window.location.origin : "http://localhost:3000"; - const invitationLink = `${baseUrl}/invitation/accept?invitation_token=${attributes.token}`; + const invitationLink = buildInvitationAcceptLink(attributes.token, baseUrl); return (
diff --git a/ui/components/invitations/workflow/forms/send-invitation-form.test.tsx b/ui/components/invitations/workflow/forms/send-invitation-form.test.tsx index a653a31692..1bb81d44b8 100644 --- a/ui/components/invitations/workflow/forms/send-invitation-form.test.tsx +++ b/ui/components/invitations/workflow/forms/send-invitation-form.test.tsx @@ -66,7 +66,13 @@ const fillAndSubmit = async (user: ReturnType) => { describe("SendInvitationForm", () => { beforeEach(() => { pushMock.mockReset(); - sendInviteMock.mockReset().mockResolvedValue({ data: { id: "inv-1" } }); + // The API answers with the created record, token included. + sendInviteMock.mockReset().mockResolvedValue({ + data: { + id: "inv-1", + attributes: { email: "teammate@company.com", token: "abc123DEF45678" }, + }, + }); }); it("navigates to the invitation details by default", async () => { diff --git a/ui/components/invitations/workflow/forms/send-invitation-form.tsx b/ui/components/invitations/workflow/forms/send-invitation-form.tsx index cbbbdac225..8401f3c5d1 100644 --- a/ui/components/invitations/workflow/forms/send-invitation-form.tsx +++ b/ui/components/invitations/workflow/forms/send-invitation-form.tsx @@ -1,13 +1,10 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; import { SaveIcon } from "lucide-react"; import { useRouter } from "next/navigation"; -import { Controller, useForm } from "react-hook-form"; -import * as z from "zod"; +import { Controller } from "react-hook-form"; -import { sendInvite } from "@/actions/invitations/invitation"; -import { Button, useToast } from "@/components/shadcn"; +import { Button } from "@/components/shadcn"; import { CustomInput } from "@/components/shadcn/custom"; import { Form } from "@/components/shadcn/form"; import { @@ -17,15 +14,9 @@ import { SelectTrigger, 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" }), - roleId: z.string().min(1, "Role is required"), -}); - -export type FormValues = z.infer; +import { useSendInvitation } from "./use-send-invitation"; interface SendInvitationFormProps { roles: InvitationRoleOption[]; @@ -45,90 +36,23 @@ export const SendInvitationForm = ({ source, onSuccess, }: SendInvitationFormProps) => { - const { toast } = useToast(); const router = useRouter(); - const form = useForm({ - resolver: zodResolver(sendInvitationFormSchema), - defaultValues: { - email: "", - roleId: isSelectorDisabled ? defaultRole : "", + const { form, onSubmit, isSubmitting } = useSendInvitation({ + source, + defaultRoleId: isSelectorDisabled ? defaultRole : "", + onSuccess: (invitation) => { + if (onSuccess) { + onSuccess(invitation.id); + return; + } + router.push(`/invitations/check-details/?id=${invitation.id}`); }, }); - const isLoading = form.formState.isSubmitting; - - const onSubmitClient = async (values: FormValues) => { - 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); - - if (data?.errors && data.errors.length > 0) { - data.errors.forEach((error: ApiError) => { - const errorMessage = error.detail; - const pointer = error.source?.pointer; - switch (pointer) { - case "/data/attributes/email": - form.setError("email", { - type: "server", - message: errorMessage, - }); - break; - case "/data/relationships/roles": - form.setError("roleId", { - type: "server", - message: errorMessage, - }); - break; - default: - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: errorMessage, - }); - } - }); - } else { - 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) { - toast({ - variant: "destructive", - title: "Error", - description: "An unexpected error occurred. Please try again.", - }); - } - }; - return (
- + {/* Email Field */} - {isLoading ? ( + {isSubmitting ? ( <>Loading ) : ( <> diff --git a/ui/components/invitations/workflow/forms/use-send-invitation.ts b/ui/components/invitations/workflow/forms/use-send-invitation.ts new file mode 100644 index 0000000000..3abd9003fc --- /dev/null +++ b/ui/components/invitations/workflow/forms/use-send-invitation.ts @@ -0,0 +1,106 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm, useFormState, type UseFormProps } from "react-hook-form"; +import * as z from "zod"; + +import { sendInvite } from "@/actions/invitations/invitation"; +import { toSentInvitation } from "@/actions/invitations/invitation.adapter"; +import { useToast } from "@/components/shadcn"; +import { ApiError } from "@/types"; +import type { SentInvitation } from "@/types/onboarding-invite"; + +export const sendInvitationFormSchema = z.object({ + email: z.email({ error: "Please enter a valid email" }), + roleId: z.string().min(1, "Role is required"), +}); + +export type SendInvitationFormValues = z.infer; + +const EMAIL_ERROR_POINTER = "/data/attributes/email"; +const ROLES_ERROR_POINTER = "/data/relationships/roles"; + +interface UseSendInvitationOptions { + // Where the invitation is sent from, forwarded to the API as `?source=` + // so the origin can be told apart (e.g. the onboarding invite step). + source?: string; + defaultRoleId?: string; + // `onChange` lets a caller gate its submit button on `isValid`. + mode?: UseFormProps["mode"]; + onSuccess: (invitation: SentInvitation) => void; +} + +/** Owns an invitation form: schema, submit, API error mapping and toasts. */ +export function useSendInvitation({ + source, + defaultRoleId = "", + mode = "onSubmit", + onSuccess, +}: UseSendInvitationOptions) { + const { toast } = useToast(); + const form = useForm({ + resolver: zodResolver(sendInvitationFormSchema), + mode, + defaultValues: { email: "", roleId: defaultRoleId }, + }); + // A hook, not `form.formState` read inline: the React Compiler keys its memo + // on the stable `form` object and would freeze a proxy read. + const { isSubmitting, isValid } = useFormState({ control: form.control }); + + const onSubmit = form.handleSubmit(async (values) => { + 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); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const message = error.detail; + switch (error.source?.pointer) { + case EMAIL_ERROR_POINTER: + form.setError("email", { type: "server", message }); + break; + case ROLES_ERROR_POINTER: + form.setError("roleId", { type: "server", message }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: message, + }); + } + }); + return; + } + + const invitation = toSentInvitation(data); + if (!invitation) { + // 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; + } + onSuccess(invitation); + } catch { + toast({ + variant: "destructive", + title: "Error", + description: "An unexpected error occurred. Please try again.", + }); + } + }); + + return { form, onSubmit, isSubmitting, isValid }; +} diff --git a/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx b/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx index 403bee8e63..aecb35fee2 100644 --- a/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx +++ b/ui/components/onboarding/__tests__/onboarding-invite-step.test.tsx @@ -19,8 +19,8 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), })); -vi.mock("@/actions/onboarding/invite", () => ({ - getOnboardingInviteRoles: getRolesMock, +vi.mock("@/actions/invitations/roles", () => ({ + getInvitationRoles: getRolesMock, })); vi.mock("@/actions/invitations/invitation", () => ({ @@ -85,7 +85,13 @@ describe("OnboardingInviteStep", () => { outcomes.length = 0; window.addEventListener(ONBOARDING_INVITE_STEP_EVENT, recordOutcome); getRolesMock.mockReset().mockResolvedValue(ROLES); - sendInviteMock.mockReset().mockResolvedValue({ data: { id: "inv-1" } }); + // The API answers with the created record, token included. + sendInviteMock.mockReset().mockResolvedValue({ + data: { + id: "inv-1", + attributes: { email: "teammate@company.com", token: "abc123DEF45678" }, + }, + }); toastMock.mockReset(); }); diff --git a/ui/components/onboarding/onboarding-invite-dialog.tsx b/ui/components/onboarding/onboarding-invite-dialog.tsx index 7431d7c829..f8ac0f5771 100644 --- a/ui/components/onboarding/onboarding-invite-dialog.tsx +++ b/ui/components/onboarding/onboarding-invite-dialog.tsx @@ -4,6 +4,7 @@ import { SendInvitationForm } from "@/components/invitations/workflow/forms/send import { Button } from "@/components/shadcn"; import { DialogFooter } from "@/components/shadcn/dialog"; import { Modal } from "@/components/shadcn/modal/modal"; +import { orderRolesAdminFirst } from "@/lib/invitations/order-roles"; import { INVITATION_SOURCE, type InvitationRoleOption, @@ -16,19 +17,6 @@ interface OnboardingInviteDialogProps { 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 @@ -55,7 +43,9 @@ export function OnboardingInviteDialog({
{hasRoles ? ( 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); + // 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. + const roles = useInvitationRoles(); 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; diff --git a/ui/components/providers/wizard/provider-wizard-modal.test.tsx b/ui/components/providers/wizard/provider-wizard-modal.test.tsx index 5a1115dbd9..450c712b77 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.test.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.test.tsx @@ -36,12 +36,23 @@ const { vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }), })); -vi.mock("next-auth/react", () => ({ - useSession: () => ({ - data: { tenantId: "tenant-abc" }, - status: "authenticated", - }), +const { getInvitationRoles, sendInvite, session } = vi.hoisted(() => ({ + getInvitationRoles: vi.fn(), + sendInvite: vi.fn(), + // Mutable: only the permissions differ between cases. + session: { + data: { tenantId: "tenant-abc" } as { + tenantId: string; + user?: { permissions: Record }; + }, + }, })); + +vi.mock("next-auth/react", () => ({ + useSession: () => ({ data: session.data, status: "authenticated" }), +})); +vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles })); +vi.mock("@/actions/invitations/invitation", () => ({ sendInvite })); vi.mock("@/actions/providers/providers", () => ({ addCredentialsProvider, addProvider, @@ -165,6 +176,7 @@ describe("provider wizard account creation", () => { afterEach(() => { vi.unstubAllEnvs(); + session.data = { tenantId: "tenant-abc" }; }); it("shows progress, blocks repeat clicks, and advances after creation", async () => { @@ -459,6 +471,61 @@ describe("provider wizard account creation", () => { expect(endActiveTour).toHaveBeenCalled(); }); + it("closes the wizard once a teammate has been invited to connect the account instead", async () => { + // Given: a user who can invite but cannot reach the account. + session.data = { + tenantId: "tenant-abc", + user: { permissions: { manage_account: true } }, + }; + getInvitationRoles.mockResolvedValue([ + { id: "22222222-2222-4222-8222-222222222222", name: "admin" }, + ]); + sendInvite.mockResolvedValue({ + data: { + id: "inv-1", + attributes: { + email: "teammate@company.com", + token: "abc123DEF45678", + }, + }, + }); + const funnelSignals: ProviderFunnelDetail[] = []; + const recordFunnelSignal: EventListener = (event) => { + funnelSignals.push((event as CustomEvent).detail); + }; + window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal); + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + render(); + await screen.findByRole("option", { name: "Acme Cloud Registry" }); + await user.click( + screen.getByRole("option", { name: /Amazon Web Services/ }), + ); + + // When + await user.click( + await screen.findByRole("radio", { name: /invite a teammate/i }), + ); + await user.type( + await screen.findByRole("textbox", { name: /Teammate email/ }), + "teammate@company.com", + ); + const send = screen.getByRole("button", { name: "Send invitation" }); + await waitFor(() => expect(send).toBeEnabled()); + await user.click(send); + await user.click(await screen.findByRole("button", { name: "Done" })); + window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal); + + // Then: no account was created, so the wizard closes instead of launching. + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(screen.queryByText("Launch scan")).not.toBeInTheDocument(); + expect(funnelSignals.at(-1)).toEqual({ + step: "wizard_closed", + lastStep: "connect", + providerCreated: false, + }); + }); + it("goes back to the provider list", async () => { // Given const user = await pickAws(); diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index f5bf93f789..45e9b9ca8d 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -185,6 +185,7 @@ export function ProviderWizardModal({ handleTestSuccess(); endActiveTour(); }} + onClose={handleClose} onSelectOrganizations={openOrganizationsFlow} onFooterChange={setFooterConfig} onProviderTypeChange={(providerType) => { diff --git a/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx b/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx index 73cf42fea8..0d1aa2df61 100644 --- a/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx +++ b/ui/components/providers/wizard/steps/aws/aws-connect-step.test.tsx @@ -23,6 +23,10 @@ const { updateCredentialsProvider, testProviderConnection, openCloudUpgradeMock, + endActiveTour, + getInvitationRoles, + sendInvite, + session, } = vi.hoisted(() => ({ addProvider: vi.fn(), addCredentialsProvider: vi.fn(), @@ -30,14 +34,24 @@ const { updateCredentialsProvider: vi.fn(), testProviderConnection: vi.fn(), openCloudUpgradeMock: vi.fn(), + endActiveTour: vi.fn(), + getInvitationRoles: vi.fn(), + sendInvite: vi.fn(), + // Mutable: only the permissions differ between suites. + session: { + data: { tenantId: "tenant-abc" } as { + tenantId: string; + user?: { permissions: Record }; + }, + }, })); vi.mock("next-auth/react", () => ({ - useSession: () => ({ - data: { tenantId: "tenant-abc" }, - status: "authenticated", - }), + useSession: () => ({ data: session.data, status: "authenticated" }), })); +vi.mock("@/lib/tours/use-driver-tour", () => ({ endActiveTour })); +vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles })); +vi.mock("@/actions/invitations/invitation", () => ({ sendInvite })); vi.mock("@/actions/providers/providers", () => ({ addProvider, addCredentialsProvider, @@ -130,6 +144,7 @@ describe("AwsConnectStep", () => { afterEach(() => { window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal); vi.unstubAllEnvs(); + session.data = { tenantId: "tenant-abc" }; }); describe("in Prowler Cloud", () => { @@ -754,4 +769,99 @@ describe("AwsConnectStep", () => { expect(secret).not.toHaveProperty("aws_access_key_id"); }); }); + + describe("inviting a teammate who can connect the account", () => { + const inviteRadio = () => + screen.queryByRole("radio", { name: /invite a teammate/i }); + + beforeEach(() => { + session.data = { + tenantId: "tenant-abc", + user: { permissions: { manage_account: true } }, + }; + getInvitationRoles.mockResolvedValue([ + { id: "22222222-2222-4222-8222-222222222222", name: "admin" }, + ]); + }); + + it("is not offered to a user who cannot invite", () => { + // Given: no `manage_account`, so the API would refuse the invitation. + session.data = { tenantId: "tenant-abc" }; + + // When + renderStep(); + + // Then + expect(inviteRadio()).not.toBeInTheDocument(); + expect(screen.getByRole("radio", { name: /IAM Role/ })).toBeChecked(); + }); + + it("swaps the AWS form for the invitation and signals the choice once", async () => { + // Given + const { user } = renderStep(); + + // When + await user.click(inviteRadio()!); + await user.click(inviteRadio()!); + + // Then: the teammate form takes the step and the footer, the tour steps aside. + expect(inviteRadio()).toBeChecked(); + expect( + screen.queryByRole("textbox", { name: /Role ARN/ }), + ).not.toBeInTheDocument(); + expect( + await screen.findByRole("textbox", { name: /Teammate email/ }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Send invitation" }), + ).toBeInTheDocument(); + expect(endActiveTour).toHaveBeenCalled(); + expect( + funnelSignals.filter((signal) => signal.step === "method_selected"), + ).toEqual([ + { + step: "method_selected", + providerType: "aws", + method: "invite_teammate", + }, + ]); + }); + + it("comes back to the IAM Role form with what was typed", async () => { + // Given + const { user } = renderStep(); + await user.type( + screen.getByRole("textbox", { name: /Role ARN/ }), + ROLE_ARN, + ); + await user.click(inviteRadio()!); + await screen.findByRole("textbox", { name: /Teammate email/ }); + + // When + await user.click(screen.getByRole("radio", { name: /IAM Role/ })); + + // Then + expect(screen.getByRole("textbox", { name: /Role ARN/ })).toHaveValue( + ROLE_ARN, + ); + expect(inviteRadio()).not.toBeChecked(); + }); + + it("restores the invitation panel when the step is reopened", async () => { + // Given: the user left for the organizations tab and came back. + const { user, unmount } = renderStep(); + await user.click(inviteRadio()!); + await screen.findByRole("textbox", { name: /Teammate email/ }); + unmount(); + + // When + renderStep(); + + // Then + expect(inviteRadio()).toBeChecked(); + expect( + await screen.findByRole("textbox", { name: /Teammate email/ }), + ).toBeInTheDocument(); + }); + }); }); diff --git a/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx b/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx index 9cca93a5dd..3456717d38 100644 --- a/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx +++ b/ui/components/providers/wizard/steps/aws/aws-connect-step.tsx @@ -7,6 +7,7 @@ import { KeyRound, Loader2, ShieldCheck, + UserPlus, } from "lucide-react"; import { useSession } from "next-auth/react"; import { useEffect, useRef, useState } from "react"; @@ -35,6 +36,7 @@ import { CollapsibleTrigger, } from "@/components/shadcn/collapsible"; import { Form } from "@/components/shadcn/form"; +import { useAuth } from "@/hooks/use-auth"; import { useFormServerErrors } from "@/hooks/use-form-server-errors"; import { useMountEffect } from "@/hooks/use-mount-effect"; import { PROVIDER_CREDENTIALS_ERROR_MAPPING } from "@/lib/error-mappings"; @@ -43,14 +45,22 @@ import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-cr import { ACCOUNT_SUBMIT_OUTCOME, dispatchProviderFunnel, + PROVIDER_FUNNEL_METHOD, PROVIDER_FUNNEL_STEP, } from "@/lib/provider-funnel/provider-funnel-events"; import { testProviderConnection } from "@/lib/provider-helpers"; +import { endActiveTour } from "@/lib/tours/use-driver-tour"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; import type { AWSCredentials, AWSCredentialsRole } from "@/types"; -import type { AwsConnectDraft } from "@/types/provider-wizard"; +import { + AWS_CONNECT_PANEL, + type AwsConnectDraft, + type AwsConnectPanel, +} from "@/types/provider-wizard"; import { CONNECTION_CHECK_STATUS } from "@/types/providers"; +import { InviteTeammatePanel } from "../invite-teammate/invite-teammate-panel"; + import { awsKeysConnectSchema, type AwsKeysConnectValues, @@ -84,6 +94,11 @@ const initialMethod = (): AwsAccessMethod => ? AWS_ACCESS_METHOD.CREDENTIALS : AWS_ACCESS_METHOD.ROLE; +const initialPanel = (): AwsConnectPanel => + readDraft()?.panel === AWS_CONNECT_PANEL.INVITE + ? AWS_CONNECT_PANEL.INVITE + : AWS_CONNECT_PANEL.ACCESS; + function useDraftValues( form: UseFormReturn, key: keyof Pick, @@ -112,14 +127,41 @@ export function AwsConnectStep({ }: AwsConnectStepProps) { // Local state needed: the access method only matters until the account is connected. const [method, setMethod] = useState(initialMethod); + // Local state needed: whether the step shows the access forms or hands the + // account over to a teammate. Separate from the method, which is the `via` + // an account gets connected with. + const [panel, setPanel] = useState(initialPanel); // Local state needed: the active form reports it so the method cannot change mid-submit. const [isBusy, setIsBusy] = useState(false); + const { permissions } = useAuth(); + // Inviting takes `manage_account`, which the API also asks of the roles list. + const canInvite = permissions.manage_account === true; - const isRole = method === AWS_ACCESS_METHOD.ROLE; + const isInvite = canInvite && panel === AWS_CONNECT_PANEL.INVITE; + const isRole = !isInvite && method === AWS_ACCESS_METHOD.ROLE; + const isKeys = !isInvite && method === AWS_ACCESS_METHOD.CREDENTIALS; const chooseMethod = (next: AwsAccessMethod) => { + setPanel(AWS_CONNECT_PANEL.ACCESS); setMethod(next); - useProviderWizardStore.getState().setAwsConnectDraft({ method: next }); + useProviderWizardStore + .getState() + .setAwsConnectDraft({ method: next, panel: AWS_CONNECT_PANEL.ACCESS }); + }; + + const chooseInvite = () => { + if (isInvite) return; + setPanel(AWS_CONNECT_PANEL.INVITE); + useProviderWizardStore + .getState() + .setAwsConnectDraft({ panel: AWS_CONNECT_PANEL.INVITE }); + // Delegating diverges from the path the tour guides toward. No-op off-onboarding. + endActiveTour(); + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.METHOD_SELECTED, + providerType: "aws", + method: PROVIDER_FUNNEL_METHOD.INVITE_TEAMMATE, + }); }; return ( @@ -133,11 +175,11 @@ export function AwsConnectStep({

- Choose how Prowler should access your account. + Choose how to connect this account.

chooseMethod(AWS_ACCESS_METHOD.CREDENTIALS)} /> + {canInvite && ( + + )}
- {isRole ? ( + {isInvite && ( + + )} + + {isRole && ( - ) : ( + )} + + {isKeys && ( void; /** AWS registers, stores and tests the account in this step, so it skips ahead. */ onCredentialsSaved: () => void; + /** AWS offers it as the footer action once a teammate has been invited instead. */ + onClose: () => void; onSelectOrganizations: (orgType: OrgFlowType) => void; onFooterChange: (config: WizardFooterConfig) => void; onProviderTypeChange: (providerType: ProviderType | null) => void; @@ -33,6 +36,7 @@ interface ConnectStepProps { export function ConnectStep({ onNext, onCredentialsSaved, + onClose, onSelectOrganizations, onFooterChange, onProviderTypeChange, @@ -41,9 +45,13 @@ export function ConnectStep({ const { setProvider, setVia, setSecretId, setMode } = useProviderWizardStore(); const backHandlerRef = useRef<(() => void) | null>(null); + // The modal hands over a fresh `onClose` every render; the footer effect + // keeps one closure and reads the latest through the ref, as LaunchStep does. + const closeHandlerRef = useRef(onClose); + closeHandlerRef.current = onClose; // Local state needed: AWS swaps the generic account form for its one-step form. const [isAwsFlow, setIsAwsFlow] = useState(initialProviderType === "aws"); - const [uiState, setUiState] = useState({ + const [uiState, setUiState] = useState({ showBack: false, showAction: false, actionLabel: "Next", @@ -74,6 +82,8 @@ export function ConnectStep({ if (uiState.showAction && !uiState.actionDisabled && !uiState.isLoading) { endActiveTour(); } + // Nothing left to submit once a teammate has been invited: the action closes. + const closes = uiState.actionKind === AWS_CONNECT_ACTION_KIND.CLOSE; onFooterChange({ showBack: uiState.showBack, backLabel: "Back", @@ -86,8 +96,11 @@ export function ConnectStep({ actionLabel: uiState.actionLabel, actionLoading: uiState.isLoading, actionDisabled: uiState.actionDisabled || uiState.isLoading, - actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT, - actionFormId: formId, + actionType: closes + ? WIZARD_FOOTER_ACTION_TYPE.BUTTON + : WIZARD_FOOTER_ACTION_TYPE.SUBMIT, + actionFormId: closes ? undefined : formId, + onAction: closes ? () => closeHandlerRef.current() : undefined, }); }, [isAwsFlow, onFooterChange, uiState]); diff --git a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-form.tsx b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-form.tsx new file mode 100644 index 0000000000..6a7b0d9326 --- /dev/null +++ b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-form.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect } from "react"; +import { Controller } from "react-hook-form"; + +import { useSendInvitation } from "@/components/invitations/workflow/forms/use-send-invitation"; +import { WizardInputField } from "@/components/providers/workflow/forms/fields"; +import { Form } from "@/components/shadcn/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; +import { isAdminRole } from "@/lib/invitations/order-roles"; +import { + INVITATION_SOURCE, + type InvitationRoleOption, + type SentInvitation, +} from "@/types/onboarding-invite"; +import { getProviderDisplayName, type ProviderType } from "@/types/providers"; + +import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types"; + +interface InviteTeammateFormProps { + roles: InvitationRoleOption[]; + providerType: ProviderType; + formId: string; + onSent: (invitation: SentInvitation) => void; + onUiStateChange: (state: AwsConnectUiState) => void; + onBusyChange: (isBusy: boolean) => void; +} + +/** Email and role for the teammate; the wizard footer submits it by `formId`. */ +export function InviteTeammateForm({ + roles, + providerType, + formId, + onSent, + onUiStateChange, + onBusyChange, +}: InviteTeammateFormProps) { + const { form, onSubmit, isSubmitting, isValid } = useSendInvitation({ + source: INVITATION_SOURCE.PROVIDER_CONNECT, + // Admin can finish the setup; the user may still pick another role. + defaultRoleId: roles.find(isAdminRole)?.id ?? "", + mode: "onChange", + onSuccess: onSent, + }); + + // Same contract the AWS forms use: the wizard footer lives outside the step. + // Both callbacks must be stable setters, or this effect would loop. + useEffect(() => { + onBusyChange(isSubmitting); + onUiStateChange({ + showBack: true, + showAction: true, + actionLabel: isSubmitting ? "Sending invitation..." : "Send invitation", + actionDisabled: !isValid || isSubmitting, + isLoading: isSubmitting, + actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + }); + }, [isSubmitting, isValid, onBusyChange, onUiStateChange]); + + return ( + + +

+ Invite someone from your team who can access the{" "} + {getProviderDisplayName(providerType)} account. They will join this + Prowler tenant and can connect it themselves. +

+ + + + ( +
+ +

+ Pick a role that can manage providers, such as admin. +

+ {fieldState.error && ( +

+ {fieldState.error.message} +

+ )} +
+ )} + /> + + + ); +} diff --git a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.test.tsx b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.test.tsx new file mode 100644 index 0000000000..65139703eb --- /dev/null +++ b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.test.tsx @@ -0,0 +1,294 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useRef, useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getProviderDisplayName } from "@/types/providers"; + +import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types"; + +import { InviteTeammatePanel } from "./invite-teammate-panel"; + +const { getInvitationRoles, sendInvite, toastMock } = vi.hoisted(() => ({ + getInvitationRoles: vi.fn(), + sendInvite: vi.fn(), + toastMock: vi.fn(), +})); + +vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles })); +vi.mock("@/actions/invitations/invitation", () => ({ sendInvite })); +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 FORM_ID = "invite-teammate-test-form"; +const ROLES = [ + { id: "11111111-1111-4111-8111-111111111111", name: "member" }, + { id: "22222222-2222-4222-8222-222222222222", name: "admin" }, +]; +const SENT = { + data: { + id: "inv-1", + attributes: { email: "teammate@company.com", token: "abc123DEF45678" }, + }, +}; + +// Stands in for the wizard footer: the panel only publishes its UI state. +function Harness({ + onUiState, + onBusyChange, +}: { + onUiState: (state: AwsConnectUiState) => void; + onBusyChange: (isBusy: boolean) => void; +}) { + const [uiState, setUiState] = useState(null); + // Stable like the wizard's own setter: the panel keys an effect on it. + const handleUiState = useRef((state: AwsConnectUiState) => { + setUiState(state); + onUiState(state); + }).current; + return ( + <> + + {uiState?.showAction && ( + + )} + + ); +} + +function renderPanel() { + const onUiState = vi.fn(); + const onBusyChange = vi.fn(); + render(); + return { onUiState, onBusyChange, user: userEvent.setup() }; +} + +const lastUiState = (onUiState: ReturnType) => + onUiState.mock.calls.at(-1)?.[0] as AwsConnectUiState; + +async function fillAndSend(user: ReturnType) { + await user.type( + await screen.findByRole("textbox", { name: /Teammate email/ }), + "teammate@company.com", + ); + const send = screen.getByRole("button", { name: "Send invitation" }); + await waitFor(() => expect(send).toBeEnabled()); + await user.click(send); +} + +describe("InviteTeammatePanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + getInvitationRoles.mockResolvedValue(ROLES); + sendInvite.mockResolvedValue(SENT); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("holds the footer on a disabled Send invitation while the roles load", () => { + // Given: roles that have not answered yet. + getInvitationRoles.mockReturnValue(new Promise(() => {})); + + // When + const { onUiState } = renderPanel(); + + // Then + expect(lastUiState(onUiState)).toMatchObject({ + showAction: true, + actionLabel: "Send invitation", + actionDisabled: true, + actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + }); + expect(screen.getByRole("status")).toHaveTextContent(/Loading roles/); + }); + + it("offers the roles admin first, preselected, and gates Send on a valid email", async () => { + // When + const { onUiState } = renderPanel(); + + // Then + const select = await screen.findByRole("combobox", { + name: "Select a role", + }); + expect(select).toHaveValue(ROLES[1].id); + expect( + screen.getAllByRole("option").map((option) => option.textContent), + ).toEqual(["Select a role", "admin", "member"]); + expect(lastUiState(onUiState)).toMatchObject({ + actionLabel: "Send invitation", + actionDisabled: true, + }); + }); + + it("sends the invitation tagged as coming from the provider connection and shows the link", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "false"); + const { onUiState, onBusyChange, user } = renderPanel(); + + // When + await fillAndSend(user); + + // Then: the API got the form, the user gets the link to share. + const formData = sendInvite.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("provider_connect"); + expect( + await screen.findByText("Invitation sent to teammate@company.com"), + ).toBeInTheDocument(); + expect( + screen.getByText( + `${window.location.origin}/invitation/accept?invitation_token=abc123DEF45678`, + ), + ).toBeInTheDocument(); + expect( + screen.getByText(/Prowler does not send emails/), + ).toBeInTheDocument(); + expect( + screen.getByText( + new RegExp(`connect the ${getProviderDisplayName("aws")} account`), + ), + ).toBeInTheDocument(); + // The footer closes the wizard from here, and the step is no longer busy. + expect(lastUiState(onUiState)).toMatchObject({ + actionLabel: "Done", + actionDisabled: false, + actionKind: AWS_CONNECT_ACTION_KIND.CLOSE, + }); + expect(onBusyChange).toHaveBeenLastCalledWith(false); + expect(onBusyChange).toHaveBeenCalledWith(true); + }); + + it("tells a Cloud user the invitation was emailed too", async () => { + // Given + vi.stubEnv("UI_CLOUD_ENABLED", "true"); + const { user } = renderPanel(); + + // When + await fillAndSend(user); + + // Then + expect( + await screen.findByText(/We have emailed them the invitation/), + ).toBeInTheDocument(); + expect( + screen.queryByText(/Prowler does not send emails/), + ).not.toBeInTheDocument(); + }); + + it("keeps the form up with the field error when the API rejects the email", async () => { + // Given + sendInvite.mockResolvedValue({ + errors: [ + { + detail: "This email has already been invited.", + source: { pointer: "/data/attributes/email" }, + }, + ], + }); + const { onUiState, user } = renderPanel(); + + // When + await fillAndSend(user); + + // Then + expect( + await screen.findByText("This email has already been invited."), + ).toBeInTheDocument(); + expect(screen.queryByText(/Invitation sent/)).not.toBeInTheDocument(); + expect(lastUiState(onUiState)).toMatchObject({ + actionLabel: "Send invitation", + actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + }); + }); + + it("stays on the form with a toast when the action resolves without an invitation", async () => { + // Given: a 5xx makes the action resolve undefined. + sendInvite.mockResolvedValue(undefined); + const { user } = renderPanel(); + + // When + await fillAndSend(user); + + // Then + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ variant: "destructive" }), + ), + ); + expect(screen.queryByText(/Invitation sent/)).not.toBeInTheDocument(); + expect( + screen.getByRole("textbox", { name: /Teammate email/ }), + ).toBeInTheDocument(); + }); + + it("explains and hides the action when the roles cannot be loaded", async () => { + // Given + getInvitationRoles.mockRejectedValue(new Error("roles unavailable")); + + // When + const { onUiState } = renderPanel(); + + // Then + expect( + await screen.findByText(/Roles could not be loaded right now/), + ).toBeInTheDocument(); + expect(lastUiState(onUiState)).toMatchObject({ showAction: false }); + expect( + screen.queryByRole("button", { name: "Send invitation" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.tsx b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.tsx new file mode 100644 index 0000000000..fc93a68474 --- /dev/null +++ b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-panel.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useState } from "react"; + +import { useInvitationRoles } from "@/hooks/use-invitation-roles"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { orderRolesAdminFirst } from "@/lib/invitations/order-roles"; +import type { SentInvitation } from "@/types/onboarding-invite"; +import type { ProviderType } from "@/types/providers"; + +import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types"; + +import { InviteTeammateForm } from "./invite-teammate-form"; +import { InviteTeammateSent } from "./invite-teammate-sent"; + +const SEND_LABEL = "Send invitation"; + +interface InviteTeammatePanelProps { + providerType: ProviderType; + formId: string; + onUiStateChange: (state: AwsConnectUiState) => void; + onBusyChange: (isBusy: boolean) => void; +} + +/** + * The connect step's way out for a user who cannot connect the account: invite + * a teammate who can. Loads the roles, sends the invitation through the wizard + * footer and then shows the link to share. Provider-agnostic; AWS mounts it. + */ +export function InviteTeammatePanel({ + providerType, + formId, + onUiStateChange, + onBusyChange, +}: InviteTeammatePanelProps) { + const roles = useInvitationRoles(); + // Local state needed: the sent record belongs to this panel alone, never to + // the wizard draft or the store. + const [sent, setSent] = useState(null); + + if (sent) { + return ( + + ); + } + + if (roles === null) { + return ; + } + + if (roles.length === 0) { + return ; + } + + return ( + { + // The form unmounts mid-submit; release the step before it can. + onBusyChange(false); + setSent(invitation); + }} + onUiStateChange={onUiStateChange} + onBusyChange={onBusyChange} + /> + ); +} + +interface StaticStateProps { + onUiStateChange: (state: AwsConnectUiState) => void; +} + +function RolesLoading({ onUiStateChange }: StaticStateProps) { + useMountEffect(() => { + onUiStateChange({ + showBack: true, + showAction: true, + actionLabel: SEND_LABEL, + actionDisabled: true, + isLoading: false, + actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + }); + }); + + return ( +

+ + Loading roles... +

+ ); +} + +function RolesUnavailable({ onUiStateChange }: StaticStateProps) { + useMountEffect(() => { + onUiStateChange({ + showBack: true, + showAction: false, + actionLabel: SEND_LABEL, + actionDisabled: true, + isLoading: false, + actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT, + }); + }); + + return ( +

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

+ ); +} diff --git a/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-sent.tsx b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-sent.tsx new file mode 100644 index 0000000000..f725175f31 --- /dev/null +++ b/ui/components/providers/wizard/steps/invite-teammate/invite-teammate-sent.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { CircleCheck } from "lucide-react"; + +import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import { buildInvitationAcceptLink } from "@/lib/invitations/accept-link"; +import { isCloud } from "@/lib/shared/env"; +import type { SentInvitation } from "@/types/onboarding-invite"; +import { getProviderDisplayName, type ProviderType } from "@/types/providers"; + +import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "../aws/types"; + +interface InviteTeammateSentProps { + invitation: SentInvitation; + providerType: ProviderType; + onUiStateChange: (state: AwsConnectUiState) => void; +} + +/** The link to share once the invitation exists; the footer's "Done" closes the wizard. */ +export function InviteTeammateSent({ + invitation, + providerType, + onUiStateChange, +}: InviteTeammateSentProps) { + useMountEffect(() => { + onUiStateChange({ + showBack: true, + showAction: true, + actionLabel: "Done", + actionDisabled: false, + isLoading: false, + actionKind: AWS_CONNECT_ACTION_KIND.CLOSE, + }); + }); + + // Mounted after a click, so the window is there; the guard keeps SSR safe. + const origin = typeof window === "undefined" ? "" : window.location.origin; + const link = buildInvitationAcceptLink(invitation.token, origin); + + return ( +
+
+ +
+

+ Invitation sent to {invitation.email} +

+

+ {isCloud() + ? "We have emailed them the invitation. You can also share this link with them:" + : "Prowler does not send emails. Share this link with them:"} +

+
+
+ + + +

+ The link expires in 7 days. Once they accept, they can connect the{" "} + {getProviderDisplayName(providerType)} account from the Providers page. +

+
+ ); +} diff --git a/ui/hooks/use-invitation-roles.test.ts b/ui/hooks/use-invitation-roles.test.ts new file mode 100644 index 0000000000..50755be9f5 --- /dev/null +++ b/ui/hooks/use-invitation-roles.test.ts @@ -0,0 +1,66 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useInvitationRoles } from "./use-invitation-roles"; + +const { getInvitationRoles } = vi.hoisted(() => ({ + getInvitationRoles: vi.fn(), +})); + +vi.mock("@/actions/invitations/roles", () => ({ getInvitationRoles })); + +const ROLES = [ + { id: "11111111-1111-4111-8111-111111111111", name: "member" }, + { id: "22222222-2222-4222-8222-222222222222", name: "admin" }, +]; + +describe("useInvitationRoles", () => { + beforeEach(() => { + getInvitationRoles.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("is unsettled until the roles arrive, then exposes them as loaded", async () => { + getInvitationRoles.mockResolvedValue(ROLES); + + const { result } = renderHook(() => useInvitationRoles()); + + expect(result.current).toBeNull(); + await waitFor(() => expect(result.current).toEqual(ROLES)); + }); + + it("settles empty when the read fails", async () => { + getInvitationRoles.mockRejectedValue(new Error("roles unavailable")); + + const { result } = renderHook(() => useInvitationRoles()); + + await waitFor(() => expect(result.current).toEqual([])); + }); + + it("settles empty when the read never answers, and ignores a late answer", async () => { + vi.useFakeTimers(); + let resolveLate: (roles: typeof ROLES) => void = () => {}; + getInvitationRoles.mockReturnValue( + new Promise((resolve) => { + resolveLate = resolve; + }), + ); + + const { result } = renderHook(() => useInvitationRoles()); + expect(result.current).toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + expect(result.current).toEqual([]); + + await act(async () => { + resolveLate(ROLES); + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current).toEqual([]); + }); +}); diff --git a/ui/hooks/use-invitation-roles.ts b/ui/hooks/use-invitation-roles.ts new file mode 100644 index 0000000000..8a098e1686 --- /dev/null +++ b/ui/hooks/use-invitation-roles.ts @@ -0,0 +1,38 @@ +"use client"; + +import { useState } from "react"; + +import { getInvitationRoles } from "@/actions/invitations/roles"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import type { InvitationRoleOption } from "@/types/onboarding-invite"; + +// Roles that have not arrived by then count as unavailable, so a request +// that never answers cannot hold a form behind an empty list. +const ROLES_TIMEOUT_MS = 5_000; + +/** Roles an invitation can grant: `null` until the read settles, `[]` when it failed or timed out. */ +export function useInvitationRoles(): InvitationRoleOption[] | null { + const [roles, setRoles] = useState(null); + + useMountEffect(() => { + 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); + }; + timer = setTimeout(() => settle([]), ROLES_TIMEOUT_MS); + getInvitationRoles() + .then(settle) + .catch(() => settle([])); + return () => { + active = false; + clearTimeout(timer); + }; + }); + + return roles; +} diff --git a/ui/lib/invitations/accept-link.test.ts b/ui/lib/invitations/accept-link.test.ts new file mode 100644 index 0000000000..d903b85e45 --- /dev/null +++ b/ui/lib/invitations/accept-link.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { buildInvitationAcceptLink } from "./accept-link"; + +describe("buildInvitationAcceptLink", () => { + it("points the invitee at the accept page of the given origin", () => { + expect( + buildInvitationAcceptLink("abc123DEF45678", "https://app.example.com"), + ).toBe( + "https://app.example.com/invitation/accept?invitation_token=abc123DEF45678", + ); + }); + + it("keeps the token safe inside the query string", () => { + expect(buildInvitationAcceptLink("a b&c", "https://app.example.com")).toBe( + "https://app.example.com/invitation/accept?invitation_token=a%20b%26c", + ); + }); +}); diff --git a/ui/lib/invitations/accept-link.ts b/ui/lib/invitations/accept-link.ts new file mode 100644 index 0000000000..4fa520b4de --- /dev/null +++ b/ui/lib/invitations/accept-link.ts @@ -0,0 +1,9 @@ +const INVITATION_ACCEPT_PATH = "/invitation/accept"; + +/** Link an invitee opens to join the tenant; the API only hands back the token. */ +export function buildInvitationAcceptLink( + token: string, + origin: string, +): string { + return `${origin}${INVITATION_ACCEPT_PATH}?invitation_token=${encodeURIComponent(token)}`; +} diff --git a/ui/lib/invitations/order-roles.test.ts b/ui/lib/invitations/order-roles.test.ts new file mode 100644 index 0000000000..64aefeea48 --- /dev/null +++ b/ui/lib/invitations/order-roles.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { orderRolesAdminFirst } from "./order-roles"; + +describe("orderRolesAdminFirst", () => { + it("moves the admin role to the front and keeps the rest in order", () => { + const roles = [ + { id: "1", name: "member" }, + { id: "2", name: "Admin" }, + { id: "3", name: "auditor" }, + ]; + + expect(orderRolesAdminFirst(roles).map((role) => role.name)).toEqual([ + "Admin", + "member", + "auditor", + ]); + }); + + it("leaves the list untouched when there is no admin role", () => { + const roles = [ + { id: "1", name: "member" }, + { id: "2", name: "auditor" }, + ]; + + expect(orderRolesAdminFirst(roles)).toEqual(roles); + expect(orderRolesAdminFirst(roles)).not.toBe(roles); + }); +}); diff --git a/ui/lib/invitations/order-roles.ts b/ui/lib/invitations/order-roles.ts new file mode 100644 index 0000000000..cd87c0d49d --- /dev/null +++ b/ui/lib/invitations/order-roles.ts @@ -0,0 +1,15 @@ +import type { InvitationRoleOption } from "@/types/onboarding-invite"; + +const ADMIN_ROLE_NAME = "admin"; + +export const isAdminRole = (role: InvitationRoleOption) => + role.name.toLowerCase() === ADMIN_ROLE_NAME; + +/** Admin first: the natural pick for a teammate who has to finish the setup. */ +export function orderRolesAdminFirst( + roles: InvitationRoleOption[], +): InvitationRoleOption[] { + return [...roles].sort( + (a, b) => Number(isAdminRole(b)) - Number(isAdminRole(a)), + ); +} diff --git a/ui/lib/provider-funnel/provider-funnel-events.ts b/ui/lib/provider-funnel/provider-funnel-events.ts index 2d485b9536..af2ad45be8 100644 --- a/ui/lib/provider-funnel/provider-funnel-events.ts +++ b/ui/lib/provider-funnel/provider-funnel-events.ts @@ -41,6 +41,8 @@ export type WizardOpenSource = export const PROVIDER_FUNNEL_METHOD = { SINGLE: "single", ORGANIZATION: "organization", + // The user cannot connect the account and hands it to a teammate instead. + INVITE_TEAMMATE: "invite_teammate", } as const; export type ProviderFunnelMethod = diff --git a/ui/store/provider-wizard/store.ts b/ui/store/provider-wizard/store.ts index 2d94eff7db..b401bf9803 100644 --- a/ui/store/provider-wizard/store.ts +++ b/ui/store/provider-wizard/store.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { + AWS_CONNECT_PANEL, AwsConnectDraft, PROVIDER_WIZARD_MODE, ProviderWizardIdentity, @@ -39,6 +40,7 @@ const initialState = { const EMPTY_AWS_CONNECT_DRAFT: AwsConnectDraft = { method: "role", + panel: AWS_CONNECT_PANEL.ACCESS, roleValues: {}, keysValues: {}, }; diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index 9777c1b61b..da52041e0d 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -708,6 +708,46 @@ export class ProvidersPage extends BasePage { await this.selectProviderRadio(this.githubProviderRadio); } + // Offered on the AWS step to a user who can invite but cannot reach the account. + async selectAwsInviteTeammate(): Promise { + const invite = this.wizardModal.getByRole("radio", { + name: /invite a teammate/i, + }); + await expect(invite).toBeVisible({ timeout: 10000 }); + await invite.click(); + await expect( + this.wizardModal.getByRole("textbox", { name: /Teammate email/i }), + ).toBeVisible({ timeout: 10000 }); + } + + // The admin role comes preselected; only the email is needed. + async sendTeammateInvitation(email: string): Promise { + await this.wizardModal + .getByRole("textbox", { name: /Teammate email/i }) + .fill(email); + const send = this.page.getByRole("button", { + name: "Send invitation", + exact: true, + }); + await expect(send).toBeEnabled({ timeout: 10000 }); + await send.click(); + } + + async verifyTeammateInvitationSent(email: string): Promise { + await expect( + this.wizardModal.getByText(`Invitation sent to ${email}`), + ).toBeVisible({ timeout: 15000 }); + await expect( + this.wizardModal.getByText(/\/invitation\/accept\?invitation_token=/), + ).toBeVisible(); + } + + // "Done" replaces the submit once the invitation exists and closes the wizard. + async finishTeammateInvitation(): Promise { + await this.page.getByRole("button", { name: "Done", exact: true }).click(); + await expect(this.wizardModal).not.toBeVisible(); + } + // AWS picks its access method on the same step that registers the account. async selectAwsAccessMethod(type: AWSCredentialType): Promise { const name = diff --git a/ui/tests/providers/providers.md b/ui/tests/providers/providers.md index 049c3fcbe3..53ba05f1f9 100644 --- a/ui/tests/providers/providers.md +++ b/ui/tests/providers/providers.md @@ -1075,3 +1075,52 @@ - Private Key is provided as base64-encoded PEM content and decoded before use (multi-line content) - Provider cleanup performed before each test to ensure clean state - Requires a valid Okta API Services app with a registered public key (JWK) matching the provided private key + +--- + +## Test Case: `PROVIDER-E2E-020` - Invite a Teammate From the AWS Connect Step + +**Priority:** `high` + +**Tags:** + +- type → @e2e +- feature → @providers +- provider → @aws + +**Description/Objective:** Validates that a user who cannot connect the AWS account can invite a teammate from the AWS connect step, without leaving the wizard, and gets the invitation link to share. + +**Preconditions:** + +- Admin user authentication required (admin.auth.setup setup): the option is only offered to users who can invite (`manage_account`) +- No environment variables required: no provider is created + +### Flow Steps + +1. Navigate to providers page +2. Click "Add Provider" button +3. Select AWS provider type +4. Select the "I don't have access, invite a teammate" option +5. Fill the teammate email (the admin role comes preselected) +6. Click "Send invitation" +7. Verify the confirmation and the invitation link +8. Click "Done" + +### Expected Result + +- The AWS access form is replaced by the invitation form +- The invitation is created and the confirmation shows the invited email and the accept link +- "Done" closes the wizard without creating a provider + +### Key verification points + +- The invite option is visible on the AWS step for the admin user +- "Send invitation" is enabled once a valid email is typed +- Confirmation text "Invitation sent to {email}" is visible +- The accept link contains `/invitation/accept?invitation_token=` +- The wizard modal is closed after "Done" + +### Notes + +- Uses a unique email per run so the invitation never collides with a pending one +- The invitation is left pending; it expires on its own after 7 days diff --git a/ui/tests/providers/providers.spec.ts b/ui/tests/providers/providers.spec.ts index 4cdd0aa63c..8dd348e9c5 100644 --- a/ui/tests/providers/providers.spec.ts +++ b/ui/tests/providers/providers.spec.ts @@ -1,6 +1,7 @@ import { test } from "@playwright/test"; import { isCloud } from "@/lib/shared/env"; +import { makeSuffix } from "../helpers"; import { ProvidersPage, AWSProviderData, @@ -263,6 +264,44 @@ test.describe("Add Provider", () => { ); }); + test.describe("Invite a teammate from the AWS step", () => { + let providersPage: ProvidersPage; + + test.beforeEach(async ({ page }) => { + providersPage = new ProvidersPage(page); + }); + + // The admin user can invite (manage_account) and add providers. + test.use({ storageState: "playwright/.auth/admin_user.json" }); + + test( + "should invite a teammate to connect the AWS account instead", + { + tag: ["@high", "@e2e", "@providers", "@aws", "@PROVIDER-E2E-020"], + }, + async () => { + const uniqueEmail = `e2e+aws-${makeSuffix(10)}@prowler.com`; + + // Navigate to providers page + await providersPage.goto(); + await providersPage.verifyPageLoaded(); + + // Start adding new provider and pick AWS + await providersPage.clickAddProvider(); + await providersPage.verifyConnectAccountPageLoaded(); + await providersPage.selectAWSProvider(); + + // Hand the account over to a teammate instead of connecting it + await providersPage.selectAwsInviteTeammate(); + await providersPage.sendTeammateInvitation(uniqueEmail); + + // The invitation exists and the link to share is shown; Done closes the wizard + await providersPage.verifyTeammateInvitationSent(uniqueEmail); + await providersPage.finishTeammateInvitation(); + }, + ); + }); + test.describe.serial("Add AZURE Provider", () => { // Providers page object let providersPage: ProvidersPage; diff --git a/ui/types/onboarding-invite.ts b/ui/types/onboarding-invite.ts index e1bcdec4d6..f0d8498dcf 100644 --- a/ui/types/onboarding-invite.ts +++ b/ui/types/onboarding-invite.ts @@ -3,9 +3,18 @@ export const INVITATION_SOURCE_PARAM = "source"; export const INVITATION_SOURCE = { ONBOARDING: "onboarding", + // Sent from the add-provider wizard by a user who cannot connect the account. + PROVIDER_CONNECT: "provider_connect", } as const; export interface InvitationRoleOption { id: string; name: string; } + +/** What a successful `sendInvite` yields: enough to show and share the accept link. */ +export interface SentInvitation { + id: string; + email: string; + token: string; +} diff --git a/ui/types/provider-wizard.ts b/ui/types/provider-wizard.ts index 8f8dd5c17a..2b927f25f0 100644 --- a/ui/types/provider-wizard.ts +++ b/ui/types/provider-wizard.ts @@ -27,9 +27,19 @@ export interface ProviderWizardIdentity { export type AwsConnectDraftValues = Record; +/** Which panel the AWS connect step shows: the access forms or the teammate invitation. */ +export const AWS_CONNECT_PANEL = { + ACCESS: "access", + INVITE: "invite", +} as const; + +export type AwsConnectPanel = + (typeof AWS_CONNECT_PANEL)[keyof typeof AWS_CONNECT_PANEL]; + /** What the AWS connect step typed so far; in memory only, gone with the wizard. */ export interface AwsConnectDraft { method: string; + panel: AwsConnectPanel; roleValues: AwsConnectDraftValues; keysValues: AwsConnectDraftValues; }