feat(ui): complete Registry provider onboarding for Private Cloud (#12494)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Alan Buscagliaandalejandrobailo authored and GitHub committed 2026-09-16 12:21:25 +02:00
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>
);
}