refactor(ui): gap closure — extract form field groups from bloated RHF dialogs

Split 4 RHF form dialogs that grew after Phase 3 Zod refactoring:
- channel-instance-form-dialog: 445 → 292 (+ form-step 196)
- cli-credential-form-dialog: 417 → 277 (+ binary-fields 151, scope-fields 72)
- mcp-form-dialog: 359 → 209 (+ connection-fields 100, settings-fields 95)
- agent-create-dialog: 351 → 165 (+ identity-model-fields 166, description 90)

Skipped: heartbeat-config (already decomposed), provider-form (no 50+ line boundary).

Files >300 lines: 20 → 16. Build passes.
This commit is contained in:
viettranx committed 2026-04-03 23:26:26 +07:00
1 parent e962a8ec7a
commit f6bca7003a
12 files changed
+985 -744

No files matched your search

+29 -215
View File
@@ -1,8 +1,7 @@
import { useMemo, useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import { useMemo, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import { ChevronRight } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -11,26 +10,14 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Combobox } from "@/components/ui/combobox";
import type { AgentData } from "@/types/agent";
import { slugify } from "@/lib/slug";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
import { useAgentPresets } from "./agent-presets";
import { agentCreateSchema, type AgentCreateFormData } from "@/schemas/agent.schema";
import { useState } from "react";
import { AgentIdentityAndModelFields } from "./agent-identity-and-model-fields";
import { AgentDescriptionSection } from "./agent-description-section";
interface AgentCreateDialogProps {
open: boolean;
@@ -60,14 +47,15 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
},
});
const { register, control, handleSubmit, watch, setValue, reset, formState: { errors } } = form;
const { handleSubmit, watch, setValue, reset, formState: { errors } } = form;
const provider = watch("provider");
const model = watch("model");
const agentType = watch("agentType");
const agentKey = watch("agentKey");
const displayName = watch("displayName");
const enabledProviders = providers.filter((p) => p.enabled);
const selectedProvider = useMemo(
() => enabledProviders.find((p) => p.name === provider),
[enabledProviders, provider],
@@ -76,12 +64,8 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId);
const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
// Reset verification when provider or model changes
useEffect(() => {
resetVerify();
}, [provider, model, resetVerify]);
useEffect(() => { resetVerify(); }, [provider, model, resetVerify]);
// Reset form when dialog closes
useEffect(() => {
if (!open) {
reset();
@@ -95,12 +79,6 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
await verify(selectedProviderId, model.trim());
};
const handleVerifyAndCreate = async () => {
if (!selectedProviderId || !model.trim()) return;
const res = await verify(selectedProviderId, model.trim());
if (res?.valid) await handleSubmitForm(form.getValues());
};
const handleSubmitForm = async (data: AgentCreateFormData) => {
setLoading(true);
setSubmitError("");
@@ -125,15 +103,17 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
}
};
const handleVerifyAndCreate = async () => {
if (!selectedProviderId || !model.trim()) return;
const res = await verify(selectedProviderId, model.trim());
if (res?.valid) await handleSubmitForm(form.getValues());
};
const handleProviderChange = (value: string) => {
setValue("provider", value, { shouldValidate: true });
setValue("model", "", { shouldValidate: false });
};
const displayName = watch("displayName");
const agentKey = watch("agentKey");
// Derived submit button state
const canCreate = !!agentKey && !!displayName && !!provider && !!model &&
!errors.agentKey && !errors.displayName &&
(agentType !== "predefined" || !!watch("description")?.trim());
@@ -144,185 +124,22 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
<DialogHeader>
<DialogTitle>{t("create.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="displayName">{t("create.displayName")}</Label>
<div className="flex gap-2">
<Input
id="emoji"
{...register("emoji")}
placeholder="🤖"
className="w-14 shrink-0 text-center text-lg"
maxLength={2}
title={t("create.emojiHint")}
/>
<Input
id="displayName"
{...register("displayName")}
onBlur={(e) => {
register("displayName").onBlur(e);
const name = e.target.value.trim();
if (name && !form.getFieldState("agentKey").isDirty) {
setValue("agentKey", slugify(name), { shouldValidate: true });
}
}}
placeholder={t("create.displayNamePlaceholder")}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="agentKey">{t("create.agentKey")}</Label>
<Input
id="agentKey"
{...register("agentKey")}
onBlur={(e) => {
setValue("agentKey", slugify(e.target.value), { shouldValidate: true });
}}
placeholder={t("create.agentKeyPlaceholder")}
/>
{errors.agentKey ? (
<p className="text-xs text-destructive">{errors.agentKey.message}</p>
) : (
<p className="text-xs text-muted-foreground">{t("create.agentKeyHint")}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t("create.provider")}</Label>
{enabledProviders.length > 0 ? (
<Controller
control={control}
name="provider"
render={({ field }) => (
<Select value={field.value} onValueChange={handleProviderChange}>
<SelectTrigger>
<SelectValue placeholder={t("create.selectProvider")} />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.name} value={p.name}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
) : (
<Input
{...register("provider")}
placeholder="openrouter"
/>
)}
</div>
<div className="space-y-2">
<Label>{t("create.model")}</Label>
<div className="flex gap-2">
<div className="flex-1">
<Controller
control={control}
name="model"
render={({ field }) => (
<Combobox
value={field.value}
onChange={(v) => setValue("model", v, { shouldValidate: true })}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? t("create.loadingModels") : t("create.enterOrSelectModel")}
/>
)}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 px-3"
disabled={!selectedProviderId || !model.trim() || verifying}
onClick={handleVerify}
>
{verifying ? "..." : t("create.check")}
</Button>
</div>
{verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
{verifyResult.valid ? t("create.modelVerified") : verifyResult.error || t("create.verificationFailed")}
</p>
)}
{!verifyResult && provider && !modelsLoading && models.length === 0 && (
<p className="text-xs text-muted-foreground">{t("create.noModelsHint")}</p>
)}
</div>
</div>
{agentType === "predefined" ? (
<div className="space-y-3">
<Label>{t("create.describeAgent")}</Label>
<div className="flex flex-wrap gap-1.5">
{agentPresets.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setValue("description", preset.prompt, { shouldValidate: true })}
className="rounded-full border px-2.5 py-0.5 text-xs transition-colors hover:bg-accent"
>
{preset.label}
</button>
))}
</div>
<Textarea
{...register("description")}
placeholder={t("create.descriptionPlaceholder")}
className="min-h-[120px]"
/>
<p className="text-xs text-muted-foreground">
{t("create.descriptionHint")}
</p>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="space-y-0.5">
<Label htmlFor="create-self-evolve" className="text-sm font-normal">{t("create.selfEvolution")}</Label>
<p className="text-xs text-muted-foreground">{t("create.selfEvolutionHint")}</p>
</div>
<Controller
control={control}
name="selfEvolve"
render={({ field }) => (
<Switch id="create-self-evolve" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
</div>
</div>
) : (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2.5 space-y-2">
<p className="text-xs text-amber-700 dark:text-amber-400">
{t("create.openWarning")}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setValue("agentType", "predefined")}
>
{t("create.switchToPredefined")}
</Button>
</div>
)}
{/* Collapsible toggle for Open agent type */}
<button
type="button"
onClick={() => setValue("agentType", agentType === "open" ? "predefined" : "open")}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={`h-3 w-3 transition-transform ${agentType === "open" ? "rotate-90" : ""}`} />
{t("create.useOpenAgent")}
</button>
{submitError && (
<p className="text-sm text-destructive">{submitError}</p>
)}
<AgentIdentityAndModelFields
form={form}
enabledProviders={enabledProviders}
models={models}
modelsLoading={modelsLoading}
verifying={verifying}
verifyResult={verifyResult}
onProviderChange={handleProviderChange}
onVerify={handleVerify}
/>
<AgentDescriptionSection form={form} agentPresets={agentPresets} />
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
{t("create.cancel")}
@@ -330,10 +147,7 @@ export function AgentCreateDialog({ open, onOpenChange, onCreate }: AgentCreateD
{loading ? (
<Button disabled>{t("create.creating")}</Button>
) : !verifyResult?.valid && selectedProviderId && model.trim() ? (
<Button
onClick={handleVerifyAndCreate}
disabled={verifying || !canCreate}
>
<Button onClick={handleVerifyAndCreate} disabled={verifying || !canCreate}>
{verifying ? t("create.checking") : t("create.checkAndCreate")}
</Button>
) : (
@@ -0,0 +1,90 @@
import { Controller } from "react-hook-form";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { ChevronRight } from "lucide-react";
import type { AgentCreateFormData } from "@/schemas/agent.schema";
import type { AgentPreset } from "./agent-presets";
interface AgentDescriptionSectionProps {
form: UseFormReturn<AgentCreateFormData>;
agentPresets: AgentPreset[];
}
/**
* Renders the agent type toggle, description textarea with presets,
* and self-evolution switch for predefined agents.
*/
export function AgentDescriptionSection({ form, agentPresets }: AgentDescriptionSectionProps) {
const { t } = useTranslation("agents");
const { register, control, watch, setValue } = form;
const agentType = watch("agentType");
return (
<>
{agentType === "predefined" ? (
<div className="space-y-3">
<Label>{t("create.describeAgent")}</Label>
<div className="flex flex-wrap gap-1.5">
{agentPresets.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setValue("description", preset.prompt, { shouldValidate: true })}
className="rounded-full border px-2.5 py-0.5 text-xs transition-colors hover:bg-accent"
>
{preset.label}
</button>
))}
</div>
<Textarea
{...register("description")}
placeholder={t("create.descriptionPlaceholder")}
className="min-h-[120px]"
/>
<p className="text-xs text-muted-foreground">{t("create.descriptionHint")}</p>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
<div className="space-y-0.5">
<Label htmlFor="create-self-evolve" className="text-sm font-normal">
{t("create.selfEvolution")}
</Label>
<p className="text-xs text-muted-foreground">{t("create.selfEvolutionHint")}</p>
</div>
<Controller
control={control}
name="selfEvolve"
render={({ field }) => (
<Switch id="create-self-evolve" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
</div>
</div>
) : (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2.5 space-y-2">
<p className="text-xs text-amber-700 dark:text-amber-400">{t("create.openWarning")}</p>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setValue("agentType", "predefined")}
>
{t("create.switchToPredefined")}
</Button>
</div>
)}
<button
type="button"
onClick={() => setValue("agentType", agentType === "open" ? "predefined" : "open")}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={`h-3 w-3 transition-transform ${agentType === "open" ? "rotate-90" : ""}`} />
{t("create.useOpenAgent")}
</button>
</>
);
}
@@ -0,0 +1,166 @@
import { Controller } from "react-hook-form";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { slugify } from "@/lib/slug";
import type { AgentCreateFormData } from "@/schemas/agent.schema";
import type { ProviderData } from "@/pages/providers/hooks/use-providers";
import type { ModelInfo } from "@/pages/providers/hooks/use-provider-models";
interface AgentIdentityAndModelFieldsProps {
form: UseFormReturn<AgentCreateFormData>;
enabledProviders: ProviderData[];
models: ModelInfo[];
modelsLoading: boolean;
verifying: boolean;
verifyResult: { valid: boolean; error?: string } | null;
onProviderChange: (value: string) => void;
onVerify: () => void;
}
/**
* Renders agent identity fields (emoji, displayName, agentKey) and
* provider/model selector with inline verify button.
*/
export function AgentIdentityAndModelFields({
form,
enabledProviders,
models,
modelsLoading,
verifying,
verifyResult,
onProviderChange,
onVerify,
}: AgentIdentityAndModelFieldsProps) {
const { t } = useTranslation("agents");
const { register, control, watch, setValue, formState: { errors } } = form;
const provider = watch("provider");
const model = watch("model");
return (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="displayName">{t("create.displayName")}</Label>
<div className="flex gap-2">
<Input
id="emoji"
{...register("emoji")}
placeholder="🤖"
className="w-14 shrink-0 text-center text-lg"
maxLength={2}
title={t("create.emojiHint")}
/>
<Input
id="displayName"
{...register("displayName")}
onBlur={(e) => {
register("displayName").onBlur(e);
const name = e.target.value.trim();
if (name && !form.getFieldState("agentKey").isDirty) {
setValue("agentKey", slugify(name), { shouldValidate: true });
}
}}
placeholder={t("create.displayNamePlaceholder")}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="agentKey">{t("create.agentKey")}</Label>
<Input
id="agentKey"
{...register("agentKey")}
onBlur={(e) => {
setValue("agentKey", slugify(e.target.value), { shouldValidate: true });
}}
placeholder={t("create.agentKeyPlaceholder")}
/>
{errors.agentKey ? (
<p className="text-xs text-destructive">{errors.agentKey.message}</p>
) : (
<p className="text-xs text-muted-foreground">{t("create.agentKeyHint")}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t("create.provider")}</Label>
{enabledProviders.length > 0 ? (
<Controller
control={control}
name="provider"
render={({ field }) => (
<Select value={field.value} onValueChange={onProviderChange}>
<SelectTrigger>
<SelectValue placeholder={t("create.selectProvider")} />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.name} value={p.name}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
) : (
<Input {...register("provider")} placeholder="openrouter" />
)}
</div>
<div className="space-y-2">
<Label>{t("create.model")}</Label>
<div className="flex gap-2">
<div className="flex-1">
<Controller
control={control}
name="model"
render={({ field }) => (
<Combobox
value={field.value}
onChange={(v) => setValue("model", v, { shouldValidate: true })}
options={models.map((m) => ({ value: m.id, label: m.name ?? m.id }))}
placeholder={modelsLoading ? t("create.loadingModels") : t("create.enterOrSelectModel")}
/>
)}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 px-3"
disabled={!provider || !model.trim() || verifying}
onClick={onVerify}
>
{verifying ? "..." : t("create.check")}
</Button>
</div>
{verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
{verifyResult.valid
? t("create.modelVerified")
: verifyResult.error || t("create.verificationFailed")}
</p>
)}
{!verifyResult && provider && !modelsLoading && models.length === 0 && (
<p className="text-xs text-muted-foreground">{t("create.noModelsHint")}</p>
)}
</div>
</div>
</>
);
}
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { useForm, Controller } from "react-hook-form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import {
@@ -10,26 +10,13 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ChannelInstanceData, ChannelInstanceInput } from "./hooks/use-channel-instances";
import type { AgentData } from "@/types/agent";
import { slugify } from "@/lib/slug";
import { credentialsSchema, configSchema, wizardConfig, type FieldDef } from "./channel-schemas";
import { ChannelFields } from "./channel-fields";
import { ChannelScopesInfo } from "./channel-scopes-info";
import { wizardAuthSteps, wizardConfigSteps, wizardEditConfigs } from "./channel-wizard-registry";
import { TelegramGroupOverrides } from "./telegram-group-overrides";
import { wizardAuthSteps, wizardConfigSteps } from "./channel-wizard-registry";
import { CHANNEL_TYPES } from "@/constants/channels";
import { channelInstanceSchema, type ChannelInstanceFormData } from "@/schemas/channel.schema";
import { ChannelInstanceFormStep } from "./channel-instance-form-step";
type WizardStep = "form" | "auth" | "config";
@@ -52,7 +39,6 @@ export function ChannelInstanceFormDialog({
}: ChannelInstanceFormDialogProps) {
const { t } = useTranslation("channels");
// Non-form state (dynamic maps + wizard flow)
const [credsValues, setCredsValues] = useState<Record<string, unknown>>({});
const [configValues, setConfigValues] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(false);
@@ -78,7 +64,6 @@ export function ChannelInstanceFormDialog({
const hasWizard = !instance && !!wizard;
const channelLabel = CHANNEL_TYPES.find((ct) => ct.value === channelType)?.label ?? channelType;
// Step navigation
const totalSteps = hasWizard ? 1 + wizard!.steps.length : 1;
const currentStepNum = step === "form" ? 1 : (wizard?.steps.indexOf(step as "auth" | "config") ?? 0) + 2;
@@ -100,7 +85,6 @@ export function ChannelInstanceFormDialog({
});
setCredsValues({});
// Merge schema defaults into config so select fields persist their defaults.
const ct = instance?.channel_type ?? "telegram";
const schema = configSchema[ct] ?? [];
const defaults: Record<string, unknown> = {};
@@ -108,9 +92,8 @@ export function ChannelInstanceFormDialog({
if (f.defaultValue !== undefined) defaults[f.key] = f.defaultValue;
}
const merged: Record<string, unknown> = { ...defaults, ...(instance?.config ?? {}) };
// Convert boolean values to strings for select fields that use "true"/"false" options
const boolSelectKeys = new Set(
schema.filter((f) => f.type === "select" && f.options?.some((o) => o.value === "true")).map((f) => f.key),
schema.filter((f: FieldDef) => f.type === "select" && f.options?.some((o) => o.value === "true")).map((f: FieldDef) => f.key),
);
for (const key of boolSelectKeys) {
if (typeof merged[key] === "boolean") merged[key] = String(merged[key]);
@@ -124,7 +107,6 @@ export function ChannelInstanceFormDialog({
}
}, [open, instance, agents, form]);
// Auto-advance from auth to next step on completion
useEffect(() => {
if (step !== "auth" || !authCompleted) return;
const next = getNextWizardStep("auth");
@@ -143,8 +125,6 @@ export function ChannelInstanceFormDialog({
setConfigValues((prev) => ({ ...prev, [key]: value }));
}, []);
// Convert select fields with "true"/"false"/"inherit" values to proper JSON types.
// "inherit" → remove key (nil on Go side), "true"/"false" → boolean.
const coerceBoolSelects = (cfg: Record<string, unknown>, schema: FieldDef[]) => {
const boolSelectKeys = new Set(
schema.filter((f) => f.type === "select" && f.options?.some((o) => o.value === "true")).map((f) => f.key),
@@ -153,16 +133,16 @@ export function ChannelInstanceFormDialog({
const v = cfg[key];
if (v === "true") cfg[key] = true;
else if (v === "false") cfg[key] = false;
else delete cfg[key]; // "inherit" or unset
else delete cfg[key];
}
};
const handleSubmit = form.handleSubmit(async (values) => {
if (!instance) {
const schema = credentialsSchema[values.channelType] ?? [];
const missing = schema.filter((f) => f.required && !credsValues[f.key]);
const missing = schema.filter((f: FieldDef) => f.required && !credsValues[f.key]);
if (missing.length > 0) {
setError(t("form.errors.requiredFields", { fields: missing.map((f) => f.label).join(", ") }));
setError(t("form.errors.requiredFields", { fields: missing.map((f: FieldDef) => f.label).join(", ") }));
return;
}
}
@@ -234,15 +214,14 @@ export function ChannelInstanceFormDialog({
};
const canClose = step !== "auth";
const credsFields = credentialsSchema[channelType] ?? [];
const excludeSet = new Set(wizard?.excludeConfigFields ?? []);
const cfgFields = configSchema[channelType] ?? [];
const formCfgFields = excludeSet.size > 0 ? cfgFields.filter((f) => !excludeSet.has(f.key)) : cfgFields;
// Lookup registered step components for current channel type
const AuthStep = wizardAuthSteps[channelType];
const ConfigStep = wizardConfigSteps[channelType];
const EditConfig = wizardEditConfigs[channelType];
const submitLabel = loading
? t("form.saving")
: instance
? t("form.update")
: (wizard?.createLabel ? t(wizard.createLabel) : t("form.create"));
const dialogTitle = instance
? t("form.editTitle")
@@ -252,8 +231,6 @@ export function ChannelInstanceFormDialog({
? t("form.authenticate", { label: channelLabel })
: t("form.configure", { label: channelLabel });
const { register, control, formState: { errors } } = form;
return (
<Dialog open={open} onOpenChange={(v) => { if (!loading && canClose) onOpenChange(v); }}>
<DialogContent className="max-h-[85vh] flex flex-col">
@@ -266,153 +243,24 @@ export function ChannelInstanceFormDialog({
)}
</DialogHeader>
{/* === FORM STEP === */}
{step === "form" && (
<>
<div className="grid gap-4 py-2 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
<div className="grid gap-1.5">
<Label htmlFor="ci-name">{t("form.key")}</Label>
<Input
id="ci-name"
{...register("name", {
setValueAs: (v: string) => slugify(v),
})}
onChange={(e) => form.setValue("name", slugify(e.target.value), { shouldValidate: true })}
value={form.watch("name")}
placeholder={t("form.keyPlaceholder")}
disabled={!!instance}
/>
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
<p className="text-xs text-muted-foreground">{t("form.keyHint")}</p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ci-display">{t("form.displayName")}</Label>
<Input
id="ci-display"
{...register("displayName")}
placeholder={t("form.displayNamePlaceholder")}
/>
</div>
<div className="grid gap-1.5">
<Label>{t("form.channelType")}</Label>
<Controller
control={control}
name="channelType"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} disabled={!!instance}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CHANNEL_TYPES.map((ct) => (
<SelectItem key={ct.value} value={ct.value}>{ct.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="grid gap-1.5">
<Label>{t("form.agent")}</Label>
<Controller
control={control}
name="agentId"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue placeholder={t("form.selectAgent")} /></SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.display_name || a.agent_key}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.agentId && <p className="text-xs text-destructive">{errors.agentId.message}</p>}
</div>
{credsFields.length > 0 && (
<fieldset className="rounded-md border p-3 space-y-3">
<legend className="px-1 text-sm font-medium">
{t("form.credentials")}
{instance && <span className="text-xs font-normal text-muted-foreground ml-1">{t("form.credentialsHint")}</span>}
</legend>
<ChannelFields fields={credsFields} values={credsValues} onChange={handleCredsChange} idPrefix="ci-cred" isEdit={!!instance} contextValues={configValues} />
<p className="text-xs text-muted-foreground">{t("form.credentialsEncrypted")}</p>
</fieldset>
)}
<ChannelScopesInfo channelType={channelType} />
{/* Auth status indicator (edit mode, channels with auth wizard step) */}
{instance && wizard?.steps.includes("auth") && (
<div className="rounded-md border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950 p-3">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${instance.has_credentials ? "bg-green-500" : "bg-amber-500"}`} />
<span className="text-sm">
{instance.has_credentials
? t("form.authStatus.authenticated")
: t("form.authStatus.notAuthenticated")}
</span>
{!instance.has_credentials && (
<span className="text-xs text-muted-foreground ml-1">{t("form.authStatus.useQrHint")}</span>
)}
</div>
</div>
)}
{/* Wizard info banner (create mode) */}
{hasWizard && wizard?.formBanner && (
<div className="rounded-md border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950 p-3">
<p className="text-sm text-muted-foreground">{t(wizard.formBanner)}</p>
</div>
)}
{formCfgFields.length > 0 && (
<fieldset className="rounded-md border p-3 space-y-3">
<legend className="px-1 text-sm font-medium">{t("form.configuration")}</legend>
<ChannelFields fields={formCfgFields} values={configValues} onChange={handleConfigChange} idPrefix="ci-cfg" />
{instance && EditConfig && <EditConfig instance={instance} configValues={configValues} onConfigChange={handleConfigChange} />}
</fieldset>
)}
{/* Telegram group/topic overrides */}
{channelType === "telegram" && (
<TelegramGroupOverrides
groups={(configValues.groups as Record<string, Record<string, unknown>>) ?? {}}
onChange={(groups) => {
setConfigValues((prev) => ({
...prev,
groups: Object.keys(groups).length > 0 ? groups : undefined,
}));
}}
/>
)}
<div className="flex items-center gap-2">
<Controller
control={control}
name="enabled"
render={({ field }) => (
<Switch id="ci-enabled" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
<Label htmlFor="ci-enabled">{t("form.enabled")}</Label>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>{t("form.cancel")}</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? t("form.saving") : instance ? t("form.update") : (wizard?.createLabel ? t(wizard.createLabel) : t("form.create"))}
</Button>
</DialogFooter>
</>
<ChannelInstanceFormStep
form={form}
instance={instance}
agents={agents}
credsValues={credsValues}
configValues={configValues}
onCredsChange={handleCredsChange}
onConfigChange={handleConfigChange}
setConfigValues={setConfigValues}
error={error}
loading={loading}
onCancel={() => onOpenChange(false)}
onSubmit={handleSubmit}
submitLabel={submitLabel}
/>
)}
{/* === AUTH STEP (rendered by registered component) === */}
{step === "auth" && createdInstanceId && AuthStep && (
<AuthStep
instanceId={createdInstanceId}
@@ -421,7 +269,6 @@ export function ChannelInstanceFormDialog({
/>
)}
{/* === CONFIG STEP (rendered by registered component) === */}
{step === "config" && createdInstanceId && ConfigStep && (
<>
<div className="py-2 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
@@ -0,0 +1,196 @@
import { useCallback } from "react";
import { Controller } from "react-hook-form";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import type { ChannelInstanceData } from "./hooks/use-channel-instances";
import type { AgentData } from "@/types/agent";
import { slugify } from "@/lib/slug";
import { credentialsSchema, configSchema, wizardConfig, type FieldDef } from "./channel-schemas";
import { ChannelFields } from "./channel-fields";
import { ChannelScopesInfo } from "./channel-scopes-info";
import { wizardEditConfigs } from "./channel-wizard-registry";
import { TelegramGroupOverrides, type GroupConfigWithTopics } from "./telegram-group-overrides";
import { CHANNEL_TYPES } from "@/constants/channels";
import type { ChannelInstanceFormData } from "@/schemas/channel.schema";
interface ChannelInstanceFormStepProps {
form: UseFormReturn<ChannelInstanceFormData>;
instance?: ChannelInstanceData | null;
agents: AgentData[];
credsValues: Record<string, unknown>;
configValues: Record<string, unknown>;
onCredsChange: (key: string, value: unknown) => void;
onConfigChange: (key: string, value: unknown) => void;
setConfigValues: React.Dispatch<React.SetStateAction<Record<string, unknown>>>;
error: string;
loading: boolean;
onCancel: () => void;
onSubmit: () => void;
submitLabel: string;
}
export function ChannelInstanceFormStep({
form, instance, agents, credsValues, configValues,
onCredsChange, onConfigChange, setConfigValues,
error, loading, onCancel, onSubmit, submitLabel,
}: ChannelInstanceFormStepProps) {
const { t } = useTranslation("channels");
const { register, control, formState: { errors } } = form;
const channelType = form.watch("channelType");
const wizard = wizardConfig[channelType];
const EditConfig = wizardEditConfigs[channelType];
const credsFields = credentialsSchema[channelType] ?? [];
const excludeSet = new Set(wizard?.excludeConfigFields ?? []);
const cfgFields = configSchema[channelType] ?? [];
const formCfgFields = excludeSet.size > 0 ? cfgFields.filter((f: FieldDef) => !excludeSet.has(f.key)) : cfgFields;
const hasWizard = !instance && !!wizard;
const handleTelegramGroupsChange = useCallback((groups: Record<string, GroupConfigWithTopics>) => {
setConfigValues((prev) => ({
...prev,
groups: Object.keys(groups).length > 0 ? groups : undefined,
}));
}, [setConfigValues]);
return (
<>
<div className="grid gap-4 py-2 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
<div className="grid gap-1.5">
<Label htmlFor="ci-name">{t("form.key")}</Label>
<Input
id="ci-name"
{...register("name", { setValueAs: (v: string) => slugify(v) })}
onChange={(e) => form.setValue("name", slugify(e.target.value), { shouldValidate: true })}
value={form.watch("name")}
placeholder={t("form.keyPlaceholder")}
disabled={!!instance}
/>
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
<p className="text-xs text-muted-foreground">{t("form.keyHint")}</p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ci-display">{t("form.displayName")}</Label>
<Input id="ci-display" {...register("displayName")} placeholder={t("form.displayNamePlaceholder")} />
</div>
<div className="grid gap-1.5">
<Label>{t("form.channelType")}</Label>
<Controller
control={control}
name="channelType"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} disabled={!!instance}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CHANNEL_TYPES.map((ct) => (
<SelectItem key={ct.value} value={ct.value}>{ct.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="grid gap-1.5">
<Label>{t("form.agent")}</Label>
<Controller
control={control}
name="agentId"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue placeholder={t("form.selectAgent")} /></SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.display_name || a.agent_key}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.agentId && <p className="text-xs text-destructive">{errors.agentId.message}</p>}
</div>
{credsFields.length > 0 && (
<fieldset className="rounded-md border p-3 space-y-3">
<legend className="px-1 text-sm font-medium">
{t("form.credentials")}
{instance && <span className="text-xs font-normal text-muted-foreground ml-1">{t("form.credentialsHint")}</span>}
</legend>
<ChannelFields fields={credsFields} values={credsValues} onChange={onCredsChange} idPrefix="ci-cred" isEdit={!!instance} contextValues={configValues} />
<p className="text-xs text-muted-foreground">{t("form.credentialsEncrypted")}</p>
</fieldset>
)}
<ChannelScopesInfo channelType={channelType} />
{instance && wizard?.steps.includes("auth") && (
<div className="rounded-md border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950 p-3">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${instance.has_credentials ? "bg-green-500" : "bg-amber-500"}`} />
<span className="text-sm">
{instance.has_credentials ? t("form.authStatus.authenticated") : t("form.authStatus.notAuthenticated")}
</span>
{!instance.has_credentials && (
<span className="text-xs text-muted-foreground ml-1">{t("form.authStatus.useQrHint")}</span>
)}
</div>
</div>
)}
{hasWizard && wizard?.formBanner && (
<div className="rounded-md border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950 p-3">
<p className="text-sm text-muted-foreground">{t(wizard.formBanner)}</p>
</div>
)}
{formCfgFields.length > 0 && (
<fieldset className="rounded-md border p-3 space-y-3">
<legend className="px-1 text-sm font-medium">{t("form.configuration")}</legend>
<ChannelFields fields={formCfgFields} values={configValues} onChange={onConfigChange} idPrefix="ci-cfg" />
{instance && EditConfig && <EditConfig instance={instance} configValues={configValues} onConfigChange={onConfigChange} />}
</fieldset>
)}
{channelType === "telegram" && (
<TelegramGroupOverrides
groups={(configValues.groups as Record<string, Record<string, unknown>>) ?? {}}
onChange={handleTelegramGroupsChange}
/>
)}
<div className="flex items-center gap-2">
<Controller
control={control}
name="enabled"
render={({ field }) => (
<Switch id="ci-enabled" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
<Label htmlFor="ci-enabled">{t("form.enabled")}</Label>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={onCancel} disabled={loading}>{t("form.cancel")}</Button>
<Button onClick={onSubmit} disabled={loading}>
{loading ? t("form.saving") : submitLabel}
</Button>
</div>
</>
);
}
@@ -8,7 +8,7 @@ import { TelegramGroupFields, type TelegramGroupConfigValues } from "./telegram-
import { TelegramTopicOverrides, type TelegramTopicConfigValues } from "./telegram-topic-overrides";
import type { GroupManagerGroupInfo } from "./hooks/use-channel-detail";
interface GroupConfigWithTopics extends TelegramGroupConfigValues {
export interface GroupConfigWithTopics extends TelegramGroupConfigValues {
topics?: Record<string, TelegramTopicConfigValues>;
}
@@ -0,0 +1,151 @@
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Search, Check, AlertCircle } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import type { CliCredentialFormData } from "@/schemas/credential.schema";
interface CheckResult {
found: boolean;
path?: string;
error?: string;
}
interface CliCredentialBinaryFieldsProps {
form: UseFormReturn<CliCredentialFormData>;
checking: boolean;
checkResult: CheckResult | null;
onCheckBinary: () => void;
}
/**
* Renders binary name/path, description, deny-args/deny-verbose,
* timeout, and tips fields for the CLI credential form.
*/
export function CliCredentialBinaryFields({
form,
checking,
checkResult,
onCheckBinary,
}: CliCredentialBinaryFieldsProps) {
const { t } = useTranslation("cli-credentials");
const { t: tc } = useTranslation("common");
const { register, formState: { errors }, watch } = form;
const binaryName = watch("binaryName");
return (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="cc-name">{t("form.binaryName")}</Label>
<div className="flex gap-1.5">
<Input
id="cc-name"
{...register("binaryName")}
placeholder={t("placeholders.binaryName")}
className="text-base md:text-sm"
/>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
disabled={!binaryName.trim() || checking}
onClick={onCheckBinary}
title={t("form.checkBinary")}
>
<Search className="h-4 w-4" />
</Button>
</div>
{errors.binaryName && (
<p className="text-xs text-destructive">{errors.binaryName.message}</p>
)}
{checkResult && (
<p className={`text-xs flex items-center gap-1 ${checkResult.found ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
{checkResult.found
? <><Check className="h-3 w-3" />{t("form.binaryFound", { path: checkResult.path })}</>
: <><AlertCircle className="h-3 w-3" />{checkResult.error || t("form.binaryNotFound")}</>}
</p>
)}
{checking && <p className="text-xs text-muted-foreground">{t("form.checking")}</p>}
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-path">
{t("form.binaryPath")}{" "}
<span className="text-xs text-muted-foreground">({tc("optional")})</span>
</Label>
<Input
id="cc-path"
{...register("binaryPath")}
placeholder={t("placeholders.binaryPath")}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("form.binaryPathHint")}</p>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-desc">{tc("description")}</Label>
<Textarea
id="cc-desc"
{...register("description")}
placeholder={t("placeholders.description")}
rows={2}
className="text-base md:text-sm"
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="cc-deny-args">
{t("form.denyArgs")}{" "}
<span className="text-xs text-muted-foreground">({t("form.commaSeparated")})</span>
</Label>
<Input
id="cc-deny-args"
{...register("denyArgs")}
placeholder={t("placeholders.denyArgs")}
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-timeout">{t("form.timeout")}</Label>
<Input
id="cc-timeout"
type="number"
min={1}
{...register("timeout", { valueAsNumber: true })}
className="text-base md:text-sm"
/>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-deny-verbose">
{t("form.denyVerbose")}{" "}
<span className="text-xs text-muted-foreground">({t("form.commaSeparated")})</span>
</Label>
<Input
id="cc-deny-verbose"
{...register("denyVerbose")}
placeholder={t("placeholders.denyVerbose")}
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-tips">{t("form.tips")}</Label>
<Textarea
id="cc-tips"
{...register("tips")}
placeholder={t("placeholders.tips")}
rows={2}
className="text-base md:text-sm"
/>
</div>
</>
);
}
@@ -1,27 +1,24 @@
import type { ManualEnvEntry } from "./cli-credential-env-vars-section";
import { useState, useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next";
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Search, Check, AlertCircle } from "lucide-react";
import { useHttp } from "@/hooks/use-ws";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import type { SecureCLIBinary, CLICredentialInput, CLIPreset } from "./hooks/use-cli-credentials";
import { CliCredentialEnvVarsSection } from "./cli-credential-env-vars-section";
import { CliCredentialBinaryFields } from "./cli-credential-binary-fields";
import { CliCredentialScopeFields } from "./cli-credential-scope-fields";
import { cliCredentialSchema, type CliCredentialFormData } from "@/schemas/credential.schema";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -31,7 +28,6 @@ interface Props {
}
const NONE_PRESET = "__none__";
const GLOBAL_AGENT = "__global__";
const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
export function CliCredentialFormDialog({ open, onOpenChange, credential, presets, onSubmit }: Props) {
@@ -40,7 +36,6 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
const http = useHttp();
const { agents } = useAgents();
// Non-form state: preset selection, env vars, binary check, loading
const [selectedPreset, setSelectedPreset] = useState(NONE_PRESET);
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const [manualEnvEntries, setManualEnvEntries] = useState<ManualEnvEntry[]>([]);
@@ -73,8 +68,6 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
},
});
const { register, control, formState: { errors }, setValue, watch } = form;
const binaryName = watch("binaryName");
useEffect(() => {
if (!open) return;
@@ -144,7 +137,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
};
const handleCheckBinary = async () => {
const name = binaryName.trim();
const name = form.getValues("binaryName").trim();
if (!name) return;
setChecking(true);
setCheckResult(null);
@@ -154,7 +147,7 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
{ binary_name: name },
);
setCheckResult(res);
if (res.found && res.path) setValue("binaryPath", res.path);
if (res.found && res.path) form.setValue("binaryPath", res.path);
} catch {
setCheckResult({ found: false, error: t("form.binaryNotFound") });
} finally {
@@ -219,7 +212,6 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
</DialogHeader>
<div className="grid gap-4 py-2 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
{/* Preset selector — only on create */}
{!isEdit && presetEntries.length > 0 && (
<div className="grid gap-1.5">
<Label>{t("form.preset")}</Label>
@@ -255,160 +247,28 @@ export function CliCredentialFormDialog({ open, onOpenChange, credential, preset
setManualEnvEntries={setManualEnvEntries}
/>
{/* Binary name + check button */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="cc-name">{t("form.binaryName")}</Label>
<div className="flex gap-1.5">
<Input
id="cc-name"
{...register("binaryName", {
onChange: () => setCheckResult(null),
})}
placeholder={t("placeholders.binaryName")}
className="text-base md:text-sm"
/>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
disabled={!binaryName.trim() || checking}
onClick={handleCheckBinary}
title={t("form.checkBinary")}
>
<Search className="h-4 w-4" />
</Button>
</div>
{errors.binaryName && <p className="text-xs text-destructive">{errors.binaryName.message}</p>}
{checkResult && (
<p className={`text-xs flex items-center gap-1 ${checkResult.found ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
{checkResult.found ? <Check className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
{checkResult.found
? t("form.binaryFound", { path: checkResult.path })
: (checkResult.error || t("form.binaryNotFound"))}
</p>
)}
{checking && <p className="text-xs text-muted-foreground">{t("form.checking")}</p>}
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-path">
{t("form.binaryPath")} <span className="text-xs text-muted-foreground">({tc("optional")})</span>
</Label>
<Input
id="cc-path"
{...register("binaryPath")}
placeholder={t("placeholders.binaryPath")}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("form.binaryPathHint")}</p>
</div>
</div>
<CliCredentialBinaryFields
form={form}
checking={checking}
checkResult={checkResult}
onCheckBinary={handleCheckBinary}
/>
<div className="grid gap-1.5">
<Label htmlFor="cc-desc">{tc("description")}</Label>
<Textarea
id="cc-desc"
{...register("description")}
placeholder={t("placeholders.description")}
rows={2}
className="text-base md:text-sm"
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="cc-deny-args">
{t("form.denyArgs")} <span className="text-xs text-muted-foreground">({t("form.commaSeparated")})</span>
</Label>
<Input
id="cc-deny-args"
{...register("denyArgs")}
placeholder={t("placeholders.denyArgs")}
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-timeout">{t("form.timeout")}</Label>
<Input
id="cc-timeout"
type="number"
min={1}
{...register("timeout", { valueAsNumber: true })}
className="text-base md:text-sm"
/>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-deny-verbose">
{t("form.denyVerbose")} <span className="text-xs text-muted-foreground">({t("form.commaSeparated")})</span>
</Label>
<Input
id="cc-deny-verbose"
{...register("denyVerbose")}
placeholder={t("placeholders.denyVerbose")}
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="cc-tips">{t("form.tips")}</Label>
<Textarea
id="cc-tips"
{...register("tips")}
placeholder={t("placeholders.tips")}
rows={2}
className="text-base md:text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label>
{t("form.agentId")} <span className="text-xs text-muted-foreground">({t("form.agentIdHint")})</span>
</Label>
<Controller
control={control}
name="agentId"
render={({ field }) => (
<Select
value={field.value || GLOBAL_AGENT}
onValueChange={(v) => field.onChange(v === GLOBAL_AGENT ? "" : v)}
>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("placeholders.agentId")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={GLOBAL_AGENT}>{t("placeholders.agentId")}</SelectItem>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.display_name || a.agent_key || a.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="flex items-center gap-2">
<Controller
control={control}
name="enabled"
render={({ field }) => (
<Switch id="cc-enabled" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
<Label htmlFor="cc-enabled">{tc("enabled")}</Label>
</div>
<CliCredentialScopeFields form={form} agents={agents} />
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>{tc("cancel")}</Button>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
{tc("cancel")}
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? tc("saving") : isEdit ? tc("update") : tc("create")}
{loading
? tc("saving")
: isEdit
? tc("update")
: tc("create")}
</Button>
</DialogFooter>
</DialogContent>
@@ -0,0 +1,72 @@
import { Controller } from "react-hook-form";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { AgentData } from "@/types/agent";
import type { CliCredentialFormData } from "@/schemas/credential.schema";
const GLOBAL_AGENT = "__global__";
interface CliCredentialScopeFieldsProps {
form: UseFormReturn<CliCredentialFormData>;
agents: AgentData[];
}
/** Renders agent scope selector and enabled toggle for a CLI credential. */
export function CliCredentialScopeFields({ form, agents }: CliCredentialScopeFieldsProps) {
const { t } = useTranslation("cli-credentials");
const { t: tc } = useTranslation("common");
const { control } = form;
return (
<>
<div className="grid gap-1.5">
<Label>
{t("form.agentId")}{" "}
<span className="text-xs text-muted-foreground">({t("form.agentIdHint")})</span>
</Label>
<Controller
control={control}
name="agentId"
render={({ field }) => (
<Select
value={field.value || GLOBAL_AGENT}
onValueChange={(v) => field.onChange(v === GLOBAL_AGENT ? "" : v)}
>
<SelectTrigger className="text-base md:text-sm">
<SelectValue placeholder={t("placeholders.agentId")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={GLOBAL_AGENT}>{t("placeholders.agentId")}</SelectItem>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.display_name || a.agent_key || a.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="flex items-center gap-2">
<Controller
control={control}
name="enabled"
render={({ field }) => (
<Switch id="cc-enabled" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
<Label htmlFor="cc-enabled">{tc("enabled")}</Label>
</div>
</>
);
}
@@ -0,0 +1,100 @@
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { KeyValueEditor } from "@/components/shared/key-value-editor";
import { slugify } from "@/lib/slug";
import type { MCPFormData } from "@/schemas/mcp.schema";
/** Header keys whose values should be masked in the form. */
const SENSITIVE_HEADER_RE = /^(authorization|x-api-key|api-key|bearer|token|secret|password|credential)/i;
export const isSensitiveHeader = (key: string) => SENSITIVE_HEADER_RE.test(key.trim());
const TRANSPORTS = [
{ value: "stdio", label: "stdio" },
{ value: "sse", label: "SSE" },
{ value: "streamable-http", label: "Streamable HTTP" },
] as const;
interface McpConnectionFieldsProps {
form: UseFormReturn<MCPFormData>;
}
/** Renders transport selector plus stdio command/args or HTTP url/headers fields. */
export function McpConnectionFields({ form }: McpConnectionFieldsProps) {
const { t } = useTranslation("mcp");
const { register, watch, setValue } = form;
const transport = watch("transport");
const name = watch("name");
const headers = watch("headers") as Record<string, string>;
const isStdio = transport === "stdio";
return (
<>
<div className="grid gap-1.5">
<Label htmlFor="mcp-name">{t("form.name")}</Label>
<Input
id="mcp-name"
value={name}
onChange={(e) => setValue("name", slugify(e.target.value))}
placeholder="my-mcp-server"
/>
<p className="text-xs text-muted-foreground">{t("form.nameHint")}</p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="mcp-display">{t("form.displayName")}</Label>
<Input id="mcp-display" placeholder={t("form.displayNamePlaceholder")} {...register("displayName")} />
</div>
<div className="grid gap-1.5">
<Label>{t("form.transport")}</Label>
<div className="flex gap-2">
{TRANSPORTS.map((tr) => (
<Button
key={tr.value}
type="button"
variant={transport === tr.value ? "default" : "outline"}
size="sm"
onClick={() => setValue("transport", tr.value)}
>
{tr.label}
</Button>
))}
</div>
</div>
{isStdio ? (
<>
<div className="grid gap-1.5">
<Label htmlFor="mcp-cmd">{t("form.command")}</Label>
<Input id="mcp-cmd" placeholder="npx" className="font-mono" {...register("command")} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="mcp-args">{t("form.args")}</Label>
<Input id="mcp-args" placeholder={t("form.argsPlaceholder")} className="font-mono" {...register("args")} />
</div>
</>
) : (
<>
<div className="grid gap-1.5">
<Label htmlFor="mcp-url">{t("form.url")}</Label>
<Input id="mcp-url" placeholder="http://localhost:3001/sse" className="font-mono" {...register("url")} />
</div>
<div className="grid gap-1.5">
<Label>{t("form.headers")}</Label>
<KeyValueEditor
value={headers}
onChange={(v) => setValue("headers", v)}
keyPlaceholder={t("form.headerKeyPlaceholder")}
valuePlaceholder={t("form.headerValuePlaceholder")}
addLabel={t("form.addHeader")}
maskValue={isSensitiveHeader}
/>
</div>
</>
)}
</>
);
}
+37 -187
View File
@@ -11,30 +11,11 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { KeyValueEditor } from "@/components/shared/key-value-editor";
import type { MCPServerData, MCPServerInput } from "./hooks/use-mcp";
import { slugify, isValidSlug } from "@/lib/slug";
import { isValidSlug } from "@/lib/slug";
import { mcpFormSchema, type MCPFormData } from "@/schemas/mcp.schema";
/** Header keys whose values should be masked in the form. */
const SENSITIVE_HEADER_RE = /^(authorization|x-api-key|api-key|bearer|token|secret|password|credential)/i;
/** Env var keys whose values should be masked in the form. */
const SENSITIVE_ENV_RE = /^.*(key|secret|token|password|credential).*$/i;
const isSensitiveHeader = (key: string) => SENSITIVE_HEADER_RE.test(key.trim());
const isSensitiveEnv = (key: string) => SENSITIVE_ENV_RE.test(key.trim());
interface MCPFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
server?: MCPServerData | null;
onSubmit: (data: MCPServerInput) => Promise<unknown>;
onTest: (data: { transport: string; command?: string; args?: string[]; url?: string; headers?: Record<string, string>; env?: Record<string, string> }) => Promise<{ success: boolean; tool_count?: number; error?: string }>;
}
import { McpConnectionFields } from "./mcp-connection-fields";
import { McpSettingsFields } from "./mcp-settings-fields";
/** Split a string into shell-like tokens, treating commas and spaces outside quotes as delimiters. */
function splitShellTokens(input: string): string[] {
@@ -47,11 +28,20 @@ function splitShellTokens(input: string): string[] {
return tokens.filter(Boolean);
}
const TRANSPORTS = [
{ value: "stdio", label: "stdio" },
{ value: "sse", label: "SSE" },
{ value: "streamable-http", label: "Streamable HTTP" },
] as const;
interface MCPFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
server?: MCPServerData | null;
onSubmit: (data: MCPServerInput) => Promise<unknown>;
onTest: (data: {
transport: string;
command?: string;
args?: string[];
url?: string;
headers?: Record<string, string>;
env?: Record<string, string>;
}) => Promise<{ success: boolean; tool_count?: number; error?: string }>;
}
export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }: MCPFormDialogProps) {
const { t } = useTranslation("mcp");
@@ -79,19 +69,13 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
},
});
const { register, watch, setValue, reset, handleSubmit: rhfHandleSubmit } = form;
const { watch, reset, handleSubmit: rhfHandleSubmit } = form;
const transport = watch("transport");
const name = watch("name");
const command = watch("command");
const args = watch("args");
const url = watch("url");
const headers = watch("headers") as Record<string, string>;
const env = watch("env") as Record<string, string>;
const enabled = watch("enabled");
const requireUserCreds = watch("requireUserCreds");
const timeout = watch("timeout");
const toolPrefix = watch("toolPrefix");
const isStdio = transport === "stdio";
useEffect(() => {
@@ -131,32 +115,19 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
}
}
const parsedHeaders = !isStdio && Object.keys(headers).length > 0
? (headers as Record<string, string>)
: undefined;
const parsedEnv = Object.keys(env).length > 0
? (env as Record<string, string>)
: undefined;
return {
transport,
command: isStdio ? resolvedCommand : undefined,
args: parsedArgs,
url: !isStdio ? url.trim() : undefined,
headers: parsedHeaders,
env: parsedEnv,
headers: !isStdio && Object.keys(headers).length > 0 ? headers : undefined,
env: Object.keys(env).length > 0 ? env : undefined,
};
};
const handleTest = async () => {
if (isStdio && !command.trim()) {
setError(t("form.errors.commandRequired"));
return;
}
if (!isStdio && !url.trim()) {
setError(t("form.errors.urlRequired"));
return;
}
if (isStdio && !command.trim()) { setError(t("form.errors.commandRequired")); return; }
if (!isStdio && !url.trim()) { setError(t("form.errors.urlRequired")); return; }
setTesting(true);
setError("");
setTestResult(null);
@@ -171,27 +142,17 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
};
const handleSubmit = rhfHandleSubmit(async (data) => {
if (!isValidSlug(data.name.trim())) {
setError(t("form.errors.nameSlug"));
return;
}
if (isStdio && !data.command.trim()) {
setError(t("form.errors.commandRequired"));
return;
}
if (!isStdio && !data.url.trim()) {
setError(t("form.errors.urlRequired"));
return;
}
if (!isValidSlug(data.name.trim())) { setError(t("form.errors.nameSlug")); return; }
if (isStdio && !data.command.trim()) { setError(t("form.errors.commandRequired")); return; }
if (!isStdio && !data.url.trim()) { setError(t("form.errors.urlRequired")); return; }
setLoading(true);
setError("");
try {
const conn = buildConnectionData();
await onSubmit({
name: data.name.trim(),
display_name: data.displayName.trim() || undefined,
...conn,
...buildConnectionData(),
tool_prefix: data.toolPrefix.trim() || undefined,
timeout_sec: data.timeout,
settings: { require_user_credentials: data.requireUserCreds },
@@ -213,141 +174,30 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
</DialogHeader>
<div className="grid gap-4 py-2 -mx-4 px-4 sm:-mx-6 sm:px-6 overflow-y-auto min-h-0">
<div className="grid gap-1.5">
<Label htmlFor="mcp-name">{t("form.name")}</Label>
<Input
id="mcp-name"
value={name}
onChange={(e) => setValue("name", slugify(e.target.value))}
placeholder="my-mcp-server"
/>
<p className="text-xs text-muted-foreground">{t("form.nameHint")}</p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="mcp-display">{t("form.displayName")}</Label>
<Input id="mcp-display" placeholder={t("form.displayNamePlaceholder")} {...register("displayName")} />
</div>
<div className="grid gap-1.5">
<Label>{t("form.transport")}</Label>
<div className="flex gap-2">
{TRANSPORTS.map((tr) => (
<Button
key={tr.value}
type="button"
variant={transport === tr.value ? "default" : "outline"}
size="sm"
onClick={() => setValue("transport", tr.value)}
>
{tr.label}
</Button>
))}
</div>
</div>
{isStdio ? (
<>
<div className="grid gap-1.5">
<Label htmlFor="mcp-cmd">{t("form.command")}</Label>
<Input id="mcp-cmd" placeholder="npx" className="font-mono" {...register("command")} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="mcp-args">{t("form.args")}</Label>
<Input id="mcp-args" placeholder={t("form.argsPlaceholder")} className="font-mono" {...register("args")} />
</div>
</>
) : (
<>
<div className="grid gap-1.5">
<Label htmlFor="mcp-url">{t("form.url")}</Label>
<Input id="mcp-url" placeholder="http://localhost:3001/sse" className="font-mono" {...register("url")} />
</div>
<div className="grid gap-1.5">
<Label>{t("form.headers")}</Label>
<KeyValueEditor
value={headers}
onChange={(v) => setValue("headers", v)}
keyPlaceholder={t("form.headerKeyPlaceholder")}
valuePlaceholder={t("form.headerValuePlaceholder")}
addLabel={t("form.addHeader")}
maskValue={isSensitiveHeader}
/>
</div>
</>
)}
<div className="grid gap-1.5">
<Label>{t("form.env")}</Label>
<KeyValueEditor
value={env}
onChange={(v) => setValue("env", v)}
keyPlaceholder={t("form.envKeyPlaceholder")}
valuePlaceholder={t("form.envValuePlaceholder")}
addLabel={t("form.addVariable")}
maskValue={isSensitiveEnv}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="mcp-prefix">{t("form.toolPrefix")}</Label>
<div className="flex">
<span className="inline-flex items-center px-2.5 rounded-l-md border border-r-0 border-input bg-muted text-muted-foreground text-sm font-mono">mcp_</span>
<Input
id="mcp-prefix"
value={toolPrefix}
onChange={(e) => setValue("toolPrefix", e.target.value.replace(/[^a-z0-9_]/g, ""))}
placeholder={name.replace(/-/g, "_") || "auto"}
className="rounded-l-none font-mono"
/>
</div>
<p className="text-xs text-muted-foreground">{t("form.toolPrefixHint")} Tools: <code className="text-[10px]">mcp_&#123;prefix&#125;__&#123;tool&#125;</code></p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="mcp-timeout">{t("form.timeout")}</Label>
<Input
id="mcp-timeout"
type="number"
value={timeout}
onChange={(e) => setValue("timeout", Number(e.target.value))}
min={1}
/>
</div>
<div className="flex items-center gap-2">
<Switch id="mcp-enabled" checked={enabled} onCheckedChange={(v) => setValue("enabled", v)} />
<Label htmlFor="mcp-enabled">{t("form.enabled")}</Label>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Switch id="mcp-require-creds" checked={requireUserCreds} onCheckedChange={(v) => setValue("requireUserCreds", v)} />
<Label htmlFor="mcp-require-creds">{t("form.requireUserCredentials")}</Label>
</div>
<p className="text-xs text-muted-foreground pl-9">{t("form.requireUserCredentialsHint")}</p>
</div>
<McpConnectionFields form={form} />
<McpSettingsFields form={form} />
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
<div className="flex items-center gap-2 mr-auto">
<Button type="button" variant="secondary" size="sm" onClick={handleTest} disabled={loading || testing}>
{testing ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> {t("form.testing")}</> : t("form.testConnection")}
{testing
? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />{t("form.testing")}</>
: t("form.testConnection")}
</Button>
{testResult && (
<span className={`flex items-center gap-1 text-xs ${testResult.success ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"}`}>
{testResult.success ? (
<><CheckCircle2 className="h-3.5 w-3.5" /> {t("form.toolsFound", { count: testResult.tool_count })}</>
) : (
<><XCircle className="h-3.5 w-3.5" /> {testResult.error}</>
)}
{testResult.success
? <><CheckCircle2 className="h-3.5 w-3.5" />{t("form.toolsFound", { count: testResult.tool_count })}</>
: <><XCircle className="h-3.5 w-3.5" />{testResult.error}</>}
</span>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>{t("form.cancel")}</Button>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
{t("form.cancel")}
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? t("form.saving") : server ? t("form.update") : t("form.create")}
</Button>
@@ -0,0 +1,95 @@
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { KeyValueEditor } from "@/components/shared/key-value-editor";
import type { MCPFormData } from "@/schemas/mcp.schema";
/** Env var keys whose values should be masked in the form. */
const SENSITIVE_ENV_RE = /^.*(key|secret|token|password|credential).*$/i;
export const isSensitiveEnv = (key: string) => SENSITIVE_ENV_RE.test(key.trim());
interface McpSettingsFieldsProps {
form: UseFormReturn<MCPFormData>;
}
/** Renders env vars, tool prefix, timeout, enabled, and requireUserCredentials fields. */
export function McpSettingsFields({ form }: McpSettingsFieldsProps) {
const { t } = useTranslation("mcp");
const { watch, setValue } = form;
const env = watch("env") as Record<string, string>;
const toolPrefix = watch("toolPrefix");
const timeout = watch("timeout");
const name = watch("name");
const enabled = watch("enabled");
const requireUserCreds = watch("requireUserCreds");
return (
<>
<div className="grid gap-1.5">
<Label>{t("form.env")}</Label>
<KeyValueEditor
value={env}
onChange={(v) => setValue("env", v)}
keyPlaceholder={t("form.envKeyPlaceholder")}
valuePlaceholder={t("form.envValuePlaceholder")}
addLabel={t("form.addVariable")}
maskValue={isSensitiveEnv}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="mcp-prefix">{t("form.toolPrefix")}</Label>
<div className="flex">
<span className="inline-flex items-center px-2.5 rounded-l-md border border-r-0 border-input bg-muted text-muted-foreground text-sm font-mono">
mcp_
</span>
<Input
id="mcp-prefix"
value={toolPrefix}
onChange={(e) => setValue("toolPrefix", e.target.value.replace(/[^a-z0-9_]/g, ""))}
placeholder={name.replace(/-/g, "_") || "auto"}
className="rounded-l-none font-mono"
/>
</div>
<p className="text-xs text-muted-foreground">
{t("form.toolPrefixHint")} Tools:{" "}
<code className="text-[10px]">mcp_&#123;prefix&#125;__&#123;tool&#125;</code>
</p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="mcp-timeout">{t("form.timeout")}</Label>
<Input
id="mcp-timeout"
type="number"
value={timeout}
onChange={(e) => setValue("timeout", Number(e.target.value))}
min={1}
/>
</div>
<div className="flex items-center gap-2">
<Switch
id="mcp-enabled"
checked={enabled}
onCheckedChange={(v) => setValue("enabled", v)}
/>
<Label htmlFor="mcp-enabled">{t("form.enabled")}</Label>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Switch
id="mcp-require-creds"
checked={requireUserCreds}
onCheckedChange={(v) => setValue("requireUserCreds", v)}
/>
<Label htmlFor="mcp-require-creds">{t("form.requireUserCredentials")}</Label>
</div>
<p className="text-xs text-muted-foreground pl-9">{t("form.requireUserCredentialsHint")}</p>
</div>
</>
);
}