mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-08 20:33:41 +00:00
feat(ui): complete Registry provider onboarding for Private Cloud (#12494)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
1 parent
974f4251dd
commit
2198ba2d84
144 files changed
+16218
-311
No files matched your search
@@ -0,0 +1,84 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { addProvider, updateProvider, getInstalledRegistryProviderOptions } =
|
||||
vi.hoisted(() => ({
|
||||
addProvider: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
getInstalledRegistryProviderOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/actions/providers/providers", () => ({
|
||||
addProvider,
|
||||
updateProvider,
|
||||
}));
|
||||
vi.mock("@/actions/providers/registry-provider", () => ({
|
||||
addRegistryProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/actions/registry/registry", () => ({
|
||||
getInstalledRegistryProviderOptions,
|
||||
}));
|
||||
|
||||
import { ConnectAccountForm } from "./connect-account-form";
|
||||
|
||||
describe("provider account aliases", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getInstalledRegistryProviderOptions.mockResolvedValue({
|
||||
status: "access_denied",
|
||||
});
|
||||
});
|
||||
|
||||
it("saves an alias changed after an account was already created", async () => {
|
||||
// Given
|
||||
const onSuccess = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const account = {
|
||||
id: "existing",
|
||||
attributes: { provider: "github", uid: "octocat", alias: "Original" },
|
||||
};
|
||||
addProvider.mockResolvedValue({ data: account });
|
||||
updateProvider.mockResolvedValue({
|
||||
data: {
|
||||
...account,
|
||||
attributes: { ...account.attributes, alias: "Edited" },
|
||||
},
|
||||
});
|
||||
render(<ConnectAccountForm onSuccess={onSuccess} />);
|
||||
await user.click(screen.getByRole("option", { name: "GitHub" }));
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "Username/Organization" }),
|
||||
"octocat",
|
||||
);
|
||||
const alias = screen.getByRole("textbox", {
|
||||
name: "Provider alias (optional)",
|
||||
});
|
||||
await user.type(alias, "Original");
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledOnce());
|
||||
|
||||
// When
|
||||
await user.clear(alias);
|
||||
await user.type(alias, "Edited");
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(onSuccess).toHaveBeenLastCalledWith({
|
||||
id: "existing",
|
||||
providerType: "github",
|
||||
uid: "octocat",
|
||||
alias: "Edited",
|
||||
}),
|
||||
);
|
||||
expect(addProvider).toHaveBeenCalledOnce();
|
||||
expect(Object.fromEntries(updateProvider.mock.calls[0][0])).toMatchObject({
|
||||
providerId: "existing",
|
||||
providerAlias: "Edited",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,20 +3,25 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
|
||||
import { useForm, UseFormReturn } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { addProvider } from "@/actions/providers/providers";
|
||||
import { addProvider, updateProvider } from "@/actions/providers/providers";
|
||||
import { addRegistryProvider } from "@/actions/providers/registry-provider";
|
||||
import { getInstalledRegistryProviderOptions } from "@/actions/registry/registry";
|
||||
import { AwsMethodSelector } from "@/components/providers/organizations/aws-method-selector";
|
||||
import { AzureMethodSelector } from "@/components/providers/organizations/azure-method-selector";
|
||||
import { GcpMethodSelector } from "@/components/providers/organizations/gcp-method-selector";
|
||||
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
|
||||
import { ProviderTitleDocs } from "@/components/providers/workflow/provider-title-docs";
|
||||
import { Button, useToast } from "@/components/shadcn";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn/alert";
|
||||
import { Form } from "@/components/shadcn/form";
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
import type { RegistryProviderOption } from "@/lib/registry/provider-options";
|
||||
import {
|
||||
addProviderFormSchema,
|
||||
createAddProviderFormSchema,
|
||||
AddProviderFormValues,
|
||||
ApiError,
|
||||
KnownProviderType,
|
||||
ProviderType,
|
||||
@@ -26,10 +31,11 @@ import {
|
||||
OrgFlowType,
|
||||
toOrgFlowType,
|
||||
} from "@/types/organizations";
|
||||
import { isKnownProviderType } from "@/types/providers";
|
||||
|
||||
import { RadioGroupProvider } from "../../radio-group-provider";
|
||||
|
||||
export type FormValues = z.infer<typeof addProviderFormSchema>;
|
||||
export type FormValues = AddProviderFormValues;
|
||||
|
||||
export interface ConnectAccountSuccessData {
|
||||
id: string;
|
||||
@@ -209,7 +215,41 @@ export const ConnectAccountForm = ({
|
||||
const [method, setMethod] = useState<"single" | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const formSchema = addProviderFormSchema;
|
||||
const [registryOptions, setRegistryOptions] = useState<
|
||||
RegistryProviderOption[]
|
||||
>([]);
|
||||
const [registryError, setRegistryError] = useState(false);
|
||||
const [providerError, setProviderError] = useState<string | null>(null);
|
||||
const [discoveryAttempt, setDiscoveryAttempt] = useState(0);
|
||||
const submitting = useRef(false);
|
||||
const createdAccount = useRef<ConnectAccountSuccessData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const result = await getInstalledRegistryProviderOptions();
|
||||
if (!active) return;
|
||||
setRegistryOptions(result.status === "ready" ? result.options : []);
|
||||
setRegistryError(result.status === "error");
|
||||
} catch {
|
||||
if (active) {
|
||||
setRegistryOptions([]);
|
||||
setRegistryError(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
void load();
|
||||
window.addEventListener("registry-artifacts-changed", load);
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener("registry-artifacts-changed", load);
|
||||
};
|
||||
}, [discoveryAttempt]);
|
||||
|
||||
const formSchema = createAddProviderFormSchema(
|
||||
registryOptions.map((option) => option.type),
|
||||
);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -229,6 +269,22 @@ export const ConnectAccountForm = ({
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
|
||||
const onSubmitClient = async (values: FormValues) => {
|
||||
if (submitting.current) return;
|
||||
const existingAccount =
|
||||
createdAccount.current?.providerType === values.providerType &&
|
||||
createdAccount.current.uid === values.providerUid
|
||||
? createdAccount.current
|
||||
: null;
|
||||
if (
|
||||
existingAccount &&
|
||||
(existingAccount.alias ?? "") === (values.providerAlias?.trim() ?? "") &&
|
||||
onSuccess
|
||||
) {
|
||||
onSuccess(existingAccount);
|
||||
return;
|
||||
}
|
||||
submitting.current = true;
|
||||
setProviderError(null);
|
||||
const formValues = { ...values };
|
||||
|
||||
const formData = new FormData();
|
||||
@@ -237,7 +293,20 @@ export const ConnectAccountForm = ({
|
||||
);
|
||||
|
||||
try {
|
||||
const data = await addProvider(formData);
|
||||
let data;
|
||||
if (existingAccount) {
|
||||
const update = new FormData();
|
||||
update.set(ProviderCredentialFields.PROVIDER_ID, existingAccount.id);
|
||||
update.set(
|
||||
ProviderCredentialFields.PROVIDER_ALIAS,
|
||||
values.providerAlias?.trim() ?? "",
|
||||
);
|
||||
data = await updateProvider(update);
|
||||
} else {
|
||||
data = await (isKnownProviderType(values.providerType)
|
||||
? addProvider(formData)
|
||||
: addRegistryProvider(formData));
|
||||
}
|
||||
|
||||
if (data?.errors && data.errors.length > 0) {
|
||||
data.errors.forEach((error: ApiError) => {
|
||||
@@ -246,10 +315,9 @@ export const ConnectAccountForm = ({
|
||||
|
||||
switch (pointer) {
|
||||
case "/data/attributes/provider":
|
||||
form.setError("providerType", {
|
||||
type: "server",
|
||||
message: errorMessage,
|
||||
});
|
||||
// Provider selection is hidden here; keep failures visible and
|
||||
// retryable when availability changes without editing the form.
|
||||
setProviderError(errorMessage);
|
||||
break;
|
||||
case "/data/attributes/uid":
|
||||
case "/data/attributes/__all__":
|
||||
@@ -280,12 +348,13 @@ export const ConnectAccountForm = ({
|
||||
} = data.data;
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess({
|
||||
createdAccount.current = {
|
||||
id,
|
||||
providerType: createdProviderType,
|
||||
uid: uid || values.providerUid,
|
||||
alias: alias ?? values.providerAlias ?? null,
|
||||
});
|
||||
};
|
||||
onSuccess(createdAccount.current);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -301,10 +370,13 @@ export const ConnectAccountForm = ({
|
||||
? error.message
|
||||
: "Something went wrong. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackStep = () => {
|
||||
setProviderError(null);
|
||||
applyBackStep({
|
||||
prevStep,
|
||||
method,
|
||||
@@ -327,6 +399,7 @@ export const ConnectAccountForm = ({
|
||||
|
||||
useEffect(() => {
|
||||
onBackHandlerChange?.(() => {
|
||||
setProviderError(null);
|
||||
applyBackStep({
|
||||
prevStep,
|
||||
method,
|
||||
@@ -352,7 +425,7 @@ export const ConnectAccountForm = ({
|
||||
onUiStateChange?.({
|
||||
showBack: prevStep === 2,
|
||||
showAction: prevStep === 2 && showUidForm,
|
||||
actionLabel: "Next",
|
||||
actionLabel: isLoading ? "Creating provider..." : "Next",
|
||||
actionDisabled: !canSubmit || isLoading,
|
||||
isLoading,
|
||||
});
|
||||
@@ -375,7 +448,26 @@ export const ConnectAccountForm = ({
|
||||
{/* Step 1: Provider selection */}
|
||||
{prevStep === 1 && (
|
||||
<div data-tour-id="add-provider-provider-type">
|
||||
{registryError && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Registry providers could not be loaded</AlertTitle>
|
||||
<AlertDescription>
|
||||
Built-in providers are available. Check the Registry
|
||||
connection and try again.
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setDiscoveryAttempt((attempt) => attempt + 1)
|
||||
}
|
||||
>
|
||||
Retry Registry providers
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<RadioGroupProvider
|
||||
registryOptions={registryOptions}
|
||||
control={form.control}
|
||||
isInvalid={!!form.formState.errors.providerType}
|
||||
errorMessage={form.formState.errors.providerType?.message}
|
||||
@@ -423,6 +515,12 @@ export const ConnectAccountForm = ({
|
||||
{prevStep === 2 && showUidForm && (
|
||||
<>
|
||||
<ProviderTitleDocs providerType={providerType} />
|
||||
{providerError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Unable to create provider</AlertTitle>
|
||||
<AlertDescription>{providerError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<WizardInputField
|
||||
control={form.control}
|
||||
name="providerUid"
|
||||
@@ -465,13 +563,14 @@ export const ConnectAccountForm = ({
|
||||
variant="default"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
aria-busy={isLoading || undefined}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
<Loader2 aria-hidden className="animate-spin" />
|
||||
) : (
|
||||
<ChevronRightIcon size={24} />
|
||||
)}
|
||||
{isLoading ? "Loading" : "Next"}
|
||||
{isLoading ? "Creating provider..." : "Next"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { RegistryCredentialSchema } from "@/lib/provider-credentials/provider-credential-schema";
|
||||
|
||||
import { RegistryCredentialFields } from "./provider-credential-fields";
|
||||
|
||||
const schema: RegistryCredentialSchema = {
|
||||
fields: [
|
||||
{
|
||||
name: "api_key",
|
||||
label: "API Key",
|
||||
description: "Issued from the console.",
|
||||
kind: "password",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "scheme",
|
||||
label: "Scheme",
|
||||
kind: "select",
|
||||
options: ["bearer", "basic"],
|
||||
required: false,
|
||||
},
|
||||
{ name: "notes", label: "Notes", kind: "textarea", required: false },
|
||||
],
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
for (const name of [
|
||||
"hasPointerCapture",
|
||||
"releasePointerCapture",
|
||||
"scrollIntoView",
|
||||
]) {
|
||||
Object.defineProperty(HTMLElement.prototype, name, {
|
||||
configurable: true,
|
||||
value: () => false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("RegistryCredentialFields", () => {
|
||||
it("renders accessible controlled credential fields and emits changes", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<RegistryCredentialFields
|
||||
errors={{ api_key: "A key is required." }}
|
||||
onChange={onChange}
|
||||
schema={schema}
|
||||
values={{ api_key: "", scheme: "bearer", notes: "" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.type(screen.getByLabelText(/API Key/), "x");
|
||||
await user.click(screen.getByRole("combobox", { name: "Scheme" }));
|
||||
await user.keyboard("{ArrowDown}{Enter}");
|
||||
|
||||
// Then
|
||||
const apiKey = screen.getByLabelText(/API Key/);
|
||||
const description = screen.getByText("Issued from the console.");
|
||||
const error = screen.getByRole("alert");
|
||||
expect(apiKey).toHaveAttribute("type", "password");
|
||||
expect(apiKey).toHaveAttribute("autocomplete", "new-password");
|
||||
|
||||
expect(apiKey).toHaveAttribute(
|
||||
"aria-describedby",
|
||||
`${description.id} ${error.id}`,
|
||||
);
|
||||
expect(apiKey.id).toMatch(/-0-control$/);
|
||||
expect(apiKey).toHaveAttribute("aria-invalid", "true");
|
||||
expect(apiKey).toBeRequired();
|
||||
expect(description.id).toMatch(/-0-description$/);
|
||||
expect(error).toHaveTextContent("A key is required.");
|
||||
expect(error.id).toMatch(/-0-error$/);
|
||||
expect(onChange).toHaveBeenCalledWith("api_key", "x");
|
||||
expect(onChange).toHaveBeenCalledWith("scheme", "basic");
|
||||
});
|
||||
|
||||
it("uses unique index-based IDs for hostile field names and instances", () => {
|
||||
// Given
|
||||
|
||||
const hostileSchema: RegistryCredentialSchema = {
|
||||
fields: [
|
||||
{
|
||||
name: "x-description",
|
||||
label: "First",
|
||||
kind: "text",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "registry-credential-x",
|
||||
label: "Second",
|
||||
description: "Second description.",
|
||||
kind: "text",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<>
|
||||
<RegistryCredentialFields
|
||||
errors={{}}
|
||||
onChange={vi.fn()}
|
||||
schema={hostileSchema}
|
||||
values={{}}
|
||||
/>
|
||||
<RegistryCredentialFields
|
||||
errors={{}}
|
||||
onChange={vi.fn()}
|
||||
schema={schema}
|
||||
values={{}}
|
||||
/>
|
||||
<RegistryCredentialFields
|
||||
errors={{}}
|
||||
onChange={vi.fn()}
|
||||
schema={schema}
|
||||
values={{}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
// When / Then
|
||||
expect(screen.getByLabelText("First").id).toMatch(/-0-control$/);
|
||||
|
||||
expect(screen.getByText("Second description.").id).toMatch(
|
||||
/-1-description$/,
|
||||
);
|
||||
const ids = Array.from(container.querySelectorAll("[id]"), ({ id }) => id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { type ChangeEvent, useId } from "react";
|
||||
|
||||
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
|
||||
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
|
||||
import { Input } from "@/components/shadcn/input/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { Textarea } from "@/components/shadcn/textarea/textarea";
|
||||
import type {
|
||||
RegistryCredentialSchema,
|
||||
RegistryCredentialValue,
|
||||
} from "@/lib/provider-credentials/provider-credential-schema";
|
||||
|
||||
interface RegistryCredentialFieldsProps {
|
||||
readonly errors: Readonly<Record<string, string | undefined>>;
|
||||
readonly onChange: (name: string, value: RegistryCredentialValue) => void;
|
||||
readonly schema: RegistryCredentialSchema;
|
||||
readonly values: Readonly<
|
||||
Record<string, RegistryCredentialValue | undefined>
|
||||
>;
|
||||
}
|
||||
|
||||
export function RegistryCredentialFields({
|
||||
errors,
|
||||
onChange,
|
||||
schema,
|
||||
values,
|
||||
}: RegistryCredentialFieldsProps) {
|
||||
const instanceId = useId();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{schema.fields.map((field, index) => {
|
||||
const error = errors[field.name];
|
||||
const fieldId = `registry-credential-${instanceId}-${index}`;
|
||||
const id = `${fieldId}-control`;
|
||||
const descriptionId = field.description
|
||||
? `${fieldId}-description`
|
||||
: undefined;
|
||||
const errorId = error ? `${fieldId}-error` : undefined;
|
||||
const describedBy =
|
||||
[descriptionId, errorId].filter(Boolean).join(" ") || undefined;
|
||||
const invalid = error ? true : undefined;
|
||||
const value = values[field.name];
|
||||
const textControlProps = {
|
||||
"aria-describedby": describedBy,
|
||||
"aria-invalid": invalid,
|
||||
id,
|
||||
|
||||
onChange: (
|
||||
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => onChange(field.name, event.target.value),
|
||||
required: field.required,
|
||||
placeholder: field.placeholder,
|
||||
spellCheck: false,
|
||||
value:
|
||||
typeof value === "string" || typeof value === "number" ? value : "",
|
||||
};
|
||||
|
||||
return (
|
||||
<Field key={field.name}>
|
||||
{field.kind === "checkbox" ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
aria-required={field.required}
|
||||
checked={value === true}
|
||||
id={id}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange(field.name, checked === true)
|
||||
}
|
||||
/>
|
||||
<FieldLabel htmlFor={id}>
|
||||
{field.label}
|
||||
{field.required && <span aria-hidden="true"> *</span>}
|
||||
</FieldLabel>
|
||||
</div>
|
||||
) : (
|
||||
<FieldLabel htmlFor={id}>
|
||||
{field.label}
|
||||
{field.required && <span aria-hidden="true"> *</span>}
|
||||
</FieldLabel>
|
||||
)}
|
||||
{field.kind === "checkbox" ? null : field.kind === "select" ? (
|
||||
<Select
|
||||
onValueChange={(nextValue) => onChange(field.name, nextValue)}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-describedby={describedBy}
|
||||
aria-invalid={invalid}
|
||||
aria-label={field.label}
|
||||
aria-required={field.required}
|
||||
id={id}
|
||||
>
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.kind === "textarea" ? (
|
||||
<Textarea autoComplete="off" {...textControlProps} />
|
||||
) : (
|
||||
<Input
|
||||
autoComplete={
|
||||
field.kind === "password" ? "new-password" : "off"
|
||||
}
|
||||
type={
|
||||
field.kind === "integer"
|
||||
? "number"
|
||||
: field.kind === "password"
|
||||
? "password"
|
||||
: "text"
|
||||
}
|
||||
min={field.minimum}
|
||||
max={field.maximum}
|
||||
step={field.kind === "integer" ? 1 : undefined}
|
||||
{...textControlProps}
|
||||
/>
|
||||
)}
|
||||
{field.description && (
|
||||
<p
|
||||
className="text-text-neutral-secondary text-sm"
|
||||
id={descriptionId}
|
||||
>
|
||||
{field.description}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<FieldError id={errorId} role="alert">
|
||||
{error}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user