mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-03 17:54:05 +00:00
feat(ui): invite a teammate from the AWS connect step (#12917)
This commit is contained in:
@@ -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.
|
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.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
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.
|
||||||
|
</Note>
|
||||||
|
|
||||||
## Step 6: Scan Started
|
## 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:
|
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:
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>)
|
||||||
|
: {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -5,11 +5,9 @@ import type { InvitationRoleOption } from "@/types/onboarding-invite";
|
|||||||
|
|
||||||
const ROLES_PAGE_SIZE = 50;
|
const ROLES_PAGE_SIZE = 50;
|
||||||
|
|
||||||
// Roles the onboarding invite step can offer; empty when the read fails so
|
// Roles an invitation can grant; empty when the read fails so a caller can
|
||||||
// the step can fall back to skipping rather than blocking the checkpoint.
|
// fall back (skip, disable) rather than block.
|
||||||
export const getOnboardingInviteRoles = async (): Promise<
|
export const getInvitationRoles = async (): Promise<InvitationRoleOption[]> => {
|
||||||
InvitationRoleOption[]
|
|
||||||
> => {
|
|
||||||
const rolesData = await getRoles({ pageSize: ROLES_PAGE_SIZE });
|
const rolesData = await getRoles({ pageSize: ROLES_PAGE_SIZE });
|
||||||
const roles: unknown = rolesData?.data;
|
const roles: unknown = rolesData?.data;
|
||||||
if (!Array.isArray(roles)) return [];
|
if (!Array.isArray(roles)) return [];
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Option to invite a teammate from the AWS connect step when the user cannot access the account credentials
|
||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
|
|
||||||
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
|
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
|
||||||
import { DateWithTime } from "@/components/shadcn/entities";
|
import { DateWithTime } from "@/components/shadcn/entities";
|
||||||
|
import { buildInvitationAcceptLink } from "@/lib/invitations/accept-link";
|
||||||
|
|
||||||
import { AddIcon } from "../icons";
|
import { AddIcon } from "../icons";
|
||||||
import { Button, Card, CardContent, CardHeader } from "../shadcn";
|
import { Button, Card, CardContent, CardHeader } from "../shadcn";
|
||||||
@@ -54,7 +55,7 @@ export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => {
|
|||||||
? window.location.origin
|
? window.location.origin
|
||||||
: "http://localhost:3000";
|
: "http://localhost:3000";
|
||||||
|
|
||||||
const invitationLink = `${baseUrl}/invitation/accept?invitation_token=${attributes.token}`;
|
const invitationLink = buildInvitationAcceptLink(attributes.token, baseUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-x-4 gap-y-8">
|
<div className="flex flex-col gap-x-4 gap-y-8">
|
||||||
|
|||||||
@@ -66,7 +66,13 @@ const fillAndSubmit = async (user: ReturnType<typeof userEvent.setup>) => {
|
|||||||
describe("SendInvitationForm", () => {
|
describe("SendInvitationForm", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
pushMock.mockReset();
|
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 () => {
|
it("navigates to the invitation details by default", async () => {
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { SaveIcon } from "lucide-react";
|
import { SaveIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller } from "react-hook-form";
|
||||||
import * as z from "zod";
|
|
||||||
|
|
||||||
import { sendInvite } from "@/actions/invitations/invitation";
|
import { Button } from "@/components/shadcn";
|
||||||
import { Button, useToast } from "@/components/shadcn";
|
|
||||||
import { CustomInput } from "@/components/shadcn/custom";
|
import { CustomInput } from "@/components/shadcn/custom";
|
||||||
import { Form } from "@/components/shadcn/form";
|
import { Form } from "@/components/shadcn/form";
|
||||||
import {
|
import {
|
||||||
@@ -17,15 +14,9 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/shadcn/select/select";
|
} from "@/components/shadcn/select/select";
|
||||||
import { ApiError } from "@/types";
|
|
||||||
import type { InvitationRoleOption } from "@/types/onboarding-invite";
|
import type { InvitationRoleOption } from "@/types/onboarding-invite";
|
||||||
|
|
||||||
const sendInvitationFormSchema = z.object({
|
import { useSendInvitation } from "./use-send-invitation";
|
||||||
email: z.email({ error: "Please enter a valid email" }),
|
|
||||||
roleId: z.string().min(1, "Role is required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type FormValues = z.infer<typeof sendInvitationFormSchema>;
|
|
||||||
|
|
||||||
interface SendInvitationFormProps {
|
interface SendInvitationFormProps {
|
||||||
roles: InvitationRoleOption[];
|
roles: InvitationRoleOption[];
|
||||||
@@ -45,90 +36,23 @@ export const SendInvitationForm = ({
|
|||||||
source,
|
source,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: SendInvitationFormProps) => {
|
}: SendInvitationFormProps) => {
|
||||||
const { toast } = useToast();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const { form, onSubmit, isSubmitting } = useSendInvitation({
|
||||||
resolver: zodResolver(sendInvitationFormSchema),
|
source,
|
||||||
defaultValues: {
|
defaultRoleId: isSelectorDisabled ? defaultRole : "",
|
||||||
email: "",
|
onSuccess: (invitation) => {
|
||||||
roleId: isSelectorDisabled ? defaultRole : "",
|
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 (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||||
onSubmit={form.handleSubmit(onSubmitClient)}
|
|
||||||
className="flex flex-col gap-4"
|
|
||||||
>
|
|
||||||
{/* Email Field */}
|
{/* Email Field */}
|
||||||
<CustomInput
|
<CustomInput
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -182,9 +106,9 @@ export const SendInvitationForm = ({
|
|||||||
className="w-1/2"
|
className="w-1/2"
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={isLoading}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isSubmitting ? (
|
||||||
<>Loading</>
|
<>Loading</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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<typeof sendInvitationFormSchema>;
|
||||||
|
|
||||||
|
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<SendInvitationFormValues>["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<SendInvitationFormValues>({
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@ vi.mock("next/navigation", () => ({
|
|||||||
useRouter: () => ({ push: vi.fn() }),
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/actions/onboarding/invite", () => ({
|
vi.mock("@/actions/invitations/roles", () => ({
|
||||||
getOnboardingInviteRoles: getRolesMock,
|
getInvitationRoles: getRolesMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/actions/invitations/invitation", () => ({
|
vi.mock("@/actions/invitations/invitation", () => ({
|
||||||
@@ -85,7 +85,13 @@ describe("OnboardingInviteStep", () => {
|
|||||||
outcomes.length = 0;
|
outcomes.length = 0;
|
||||||
window.addEventListener(ONBOARDING_INVITE_STEP_EVENT, recordOutcome);
|
window.addEventListener(ONBOARDING_INVITE_STEP_EVENT, recordOutcome);
|
||||||
getRolesMock.mockReset().mockResolvedValue(ROLES);
|
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();
|
toastMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SendInvitationForm } from "@/components/invitations/workflow/forms/send
|
|||||||
import { Button } from "@/components/shadcn";
|
import { Button } from "@/components/shadcn";
|
||||||
import { DialogFooter } from "@/components/shadcn/dialog";
|
import { DialogFooter } from "@/components/shadcn/dialog";
|
||||||
import { Modal } from "@/components/shadcn/modal/modal";
|
import { Modal } from "@/components/shadcn/modal/modal";
|
||||||
|
import { orderRolesAdminFirst } from "@/lib/invitations/order-roles";
|
||||||
import {
|
import {
|
||||||
INVITATION_SOURCE,
|
INVITATION_SOURCE,
|
||||||
type InvitationRoleOption,
|
type InvitationRoleOption,
|
||||||
@@ -16,19 +17,6 @@ interface OnboardingInviteDialogProps {
|
|||||||
onSkip: () => 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
|
// "Invite your team", offered once right after the first provider is
|
||||||
// connected: permissions on the cloud were just granted and the value of
|
// 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
|
// sharing the first scan is fresh. Reuses the members-page form, tagged as an
|
||||||
@@ -55,7 +43,9 @@ export function OnboardingInviteDialog({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{hasRoles ? (
|
{hasRoles ? (
|
||||||
<SendInvitationForm
|
<SendInvitationForm
|
||||||
roles={orderRoles(roles)}
|
// Admin first: the natural pick for a first teammate; the form
|
||||||
|
// itself keeps the selection required.
|
||||||
|
roles={orderRolesAdminFirst(roles)}
|
||||||
isSelectorDisabled={false}
|
isSelectorDisabled={false}
|
||||||
source={INVITATION_SOURCE.ONBOARDING}
|
source={INVITATION_SOURCE.ONBOARDING}
|
||||||
onSuccess={onSent}
|
onSuccess={onSent}
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useInvitationRoles } from "@/hooks/use-invitation-roles";
|
||||||
|
|
||||||
import { getOnboardingInviteRoles } from "@/actions/onboarding/invite";
|
|
||||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||||
import {
|
import {
|
||||||
dispatchOnboardingInviteStep,
|
dispatchOnboardingInviteStep,
|
||||||
ONBOARDING_STEP_OUTCOME,
|
ONBOARDING_STEP_OUTCOME,
|
||||||
} from "@/lib/onboarding/onboarding-events";
|
} from "@/lib/onboarding/onboarding-events";
|
||||||
import type { InvitationRoleOption } from "@/types/onboarding-invite";
|
|
||||||
|
|
||||||
import { OnboardingInviteDialog } from "./onboarding-invite-dialog";
|
import { OnboardingInviteDialog } from "./onboarding-invite-dialog";
|
||||||
|
|
||||||
@@ -16,38 +13,17 @@ interface OnboardingInviteStepProps {
|
|||||||
onDone: () => void;
|
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
|
// Mounted only while the step is showing: loads the roles once, announces
|
||||||
// the impression once, and resolves through a sent invitation or a skip.
|
// the impression once, and resolves through a sent invitation or a skip.
|
||||||
export function OnboardingInviteStep({ onDone }: OnboardingInviteStepProps) {
|
export function OnboardingInviteStep({ onDone }: OnboardingInviteStepProps) {
|
||||||
// `null` until the roles settle: the invitation form takes its default
|
// `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.
|
// role from the list at mount, so the dialog renders once the list is known.
|
||||||
const [roles, setRoles] = useState<InvitationRoleOption[] | null>(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(() => {
|
useMountEffect(() => {
|
||||||
dispatchOnboardingInviteStep({ outcome: ONBOARDING_STEP_OUTCOME.SHOWN });
|
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;
|
if (roles === null) return null;
|
||||||
|
|||||||
@@ -36,12 +36,23 @@ const {
|
|||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
|
useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
vi.mock("next-auth/react", () => ({
|
const { getInvitationRoles, sendInvite, session } = vi.hoisted(() => ({
|
||||||
useSession: () => ({
|
getInvitationRoles: vi.fn(),
|
||||||
data: { tenantId: "tenant-abc" },
|
sendInvite: vi.fn(),
|
||||||
status: "authenticated",
|
// Mutable: only the permissions differ between cases.
|
||||||
}),
|
session: {
|
||||||
|
data: { tenantId: "tenant-abc" } as {
|
||||||
|
tenantId: string;
|
||||||
|
user?: { permissions: Record<string, boolean> };
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
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", () => ({
|
vi.mock("@/actions/providers/providers", () => ({
|
||||||
addCredentialsProvider,
|
addCredentialsProvider,
|
||||||
addProvider,
|
addProvider,
|
||||||
@@ -165,6 +176,7 @@ describe("provider wizard account creation", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
|
session.data = { tenantId: "tenant-abc" };
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows progress, blocks repeat clicks, and advances after creation", async () => {
|
it("shows progress, blocks repeat clicks, and advances after creation", async () => {
|
||||||
@@ -459,6 +471,61 @@ describe("provider wizard account creation", () => {
|
|||||||
expect(endActiveTour).toHaveBeenCalled();
|
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<ProviderFunnelDetail>).detail);
|
||||||
|
};
|
||||||
|
window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<ProviderWizardModal open onOpenChange={onOpenChange} />);
|
||||||
|
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 () => {
|
it("goes back to the provider list", async () => {
|
||||||
// Given
|
// Given
|
||||||
const user = await pickAws();
|
const user = await pickAws();
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export function ProviderWizardModal({
|
|||||||
handleTestSuccess();
|
handleTestSuccess();
|
||||||
endActiveTour();
|
endActiveTour();
|
||||||
}}
|
}}
|
||||||
|
onClose={handleClose}
|
||||||
onSelectOrganizations={openOrganizationsFlow}
|
onSelectOrganizations={openOrganizationsFlow}
|
||||||
onFooterChange={setFooterConfig}
|
onFooterChange={setFooterConfig}
|
||||||
onProviderTypeChange={(providerType) => {
|
onProviderTypeChange={(providerType) => {
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const {
|
|||||||
updateCredentialsProvider,
|
updateCredentialsProvider,
|
||||||
testProviderConnection,
|
testProviderConnection,
|
||||||
openCloudUpgradeMock,
|
openCloudUpgradeMock,
|
||||||
|
endActiveTour,
|
||||||
|
getInvitationRoles,
|
||||||
|
sendInvite,
|
||||||
|
session,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
addProvider: vi.fn(),
|
addProvider: vi.fn(),
|
||||||
addCredentialsProvider: vi.fn(),
|
addCredentialsProvider: vi.fn(),
|
||||||
@@ -30,14 +34,24 @@ const {
|
|||||||
updateCredentialsProvider: vi.fn(),
|
updateCredentialsProvider: vi.fn(),
|
||||||
testProviderConnection: vi.fn(),
|
testProviderConnection: vi.fn(),
|
||||||
openCloudUpgradeMock: 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<string, boolean> };
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("next-auth/react", () => ({
|
vi.mock("next-auth/react", () => ({
|
||||||
useSession: () => ({
|
useSession: () => ({ data: session.data, status: "authenticated" }),
|
||||||
data: { tenantId: "tenant-abc" },
|
|
||||||
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", () => ({
|
vi.mock("@/actions/providers/providers", () => ({
|
||||||
addProvider,
|
addProvider,
|
||||||
addCredentialsProvider,
|
addCredentialsProvider,
|
||||||
@@ -130,6 +144,7 @@ describe("AwsConnectStep", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
|
window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
|
session.data = { tenantId: "tenant-abc" };
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("in Prowler Cloud", () => {
|
describe("in Prowler Cloud", () => {
|
||||||
@@ -754,4 +769,99 @@ describe("AwsConnectStep", () => {
|
|||||||
expect(secret).not.toHaveProperty("aws_access_key_id");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
UserPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/shadcn/collapsible";
|
} from "@/components/shadcn/collapsible";
|
||||||
import { Form } from "@/components/shadcn/form";
|
import { Form } from "@/components/shadcn/form";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useFormServerErrors } from "@/hooks/use-form-server-errors";
|
import { useFormServerErrors } from "@/hooks/use-form-server-errors";
|
||||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||||
import { PROVIDER_CREDENTIALS_ERROR_MAPPING } from "@/lib/error-mappings";
|
import { PROVIDER_CREDENTIALS_ERROR_MAPPING } from "@/lib/error-mappings";
|
||||||
@@ -43,14 +45,22 @@ import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-cr
|
|||||||
import {
|
import {
|
||||||
ACCOUNT_SUBMIT_OUTCOME,
|
ACCOUNT_SUBMIT_OUTCOME,
|
||||||
dispatchProviderFunnel,
|
dispatchProviderFunnel,
|
||||||
|
PROVIDER_FUNNEL_METHOD,
|
||||||
PROVIDER_FUNNEL_STEP,
|
PROVIDER_FUNNEL_STEP,
|
||||||
} from "@/lib/provider-funnel/provider-funnel-events";
|
} from "@/lib/provider-funnel/provider-funnel-events";
|
||||||
import { testProviderConnection } from "@/lib/provider-helpers";
|
import { testProviderConnection } from "@/lib/provider-helpers";
|
||||||
|
import { endActiveTour } from "@/lib/tours/use-driver-tour";
|
||||||
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
import { useProviderWizardStore } from "@/store/provider-wizard/store";
|
||||||
import type { AWSCredentials, AWSCredentialsRole } from "@/types";
|
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 { CONNECTION_CHECK_STATUS } from "@/types/providers";
|
||||||
|
|
||||||
|
import { InviteTeammatePanel } from "../invite-teammate/invite-teammate-panel";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
awsKeysConnectSchema,
|
awsKeysConnectSchema,
|
||||||
type AwsKeysConnectValues,
|
type AwsKeysConnectValues,
|
||||||
@@ -84,6 +94,11 @@ const initialMethod = (): AwsAccessMethod =>
|
|||||||
? AWS_ACCESS_METHOD.CREDENTIALS
|
? AWS_ACCESS_METHOD.CREDENTIALS
|
||||||
: AWS_ACCESS_METHOD.ROLE;
|
: AWS_ACCESS_METHOD.ROLE;
|
||||||
|
|
||||||
|
const initialPanel = (): AwsConnectPanel =>
|
||||||
|
readDraft()?.panel === AWS_CONNECT_PANEL.INVITE
|
||||||
|
? AWS_CONNECT_PANEL.INVITE
|
||||||
|
: AWS_CONNECT_PANEL.ACCESS;
|
||||||
|
|
||||||
function useDraftValues<T extends FieldValues>(
|
function useDraftValues<T extends FieldValues>(
|
||||||
form: UseFormReturn<T>,
|
form: UseFormReturn<T>,
|
||||||
key: keyof Pick<AwsConnectDraft, "roleValues" | "keysValues">,
|
key: keyof Pick<AwsConnectDraft, "roleValues" | "keysValues">,
|
||||||
@@ -112,14 +127,41 @@ export function AwsConnectStep({
|
|||||||
}: AwsConnectStepProps) {
|
}: AwsConnectStepProps) {
|
||||||
// Local state needed: the access method only matters until the account is connected.
|
// Local state needed: the access method only matters until the account is connected.
|
||||||
const [method, setMethod] = useState<AwsAccessMethod>(initialMethod);
|
const [method, setMethod] = useState<AwsAccessMethod>(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<AwsConnectPanel>(initialPanel);
|
||||||
// Local state needed: the active form reports it so the method cannot change mid-submit.
|
// Local state needed: the active form reports it so the method cannot change mid-submit.
|
||||||
const [isBusy, setIsBusy] = useState(false);
|
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) => {
|
const chooseMethod = (next: AwsAccessMethod) => {
|
||||||
|
setPanel(AWS_CONNECT_PANEL.ACCESS);
|
||||||
setMethod(next);
|
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 (
|
return (
|
||||||
@@ -133,11 +175,11 @@ export function AwsConnectStep({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label="AWS access method"
|
aria-label="AWS connection option"
|
||||||
className="flex flex-col gap-3"
|
className="flex flex-col gap-3"
|
||||||
>
|
>
|
||||||
<p className="text-text-neutral-secondary text-sm">
|
<p className="text-text-neutral-secondary text-sm">
|
||||||
Choose how Prowler should access your account.
|
Choose how to connect this account.
|
||||||
</p>
|
</p>
|
||||||
<RadioCard
|
<RadioCard
|
||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
@@ -153,20 +195,40 @@ export function AwsConnectStep({
|
|||||||
<RadioCard
|
<RadioCard
|
||||||
icon={KeyRound}
|
icon={KeyRound}
|
||||||
title="Static access keys"
|
title="Static access keys"
|
||||||
selected={!isRole}
|
selected={isKeys}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => chooseMethod(AWS_ACCESS_METHOD.CREDENTIALS)}
|
onClick={() => chooseMethod(AWS_ACCESS_METHOD.CREDENTIALS)}
|
||||||
/>
|
/>
|
||||||
|
{canInvite && (
|
||||||
|
<RadioCard
|
||||||
|
icon={UserPlus}
|
||||||
|
title="I don't have access, invite a teammate"
|
||||||
|
selected={isInvite}
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={chooseInvite}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isRole ? (
|
{isInvite && (
|
||||||
|
<InviteTeammatePanel
|
||||||
|
providerType="aws"
|
||||||
|
formId={formId}
|
||||||
|
onUiStateChange={onUiStateChange}
|
||||||
|
onBusyChange={setIsBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRole && (
|
||||||
<AwsRoleConnectForm
|
<AwsRoleConnectForm
|
||||||
formId={formId}
|
formId={formId}
|
||||||
onConnected={onConnected}
|
onConnected={onConnected}
|
||||||
onBusyChange={setIsBusy}
|
onBusyChange={setIsBusy}
|
||||||
onUiStateChange={onUiStateChange}
|
onUiStateChange={onUiStateChange}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{isKeys && (
|
||||||
<AwsKeysConnectForm
|
<AwsKeysConnectForm
|
||||||
formId={formId}
|
formId={formId}
|
||||||
onConnected={onConnected}
|
onConnected={onConnected}
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ export const AWS_ACCESS_METHOD = {
|
|||||||
export type AwsAccessMethod =
|
export type AwsAccessMethod =
|
||||||
(typeof AWS_ACCESS_METHOD)[keyof typeof AWS_ACCESS_METHOD];
|
(typeof AWS_ACCESS_METHOD)[keyof typeof AWS_ACCESS_METHOD];
|
||||||
|
|
||||||
|
/** What the footer's main action does: submit the step's form, or close the wizard. */
|
||||||
|
export const AWS_CONNECT_ACTION_KIND = {
|
||||||
|
SUBMIT: "submit",
|
||||||
|
CLOSE: "close",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AwsConnectActionKind =
|
||||||
|
(typeof AWS_CONNECT_ACTION_KIND)[keyof typeof AWS_CONNECT_ACTION_KIND];
|
||||||
|
|
||||||
/** What the step publishes so the wizard can draw its footer. */
|
/** What the step publishes so the wizard can draw its footer. */
|
||||||
export interface AwsConnectUiState {
|
export interface AwsConnectUiState {
|
||||||
showBack: boolean;
|
showBack: boolean;
|
||||||
@@ -13,4 +22,6 @@ export interface AwsConnectUiState {
|
|||||||
actionLabel: string;
|
actionLabel: string;
|
||||||
actionDisabled: boolean;
|
actionDisabled: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
/** Absent means submit. */
|
||||||
|
actionKind?: AwsConnectActionKind;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
|
|||||||
import { ProviderType } from "@/types/providers";
|
import { ProviderType } from "@/types/providers";
|
||||||
|
|
||||||
import { AwsConnectStep } from "./aws/aws-connect-step";
|
import { AwsConnectStep } from "./aws/aws-connect-step";
|
||||||
|
import { AWS_CONNECT_ACTION_KIND, type AwsConnectUiState } from "./aws/types";
|
||||||
import {
|
import {
|
||||||
WIZARD_FOOTER_ACTION_TYPE,
|
WIZARD_FOOTER_ACTION_TYPE,
|
||||||
WizardFooterConfig,
|
WizardFooterConfig,
|
||||||
@@ -23,6 +24,8 @@ interface ConnectStepProps {
|
|||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
/** AWS registers, stores and tests the account in this step, so it skips ahead. */
|
/** AWS registers, stores and tests the account in this step, so it skips ahead. */
|
||||||
onCredentialsSaved: () => void;
|
onCredentialsSaved: () => void;
|
||||||
|
/** AWS offers it as the footer action once a teammate has been invited instead. */
|
||||||
|
onClose: () => void;
|
||||||
onSelectOrganizations: (orgType: OrgFlowType) => void;
|
onSelectOrganizations: (orgType: OrgFlowType) => void;
|
||||||
onFooterChange: (config: WizardFooterConfig) => void;
|
onFooterChange: (config: WizardFooterConfig) => void;
|
||||||
onProviderTypeChange: (providerType: ProviderType | null) => void;
|
onProviderTypeChange: (providerType: ProviderType | null) => void;
|
||||||
@@ -33,6 +36,7 @@ interface ConnectStepProps {
|
|||||||
export function ConnectStep({
|
export function ConnectStep({
|
||||||
onNext,
|
onNext,
|
||||||
onCredentialsSaved,
|
onCredentialsSaved,
|
||||||
|
onClose,
|
||||||
onSelectOrganizations,
|
onSelectOrganizations,
|
||||||
onFooterChange,
|
onFooterChange,
|
||||||
onProviderTypeChange,
|
onProviderTypeChange,
|
||||||
@@ -41,9 +45,13 @@ export function ConnectStep({
|
|||||||
const { setProvider, setVia, setSecretId, setMode } =
|
const { setProvider, setVia, setSecretId, setMode } =
|
||||||
useProviderWizardStore();
|
useProviderWizardStore();
|
||||||
const backHandlerRef = useRef<(() => void) | null>(null);
|
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.
|
// Local state needed: AWS swaps the generic account form for its one-step form.
|
||||||
const [isAwsFlow, setIsAwsFlow] = useState(initialProviderType === "aws");
|
const [isAwsFlow, setIsAwsFlow] = useState(initialProviderType === "aws");
|
||||||
const [uiState, setUiState] = useState({
|
const [uiState, setUiState] = useState<AwsConnectUiState>({
|
||||||
showBack: false,
|
showBack: false,
|
||||||
showAction: false,
|
showAction: false,
|
||||||
actionLabel: "Next",
|
actionLabel: "Next",
|
||||||
@@ -74,6 +82,8 @@ export function ConnectStep({
|
|||||||
if (uiState.showAction && !uiState.actionDisabled && !uiState.isLoading) {
|
if (uiState.showAction && !uiState.actionDisabled && !uiState.isLoading) {
|
||||||
endActiveTour();
|
endActiveTour();
|
||||||
}
|
}
|
||||||
|
// Nothing left to submit once a teammate has been invited: the action closes.
|
||||||
|
const closes = uiState.actionKind === AWS_CONNECT_ACTION_KIND.CLOSE;
|
||||||
onFooterChange({
|
onFooterChange({
|
||||||
showBack: uiState.showBack,
|
showBack: uiState.showBack,
|
||||||
backLabel: "Back",
|
backLabel: "Back",
|
||||||
@@ -86,8 +96,11 @@ export function ConnectStep({
|
|||||||
actionLabel: uiState.actionLabel,
|
actionLabel: uiState.actionLabel,
|
||||||
actionLoading: uiState.isLoading,
|
actionLoading: uiState.isLoading,
|
||||||
actionDisabled: uiState.actionDisabled || uiState.isLoading,
|
actionDisabled: uiState.actionDisabled || uiState.isLoading,
|
||||||
actionType: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
|
actionType: closes
|
||||||
actionFormId: formId,
|
? WIZARD_FOOTER_ACTION_TYPE.BUTTON
|
||||||
|
: WIZARD_FOOTER_ACTION_TYPE.SUBMIT,
|
||||||
|
actionFormId: closes ? undefined : formId,
|
||||||
|
onAction: closes ? () => closeHandlerRef.current() : undefined,
|
||||||
});
|
});
|
||||||
}, [isAwsFlow, onFooterChange, uiState]);
|
}, [isAwsFlow, onFooterChange, uiState]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Form {...form}>
|
||||||
|
<form id={formId} onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||||
|
<p className="text-text-neutral-secondary text-sm">
|
||||||
|
Invite someone from your team who can access the{" "}
|
||||||
|
{getProviderDisplayName(providerType)} account. They will join this
|
||||||
|
Prowler tenant and can connect it themselves.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<WizardInputField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
label="Teammate email"
|
||||||
|
labelPlacement="inside"
|
||||||
|
placeholder="name@company.com"
|
||||||
|
variant="bordered"
|
||||||
|
isRequired
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="roleId"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Select
|
||||||
|
value={field.value || undefined}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="Select a role">
|
||||||
|
<SelectValue placeholder="Select a role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<SelectItem key={role.id} value={role.id}>
|
||||||
|
{role.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-text-neutral-tertiary text-xs">
|
||||||
|
Pick a role that can manage providers, such as admin.
|
||||||
|
</p>
|
||||||
|
{fieldState.error && (
|
||||||
|
<p className="text-text-error text-sm">
|
||||||
|
{fieldState.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<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 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<AwsConnectUiState | null>(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 (
|
||||||
|
<>
|
||||||
|
<InviteTeammatePanel
|
||||||
|
providerType="aws"
|
||||||
|
formId={FORM_ID}
|
||||||
|
onUiStateChange={handleUiState}
|
||||||
|
onBusyChange={onBusyChange}
|
||||||
|
/>
|
||||||
|
{uiState?.showAction && (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form={FORM_ID}
|
||||||
|
disabled={uiState.actionDisabled || uiState.isLoading}
|
||||||
|
>
|
||||||
|
{uiState.actionLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
const onUiState = vi.fn();
|
||||||
|
const onBusyChange = vi.fn();
|
||||||
|
render(<Harness onUiState={onUiState} onBusyChange={onBusyChange} />);
|
||||||
|
return { onUiState, onBusyChange, user: userEvent.setup() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastUiState = (onUiState: ReturnType<typeof vi.fn>) =>
|
||||||
|
onUiState.mock.calls.at(-1)?.[0] as AwsConnectUiState;
|
||||||
|
|
||||||
|
async function fillAndSend(user: ReturnType<typeof userEvent.setup>) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<SentInvitation | null>(null);
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<InviteTeammateSent
|
||||||
|
invitation={sent}
|
||||||
|
providerType={providerType}
|
||||||
|
onUiStateChange={onUiStateChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roles === null) {
|
||||||
|
return <RolesLoading onUiStateChange={onUiStateChange} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roles.length === 0) {
|
||||||
|
return <RolesUnavailable onUiStateChange={onUiStateChange} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InviteTeammateForm
|
||||||
|
roles={orderRolesAdminFirst(roles)}
|
||||||
|
providerType={providerType}
|
||||||
|
formId={formId}
|
||||||
|
onSent={(invitation) => {
|
||||||
|
// 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 (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="text-text-neutral-secondary flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Loader2 aria-hidden className="size-4 animate-spin" />
|
||||||
|
Loading roles...
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RolesUnavailable({ onUiStateChange }: StaticStateProps) {
|
||||||
|
useMountEffect(() => {
|
||||||
|
onUiStateChange({
|
||||||
|
showBack: true,
|
||||||
|
showAction: false,
|
||||||
|
actionLabel: SEND_LABEL,
|
||||||
|
actionDisabled: true,
|
||||||
|
isLoading: false,
|
||||||
|
actionKind: AWS_CONNECT_ACTION_KIND.SUBMIT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section role="status" className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<CircleCheck
|
||||||
|
aria-hidden
|
||||||
|
className="text-text-success-primary size-5 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<h4 className="text-sm font-semibold break-words">
|
||||||
|
Invitation sent to {invitation.email}
|
||||||
|
</h4>
|
||||||
|
<p className="text-text-neutral-secondary text-sm">
|
||||||
|
{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:"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CodeSnippet value={link} className="max-w-full" />
|
||||||
|
|
||||||
|
<p className="text-text-neutral-secondary text-sm">
|
||||||
|
The link expires in 7 days. Once they accept, they can connect the{" "}
|
||||||
|
{getProviderDisplayName(providerType)} account from the Providers page.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof ROLES>((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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<InvitationRoleOption[] | null>(null);
|
||||||
|
|
||||||
|
useMountEffect(() => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
timer = setTimeout(() => settle([]), ROLES_TIMEOUT_MS);
|
||||||
|
getInvitationRoles()
|
||||||
|
.then(settle)
|
||||||
|
.catch(() => settle([]));
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ export type WizardOpenSource =
|
|||||||
export const PROVIDER_FUNNEL_METHOD = {
|
export const PROVIDER_FUNNEL_METHOD = {
|
||||||
SINGLE: "single",
|
SINGLE: "single",
|
||||||
ORGANIZATION: "organization",
|
ORGANIZATION: "organization",
|
||||||
|
// The user cannot connect the account and hands it to a teammate instead.
|
||||||
|
INVITE_TEAMMATE: "invite_teammate",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ProviderFunnelMethod =
|
export type ProviderFunnelMethod =
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
|||||||
import { createJSONStorage, persist } from "zustand/middleware";
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AWS_CONNECT_PANEL,
|
||||||
AwsConnectDraft,
|
AwsConnectDraft,
|
||||||
PROVIDER_WIZARD_MODE,
|
PROVIDER_WIZARD_MODE,
|
||||||
ProviderWizardIdentity,
|
ProviderWizardIdentity,
|
||||||
@@ -39,6 +40,7 @@ const initialState = {
|
|||||||
|
|
||||||
const EMPTY_AWS_CONNECT_DRAFT: AwsConnectDraft = {
|
const EMPTY_AWS_CONNECT_DRAFT: AwsConnectDraft = {
|
||||||
method: "role",
|
method: "role",
|
||||||
|
panel: AWS_CONNECT_PANEL.ACCESS,
|
||||||
roleValues: {},
|
roleValues: {},
|
||||||
keysValues: {},
|
keysValues: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -708,6 +708,46 @@ export class ProvidersPage extends BasePage {
|
|||||||
await this.selectProviderRadio(this.githubProviderRadio);
|
await this.selectProviderRadio(this.githubProviderRadio);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Offered on the AWS step to a user who can invite but cannot reach the account.
|
||||||
|
async selectAwsInviteTeammate(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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.
|
// AWS picks its access method on the same step that registers the account.
|
||||||
async selectAwsAccessMethod(type: AWSCredentialType): Promise<void> {
|
async selectAwsAccessMethod(type: AWSCredentialType): Promise<void> {
|
||||||
const name =
|
const name =
|
||||||
|
|||||||
@@ -1075,3 +1075,52 @@
|
|||||||
- Private Key is provided as base64-encoded PEM content and decoded before use (multi-line content)
|
- 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
|
- 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
|
- 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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from "@playwright/test";
|
import { test } from "@playwright/test";
|
||||||
|
|
||||||
import { isCloud } from "@/lib/shared/env";
|
import { isCloud } from "@/lib/shared/env";
|
||||||
|
import { makeSuffix } from "../helpers";
|
||||||
import {
|
import {
|
||||||
ProvidersPage,
|
ProvidersPage,
|
||||||
AWSProviderData,
|
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", () => {
|
test.describe.serial("Add AZURE Provider", () => {
|
||||||
// Providers page object
|
// Providers page object
|
||||||
let providersPage: ProvidersPage;
|
let providersPage: ProvidersPage;
|
||||||
|
|||||||
@@ -3,9 +3,18 @@ export const INVITATION_SOURCE_PARAM = "source";
|
|||||||
|
|
||||||
export const INVITATION_SOURCE = {
|
export const INVITATION_SOURCE = {
|
||||||
ONBOARDING: "onboarding",
|
ONBOARDING: "onboarding",
|
||||||
|
// Sent from the add-provider wizard by a user who cannot connect the account.
|
||||||
|
PROVIDER_CONNECT: "provider_connect",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export interface InvitationRoleOption {
|
export interface InvitationRoleOption {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,9 +27,19 @@ export interface ProviderWizardIdentity {
|
|||||||
|
|
||||||
export type AwsConnectDraftValues = Record<string, string>;
|
export type AwsConnectDraftValues = Record<string, string>;
|
||||||
|
|
||||||
|
/** 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. */
|
/** What the AWS connect step typed so far; in memory only, gone with the wizard. */
|
||||||
export interface AwsConnectDraft {
|
export interface AwsConnectDraft {
|
||||||
method: string;
|
method: string;
|
||||||
|
panel: AwsConnectPanel;
|
||||||
roleValues: AwsConnectDraftValues;
|
roleValues: AwsConnectDraftValues;
|
||||||
keysValues: AwsConnectDraftValues;
|
keysValues: AwsConnectDraftValues;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user