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 (