mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-03 05:20:01 +00:00
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:
1 parent
e962a8ec7a
commit
f6bca7003a
12 files changed
+985
-744
No files matched your search
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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_{prefix}__{tool}</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_{prefix}__{tool}</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user