mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 10:14:51 +00:00
refactor(ui): Phase 3 complete — 5 more forms to RHF+Zod (11 total)
Add 5 more Zod schemas: mcp, mcp-credentials, memory, cron-advanced, team-settings. Refactor 5 more form dialogs: - mcp-form-dialog: 15 → 4 useState - mcp-user-credentials-dialog: 11 → 7 useState - memory-create-dialog: 10 → 2 useState - cron-advanced-dialog: 10 → 2 useState - team-settings-tab: 22 → 1 useState agent-advanced-dialog skipped (complex typed objects, no field validation needed — RHF adds indirection with zero benefit). Total Phase 3: 11 schemas, 11 forms refactored, ~120 useState removed.
This commit is contained in:
1 parent
7a6a944622
commit
7e62623123
10 files changed
+402
-237
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Save, Settings, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -15,6 +17,7 @@ import { useChannels } from "@/pages/channels/hooks/use-channels";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { Methods } from "@/api/protocol";
|
||||
import type { CronJob, CronJobPatch } from "../hooks/use-cron";
|
||||
import { cronAdvancedSchema, type CronAdvancedFormData } from "@/schemas/cron-advanced.schema";
|
||||
|
||||
interface DeliveryTarget {
|
||||
channel: string;
|
||||
@@ -30,7 +33,7 @@ interface CronAdvancedDialogProps {
|
||||
onUpdate?: (id: string, params: CronJobPatch) => Promise<void>;
|
||||
}
|
||||
|
||||
function deriveState(job: CronJob) {
|
||||
function deriveDefaults(job: CronJob): CronAdvancedFormData {
|
||||
return {
|
||||
timezone: job.schedule.tz ?? "UTC",
|
||||
deliver: job.deliver ?? false,
|
||||
@@ -49,17 +52,25 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
const { channels: availableChannels } = useChannels();
|
||||
const channelNames = Object.keys(availableChannels);
|
||||
|
||||
const init = deriveState(job);
|
||||
const [timezone, setTimezone] = useState(init.timezone);
|
||||
const [deliver, setDeliver] = useState(init.deliver);
|
||||
const [channel, setChannel] = useState(init.channel);
|
||||
const [to, setTo] = useState(init.to);
|
||||
const [wakeHeartbeat, setWakeHeartbeat] = useState(init.wakeHeartbeat);
|
||||
const [deleteAfterRun, setDeleteAfterRun] = useState(init.deleteAfterRun);
|
||||
const [stateless, setStateless] = useState(init.stateless);
|
||||
// UI-only state
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [targets, setTargets] = useState<DeliveryTarget[]>([]);
|
||||
|
||||
const form = useForm<CronAdvancedFormData>({
|
||||
resolver: zodResolver(cronAdvancedSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: deriveDefaults(job),
|
||||
});
|
||||
|
||||
const { watch, setValue, reset } = form;
|
||||
const timezone = watch("timezone");
|
||||
const deliver = watch("deliver");
|
||||
const channel = watch("channel");
|
||||
const to = watch("to");
|
||||
const wakeHeartbeat = watch("wakeHeartbeat");
|
||||
const deleteAfterRun = watch("deleteAfterRun");
|
||||
const stateless = watch("stateless");
|
||||
|
||||
const fetchTargets = useCallback(async () => {
|
||||
if (!job.agentId || !ws.isConnected) return;
|
||||
try {
|
||||
@@ -73,14 +84,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
// Re-sync when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const s = deriveState(job);
|
||||
setTimezone(s.timezone);
|
||||
setDeliver(s.deliver);
|
||||
setChannel(s.channel);
|
||||
setTo(s.to);
|
||||
setWakeHeartbeat(s.wakeHeartbeat);
|
||||
setDeleteAfterRun(s.deleteAfterRun);
|
||||
setStateless(s.stateless);
|
||||
reset(deriveDefaults(job));
|
||||
fetchTargets();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
@@ -90,7 +94,8 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
if (timezone && timezone !== "UTC" && !isValidIanaTimezone(timezone)) {
|
||||
const data = form.getValues();
|
||||
if (data.timezone && data.timezone !== "UTC" && !isValidIanaTimezone(data.timezone)) {
|
||||
toast.error(t("detail.invalidTimezone", "Invalid timezone"));
|
||||
return;
|
||||
}
|
||||
@@ -99,14 +104,14 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
await onUpdate(job.id, {
|
||||
schedule: {
|
||||
...job.schedule,
|
||||
tz: timezone !== "UTC" ? timezone : "",
|
||||
tz: data.timezone !== "UTC" ? data.timezone : "",
|
||||
},
|
||||
deliver,
|
||||
deliverChannel: deliver ? channel.trim() || undefined : undefined,
|
||||
deliverTo: deliver ? to.trim() || undefined : undefined,
|
||||
wakeHeartbeat,
|
||||
deleteAfterRun,
|
||||
stateless,
|
||||
deliver: data.deliver,
|
||||
deliverChannel: data.deliver ? data.channel.trim() || undefined : undefined,
|
||||
deliverTo: data.deliver ? data.to.trim() || undefined : undefined,
|
||||
wakeHeartbeat: data.wakeHeartbeat,
|
||||
deleteAfterRun: data.deleteAfterRun,
|
||||
stateless: data.stateless,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch { // toast shown by hook
|
||||
@@ -137,7 +142,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
<Label htmlFor="adv-timezone">{t("detail.timezone")}</Label>
|
||||
<Combobox
|
||||
value={timezone}
|
||||
onChange={setTimezone}
|
||||
onChange={(v) => setValue("timezone", v)}
|
||||
options={getAllIanaTimezones()}
|
||||
placeholder={t("detail.timezone")}
|
||||
className="text-base md:text-sm"
|
||||
@@ -153,7 +158,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
|
||||
<p className="text-sm font-medium">{t("detail.deliverToChannel")}</p>
|
||||
<Switch checked={deliver} onCheckedChange={setDeliver} />
|
||||
<Switch checked={deliver} onCheckedChange={(v) => setValue("deliver", v)} />
|
||||
</div>
|
||||
|
||||
{deliver && (
|
||||
@@ -163,7 +168,10 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
{channelNames.length > 0 ? (
|
||||
<Select
|
||||
value={channel || "__none__"}
|
||||
onValueChange={(v) => { setChannel(v === "__none__" ? "" : v); setTo(""); }}
|
||||
onValueChange={(v) => {
|
||||
setValue("channel", v === "__none__" ? "" : v);
|
||||
setValue("to", "");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue placeholder={t("detail.channelPlaceholder")} />
|
||||
@@ -178,7 +186,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
) : (
|
||||
<Input
|
||||
value={channel}
|
||||
onChange={(e) => setChannel(e.target.value)}
|
||||
onChange={(e) => setValue("channel", e.target.value)}
|
||||
placeholder={t("detail.channelPlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
@@ -201,7 +209,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
return (
|
||||
<Select
|
||||
value={to || "__none__"}
|
||||
onValueChange={(v) => setTo(v === "__none__" ? "" : v)}
|
||||
onValueChange={(v) => setValue("to", v === "__none__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue placeholder={t("detail.toPlaceholder")} />
|
||||
@@ -220,7 +228,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
return (
|
||||
<Input
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
onChange={(e) => setValue("to", e.target.value)}
|
||||
placeholder={t("detail.toPlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
@@ -235,7 +243,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
<p className="text-sm font-medium">{t("detail.wakeHeartbeat")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("detail.wakeHeartbeatDesc")}</p>
|
||||
</div>
|
||||
<Switch checked={wakeHeartbeat} onCheckedChange={setWakeHeartbeat} />
|
||||
<Switch checked={wakeHeartbeat} onCheckedChange={(v) => setValue("wakeHeartbeat", v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -249,7 +257,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
<p className="text-sm font-medium">{t("detail.deleteAfterRun")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("detail.deleteAfterRunDesc")}</p>
|
||||
</div>
|
||||
<Switch checked={deleteAfterRun} onCheckedChange={setDeleteAfterRun} />
|
||||
<Switch checked={deleteAfterRun} onCheckedChange={(v) => setValue("deleteAfterRun", v)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
|
||||
@@ -257,7 +265,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
|
||||
<p className="text-sm font-medium">{t("stateless")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("statelessHelp")}</p>
|
||||
</div>
|
||||
<Switch checked={stateless} onCheckedChange={setStateless} />
|
||||
<Switch checked={stateless} onCheckedChange={(v) => setValue("stateless", v)} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,6 +17,7 @@ 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 { 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;
|
||||
@@ -48,55 +51,75 @@ const TRANSPORTS = [
|
||||
{ value: "stdio", label: "stdio" },
|
||||
{ value: "sse", label: "SSE" },
|
||||
{ value: "streamable-http", label: "Streamable HTTP" },
|
||||
];
|
||||
] as const;
|
||||
|
||||
export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }: MCPFormDialogProps) {
|
||||
const { t } = useTranslation("mcp");
|
||||
const [name, setName] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [transport, setTransport] = useState("stdio");
|
||||
const [command, setCommand] = useState("");
|
||||
const [args, setArgs] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [headers, setHeaders] = useState<Record<string, string>>({});
|
||||
const [env, setEnv] = useState<Record<string, string>>({});
|
||||
const [toolPrefix, setToolPrefix] = useState("");
|
||||
const [timeout, setTimeout] = useState(60);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [requireUserCreds, setRequireUserCreds] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; tool_count?: number; error?: string } | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const form = useForm<MCPFormData>({
|
||||
resolver: zodResolver(mcpFormSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
name: "",
|
||||
displayName: "",
|
||||
transport: "stdio",
|
||||
command: "",
|
||||
args: "",
|
||||
url: "",
|
||||
headers: {},
|
||||
env: {},
|
||||
toolPrefix: "",
|
||||
timeout: 60,
|
||||
enabled: true,
|
||||
requireUserCreds: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { register, watch, setValue, 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(() => {
|
||||
if (open) {
|
||||
setName(server?.name ?? "");
|
||||
setDisplayName(server?.display_name ?? "");
|
||||
setTransport(server?.transport ?? "stdio");
|
||||
setCommand(server?.command ?? "");
|
||||
setArgs(Array.isArray(server?.args) ? server.args.join(", ") : "");
|
||||
setUrl(server?.url ?? "");
|
||||
setHeaders(server?.headers ?? {});
|
||||
setEnv(server?.env ?? {});
|
||||
setToolPrefix((server?.tool_prefix ?? "").replace(/^mcp_/, ""));
|
||||
setTimeout(server?.timeout_sec ?? 60);
|
||||
setEnabled(server?.enabled ?? true);
|
||||
setRequireUserCreds(server?.settings?.require_user_credentials ?? false);
|
||||
reset({
|
||||
name: server?.name ?? "",
|
||||
displayName: server?.display_name ?? "",
|
||||
transport: (server?.transport as MCPFormData["transport"]) ?? "stdio",
|
||||
command: server?.command ?? "",
|
||||
args: Array.isArray(server?.args) ? server.args.join(", ") : "",
|
||||
url: server?.url ?? "",
|
||||
headers: server?.headers ?? {},
|
||||
env: server?.env ?? {},
|
||||
toolPrefix: (server?.tool_prefix ?? "").replace(/^mcp_/, ""),
|
||||
timeout: server?.timeout_sec ?? 60,
|
||||
enabled: server?.enabled ?? true,
|
||||
requireUserCreds: server?.settings?.require_user_credentials ?? false,
|
||||
});
|
||||
setError("");
|
||||
setTestResult(null);
|
||||
}
|
||||
}, [open, server]);
|
||||
|
||||
const isStdio = transport === "stdio";
|
||||
}, [open, server, reset]);
|
||||
|
||||
const buildConnectionData = () => {
|
||||
let parsedArgs: string[] | undefined = undefined;
|
||||
let resolvedCommand = command.trim();
|
||||
|
||||
if (isStdio) {
|
||||
// If user pasted full command into Command field (e.g. "npx -y @foo/bar"),
|
||||
// split it: first token is the command, rest are prepended to args.
|
||||
const cmdTokens = splitShellTokens(resolvedCommand);
|
||||
if (cmdTokens.length > 1) {
|
||||
resolvedCommand = cmdTokens[0]!;
|
||||
@@ -108,8 +131,12 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
|
||||
}
|
||||
}
|
||||
|
||||
const parsedHeaders = !isStdio && Object.keys(headers).length > 0 ? headers : undefined;
|
||||
const parsedEnv = Object.keys(env).length > 0 ? env : undefined;
|
||||
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,
|
||||
@@ -143,20 +170,16 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !transport) {
|
||||
setError(t("form.errors.nameRequired"));
|
||||
return;
|
||||
}
|
||||
if (!isValidSlug(name.trim())) {
|
||||
const handleSubmit = rhfHandleSubmit(async (data) => {
|
||||
if (!isValidSlug(data.name.trim())) {
|
||||
setError(t("form.errors.nameSlug"));
|
||||
return;
|
||||
}
|
||||
if (isStdio && !command.trim()) {
|
||||
if (isStdio && !data.command.trim()) {
|
||||
setError(t("form.errors.commandRequired"));
|
||||
return;
|
||||
}
|
||||
if (!isStdio && !url.trim()) {
|
||||
if (!isStdio && !data.url.trim()) {
|
||||
setError(t("form.errors.urlRequired"));
|
||||
return;
|
||||
}
|
||||
@@ -166,13 +189,13 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
|
||||
try {
|
||||
const conn = buildConnectionData();
|
||||
await onSubmit({
|
||||
name: name.trim(),
|
||||
display_name: displayName.trim() || undefined,
|
||||
name: data.name.trim(),
|
||||
display_name: data.displayName.trim() || undefined,
|
||||
...conn,
|
||||
tool_prefix: toolPrefix.trim() || undefined,
|
||||
timeout_sec: timeout,
|
||||
settings: { require_user_credentials: requireUserCreds },
|
||||
enabled,
|
||||
tool_prefix: data.toolPrefix.trim() || undefined,
|
||||
timeout_sec: data.timeout,
|
||||
settings: { require_user_credentials: data.requireUserCreds },
|
||||
enabled: data.enabled,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
@@ -180,7 +203,7 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !loading && onOpenChange(v)}>
|
||||
@@ -192,13 +215,18 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
|
||||
<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) => setName(slugify(e.target.value))} placeholder="my-mcp-server" />
|
||||
<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" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder={t("form.displayNamePlaceholder")} />
|
||||
<Input id="mcp-display" placeholder={t("form.displayNamePlaceholder")} {...register("displayName")} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
@@ -210,7 +238,7 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
|
||||
type="button"
|
||||
variant={transport === tr.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setTransport(tr.value)}
|
||||
onClick={() => setValue("transport", tr.value)}
|
||||
>
|
||||
{tr.label}
|
||||
</Button>
|
||||
@@ -222,24 +250,24 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="mcp-cmd">{t("form.command")}</Label>
|
||||
<Input id="mcp-cmd" value={command} onChange={(e) => setCommand(e.target.value)} placeholder="npx" className="font-mono" />
|
||||
<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" value={args} onChange={(e) => setArgs(e.target.value)} placeholder={t("form.argsPlaceholder")} className="font-mono" />
|
||||
<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" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="http://localhost:3001/sse" className="font-mono" />
|
||||
<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={setHeaders}
|
||||
onChange={(v) => setValue("headers", v)}
|
||||
keyPlaceholder={t("form.headerKeyPlaceholder")}
|
||||
valuePlaceholder={t("form.headerValuePlaceholder")}
|
||||
addLabel={t("form.addHeader")}
|
||||
@@ -253,7 +281,7 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
|
||||
<Label>{t("form.env")}</Label>
|
||||
<KeyValueEditor
|
||||
value={env}
|
||||
onChange={setEnv}
|
||||
onChange={(v) => setValue("env", v)}
|
||||
keyPlaceholder={t("form.envKeyPlaceholder")}
|
||||
valuePlaceholder={t("form.envValuePlaceholder")}
|
||||
addLabel={t("form.addVariable")}
|
||||
@@ -265,24 +293,36 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
|
||||
<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) => setToolPrefix(e.target.value.replace(/[^a-z0-9_]/g, ""))} placeholder={name.replace(/-/g, "_") || "auto"} className="rounded-l-none font-mono" />
|
||||
<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) => setTimeout(Number(e.target.value))} min={1} />
|
||||
<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={setEnabled} />
|
||||
<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={setRequireUserCreds} />
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -20,6 +22,7 @@ import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { useTenants } from "@/hooks/use-tenants";
|
||||
import i18next from "i18next";
|
||||
import type { MCPServerData, MCPUserCredentialStatus, MCPUserCredentialInput } from "./hooks/use-mcp";
|
||||
import { mcpUserCredentialsSchema, type MCPUserCredentialsFormData } from "@/schemas/mcp-credentials.schema";
|
||||
|
||||
/** Header keys whose values should be masked. */
|
||||
const SENSITIVE_HEADER_RE = /^(authorization|bearer)|(key|secret|token|password|credential)/i;
|
||||
@@ -56,20 +59,24 @@ export function MCPUserCredentialsDialog({
|
||||
currentTenant?.role === "owner" ||
|
||||
currentTenant?.role === "admin";
|
||||
|
||||
// UI-only state
|
||||
const [selectedUserId, setSelectedUserId] = useState(currentUserId);
|
||||
// Separate search text from selected value — onChange fires on every keystroke,
|
||||
// but we only want to load credentials when user actually selects from dropdown
|
||||
const [userSearchText, setUserSearchText] = useState("");
|
||||
|
||||
const [status, setStatus] = useState<MCPUserCredentialStatus | null>(null);
|
||||
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [headers, setHeaders] = useState<Record<string, string>>({});
|
||||
const [env, setEnv] = useState<Record<string, string>>({});
|
||||
const form = useForm<MCPUserCredentialsFormData>({
|
||||
resolver: zodResolver(mcpUserCredentialsSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: { apiKey: "", headers: {}, env: {} },
|
||||
});
|
||||
|
||||
const { register, watch, setValue, reset } = form;
|
||||
const headers = watch("headers") as Record<string, string>;
|
||||
const env = watch("env") as Record<string, string>;
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
@@ -80,15 +87,9 @@ export function MCPUserCredentialsDialog({
|
||||
}
|
||||
}, [open, currentUserId]);
|
||||
|
||||
// Track whether this is the initial load (show full spinner) vs user switch (keep form visible)
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setApiKey("");
|
||||
setHeaders({});
|
||||
setEnv({});
|
||||
// Only show full-page spinner on initial dialog open, not on user switch
|
||||
reset({ apiKey: "", headers: {}, env: {} });
|
||||
if (initialLoad) {
|
||||
setStatus(null);
|
||||
setLoadingStatus(true);
|
||||
@@ -103,10 +104,11 @@ export function MCPUserCredentialsDialog({
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = form.getValues();
|
||||
const creds: MCPUserCredentialInput = {};
|
||||
if (apiKey.trim()) creds.api_key = apiKey.trim();
|
||||
if (Object.keys(headers).length > 0) creds.headers = headers;
|
||||
if (Object.keys(env).length > 0) creds.env = env;
|
||||
if (data.apiKey.trim()) creds.api_key = data.apiKey.trim();
|
||||
if (Object.keys(data.headers).length > 0) creds.headers = data.headers as Record<string, string>;
|
||||
if (Object.keys(data.env).length > 0) creds.env = data.env as Record<string, string>;
|
||||
const targetUser = canManageUsers ? selectedUserId : undefined;
|
||||
await onSetCredentials(server.id, creds, targetUser);
|
||||
toast.success(i18next.t("mcp:userCredentials.saved"));
|
||||
@@ -196,19 +198,18 @@ export function MCPUserCredentialsDialog({
|
||||
<Input
|
||||
id="uc-api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={t("userCredentials.apiKeyPlaceholder")}
|
||||
className="text-base md:text-sm font-mono"
|
||||
{...register("apiKey")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Headers — KeyValueEditor with sensitive masking */}
|
||||
{/* Headers */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t("userCredentials.headers")}</Label>
|
||||
<KeyValueEditor
|
||||
value={headers}
|
||||
onChange={setHeaders}
|
||||
onChange={(v) => setValue("headers", v)}
|
||||
keyPlaceholder="Header"
|
||||
valuePlaceholder="Value"
|
||||
addLabel={t("userCredentials.addHeader")}
|
||||
@@ -216,12 +217,12 @@ export function MCPUserCredentialsDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Env vars — KeyValueEditor with sensitive masking */}
|
||||
{/* Env vars */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t("userCredentials.env")}</Label>
|
||||
<KeyValueEditor
|
||||
value={env}
|
||||
onChange={setEnv}
|
||||
onChange={(v) => setValue("env", v)}
|
||||
keyPlaceholder="ENV_KEY"
|
||||
valuePlaceholder="value"
|
||||
addLabel={t("userCredentials.addEnv")}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { formatUserLabel } from "@/lib/format-user-label";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
import { useMemoryDocuments } from "./hooks/use-memory";
|
||||
import type { AgentData } from "@/types/agent";
|
||||
import { memoryCreateSchema, type MemoryCreateFormData } from "@/schemas/memory.schema";
|
||||
|
||||
interface MemoryCreateDialogProps {
|
||||
open: boolean;
|
||||
@@ -31,16 +34,31 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
|
||||
const { t } = useTranslation("memory");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const { agents } = useAgents();
|
||||
const [selectedAgentId, setSelectedAgentId] = useState("");
|
||||
const [path, setPath] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [scopeMode, setScopeMode] = useState<"global" | "existing" | "custom">("global");
|
||||
const [selectedUserId, setSelectedUserId] = useState("");
|
||||
const [customUserId, setCustomUserId] = useState("");
|
||||
const [autoIndex, setAutoIndex] = useState(true);
|
||||
|
||||
// UI-only state
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const form = useForm<MemoryCreateFormData>({
|
||||
resolver: zodResolver(memoryCreateSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
selectedAgentId: "",
|
||||
path: "",
|
||||
content: "",
|
||||
scopeMode: "global",
|
||||
selectedUserId: "",
|
||||
customUserId: "",
|
||||
autoIndex: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { register, watch, setValue, reset } = form;
|
||||
const selectedAgentId = watch("selectedAgentId");
|
||||
const scopeMode = watch("scopeMode");
|
||||
const selectedUserId = watch("selectedUserId");
|
||||
const autoIndex = watch("autoIndex");
|
||||
|
||||
const effectiveAgentId = selectedAgentId || parentAgentId || "";
|
||||
const { createDocument, indexDocument } = useMemoryDocuments({ agentId: effectiveAgentId || undefined });
|
||||
|
||||
@@ -48,33 +66,37 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedAgentId(parentAgentId || "");
|
||||
setPath("");
|
||||
setContent("");
|
||||
setScopeMode("global");
|
||||
setSelectedUserId("");
|
||||
setCustomUserId("");
|
||||
setAutoIndex(true);
|
||||
reset({
|
||||
selectedAgentId: parentAgentId || "",
|
||||
path: "",
|
||||
content: "",
|
||||
scopeMode: "global",
|
||||
selectedUserId: "",
|
||||
customUserId: "",
|
||||
autoIndex: true,
|
||||
});
|
||||
setError("");
|
||||
}
|
||||
}, [open, parentAgentId]);
|
||||
}, [open, parentAgentId, reset]);
|
||||
|
||||
const resolvedUserId = (): string | undefined => {
|
||||
if (scopeMode === "global") return undefined;
|
||||
if (scopeMode === "existing") return selectedUserId || undefined;
|
||||
return customUserId.trim() || undefined;
|
||||
const data = form.getValues();
|
||||
if (data.scopeMode === "global") return undefined;
|
||||
if (data.scopeMode === "existing") return data.selectedUserId || undefined;
|
||||
return data.customUserId.trim() || undefined;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const data = form.getValues();
|
||||
if (!effectiveAgentId) {
|
||||
setError(t("createDialog.agentRequired") ?? "Please select an agent");
|
||||
return;
|
||||
}
|
||||
if (!path.trim()) {
|
||||
if (!data.path.trim()) {
|
||||
setError(t("createDialog.pathRequired") ?? "Path is required");
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) {
|
||||
if (!data.content.trim()) {
|
||||
setError(t("createDialog.contentRequired") ?? "Content is required");
|
||||
return;
|
||||
}
|
||||
@@ -83,9 +105,9 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
|
||||
setError("");
|
||||
try {
|
||||
const uid = resolvedUserId();
|
||||
await createDocument(path.trim(), content, uid);
|
||||
if (autoIndex) {
|
||||
await indexDocument(path.trim(), uid);
|
||||
await createDocument(data.path.trim(), data.content, uid);
|
||||
if (data.autoIndex) {
|
||||
await indexDocument(data.path.trim(), uid);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
@@ -109,7 +131,7 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
|
||||
<select
|
||||
id="mc-agent"
|
||||
value={selectedAgentId || parentAgentId || ""}
|
||||
onChange={(e) => setSelectedAgentId(e.target.value)}
|
||||
onChange={(e) => setValue("selectedAgentId", e.target.value)}
|
||||
className="h-9 rounded-md border bg-background px-3 text-base md:text-sm"
|
||||
>
|
||||
<option value="">{t("createDialog.agentIdPlaceholder")}</option>
|
||||
@@ -132,7 +154,7 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
|
||||
type="button"
|
||||
variant={scopeMode === "global" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setScopeMode("global")}
|
||||
onClick={() => setValue("scopeMode", "global")}
|
||||
>
|
||||
{t("scopeLabel.global")}
|
||||
</Button>
|
||||
@@ -141,7 +163,7 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
|
||||
type="button"
|
||||
variant={scopeMode === "existing" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setScopeMode("existing")}
|
||||
onClick={() => setValue("scopeMode", "existing")}
|
||||
>
|
||||
{t("createDialog.existingScope")}
|
||||
</Button>
|
||||
@@ -150,7 +172,7 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
|
||||
type="button"
|
||||
variant={scopeMode === "custom" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setScopeMode("custom")}
|
||||
onClick={() => setValue("scopeMode", "custom")}
|
||||
>
|
||||
{t("createDialog.customScope")}
|
||||
</Button>
|
||||
@@ -158,7 +180,7 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
|
||||
{scopeMode === "existing" && knownUserIds.length > 0 && (
|
||||
<select
|
||||
value={selectedUserId}
|
||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||
onChange={(e) => setValue("selectedUserId", e.target.value)}
|
||||
className="h-9 rounded-md border bg-background px-3 text-base md:text-sm"
|
||||
>
|
||||
<option value="">{t("createDialog.selectGroupUser")}</option>
|
||||
@@ -171,10 +193,9 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
|
||||
)}
|
||||
{scopeMode === "custom" && (
|
||||
<Input
|
||||
value={customUserId}
|
||||
onChange={(e) => setCustomUserId(e.target.value)}
|
||||
placeholder="e.g. group:telegram:-100123456"
|
||||
className="font-mono text-sm"
|
||||
{...register("customUserId")}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -187,10 +208,9 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
|
||||
<Label htmlFor="mc-path">{t("createDialog.path")}</Label>
|
||||
<Input
|
||||
id="mc-path"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder={t("createDialog.pathPlaceholder")}
|
||||
className="font-mono text-sm"
|
||||
{...register("path")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -199,16 +219,15 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
|
||||
<Label htmlFor="mc-content">{t("createDialog.content")}</Label>
|
||||
<Textarea
|
||||
id="mc-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={t("createDialog.contentPlaceholder")}
|
||||
className="font-mono text-xs min-h-[200px]"
|
||||
rows={10}
|
||||
{...register("content")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch id="mc-index" checked={autoIndex} onCheckedChange={setAutoIndex} />
|
||||
<Switch id="mc-index" checked={autoIndex} onCheckedChange={(v) => setValue("autoIndex", v)} />
|
||||
<Label htmlFor="mc-index">{t("createDialog.autoIndex")}</Label>
|
||||
</div>
|
||||
|
||||
@@ -227,4 +246,3 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Save, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CHANNEL_TYPES } from "@/constants/channels";
|
||||
import type { TeamData, TeamAccessSettings, TeamNotifyConfig, EscalationMode, EscalationAction } from "@/types/team";
|
||||
import { useTeams } from "./hooks/use-teams";
|
||||
import { TeamNotificationsSection } from "./team-notifications-section";
|
||||
import { TeamAccessControlSection } from "./team-access-control-section";
|
||||
import { TeamOrchestrationSection } from "./team-orchestration-section";
|
||||
import { teamSettingsSchema, type TeamSettingsFormData } from "@/schemas/team-settings.schema";
|
||||
|
||||
interface TeamSettingsTabProps {
|
||||
teamId: string;
|
||||
@@ -15,130 +18,145 @@ interface TeamSettingsTabProps {
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function deriveDefaults(team: TeamData): TeamSettingsFormData {
|
||||
const s = (team.settings ?? {}) as TeamAccessSettings;
|
||||
const sn = s.notifications ?? {};
|
||||
const smr = s.member_requests ?? {};
|
||||
const sbe = s.blocker_escalation ?? {};
|
||||
return {
|
||||
notifyDispatched: sn.dispatched ?? true,
|
||||
notifyProgress: sn.progress ?? false,
|
||||
notifyFailed: sn.failed ?? false,
|
||||
notifyCompleted: sn.completed ?? true,
|
||||
notifyCommented: sn.commented ?? false,
|
||||
notifyNewTask: sn.new_task ?? true,
|
||||
notifySlowTool: sn.slow_tool ?? false,
|
||||
notifyMode: sn.mode ?? "direct",
|
||||
workspaceScope: s.workspace_scope ?? "isolated",
|
||||
memberRequestsEnabled: smr.enabled ?? false,
|
||||
memberRequestsAutoDispatch: smr.auto_dispatch ?? false,
|
||||
blockerEscalationEnabled: sbe.enabled ?? true,
|
||||
followupInterval: s.followup_interval_minutes ?? 30,
|
||||
followupMaxReminders: s.followup_max_reminders ?? 0,
|
||||
allowUserIds: s.allow_user_ids ?? [],
|
||||
denyUserIds: s.deny_user_ids ?? [],
|
||||
allowChannels: s.allow_channels ?? [],
|
||||
denyChannels: s.deny_channels ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const { updateTeamSettings } = useTeams();
|
||||
|
||||
const initial = (team.settings ?? {}) as TeamAccessSettings;
|
||||
const [allowUserIds, setAllowUserIds] = useState<string[]>(initial.allow_user_ids ?? []);
|
||||
const [denyUserIds, setDenyUserIds] = useState<string[]>(initial.deny_user_ids ?? []);
|
||||
const [allowChannels, setAllowChannels] = useState<string[]>(initial.allow_channels ?? []);
|
||||
const [denyChannels, setDenyChannels] = useState<string[]>(initial.deny_channels ?? []);
|
||||
const initNotify = initial.notifications ?? {};
|
||||
const [notifyDispatched, setNotifyDispatched] = useState(initNotify.dispatched ?? true);
|
||||
const [notifyProgress, setNotifyProgress] = useState(initNotify.progress ?? false);
|
||||
const [notifyFailed, setNotifyFailed] = useState(initNotify.failed ?? false);
|
||||
const [notifyCompleted, setNotifyCompleted] = useState(initNotify.completed ?? true);
|
||||
const [notifyCommented, setNotifyCommented] = useState(initNotify.commented ?? false);
|
||||
const [notifyNewTask, setNotifyNewTask] = useState(initNotify.new_task ?? true);
|
||||
const [notifySlowTool, setNotifySlowTool] = useState(initNotify.slow_tool ?? false);
|
||||
const [notifyMode, setNotifyMode] = useState<"direct" | "leader">(initNotify.mode ?? "direct");
|
||||
const initMemberRequests = initial.member_requests ?? {};
|
||||
const [memberRequestsEnabled, setMemberRequestsEnabled] = useState(initMemberRequests.enabled ?? false);
|
||||
const [memberRequestsAutoDispatch, setMemberRequestsAutoDispatch] = useState(initMemberRequests.auto_dispatch ?? false);
|
||||
const [escalationMode, setEscalationMode] = useState<EscalationMode | "">(initial.escalation_mode ?? "");
|
||||
const [escalationActions, setEscalationActions] = useState<EscalationAction[]>(initial.escalation_actions ?? []);
|
||||
const initBlockerEscalation = initial.blocker_escalation ?? {};
|
||||
const [blockerEscalationEnabled, setBlockerEscalationEnabled] = useState(initBlockerEscalation.enabled ?? true);
|
||||
const [followupInterval, setFollowupInterval] = useState<number>(initial.followup_interval_minutes ?? 30);
|
||||
const [followupMaxReminders, setFollowupMaxReminders] = useState<number>(initial.followup_max_reminders ?? 0);
|
||||
const [workspaceScope, setWorkspaceScope] = useState<string>(initial.workspace_scope ?? "isolated");
|
||||
// UI-only state
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const form = useForm<TeamSettingsFormData>({
|
||||
resolver: zodResolver(teamSettingsSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: deriveDefaults(team),
|
||||
});
|
||||
|
||||
const { watch, setValue, reset } = form;
|
||||
|
||||
// Sync when team prop changes (e.g. after refetch)
|
||||
useEffect(() => {
|
||||
const s = (team.settings ?? {}) as TeamAccessSettings;
|
||||
setAllowUserIds(s.allow_user_ids ?? []);
|
||||
setDenyUserIds(s.deny_user_ids ?? []);
|
||||
setAllowChannels(s.allow_channels ?? []);
|
||||
setDenyChannels(s.deny_channels ?? []);
|
||||
const sn = s.notifications ?? {};
|
||||
setNotifyDispatched(sn.dispatched ?? true);
|
||||
setNotifyProgress(sn.progress ?? false);
|
||||
setNotifyFailed(sn.failed ?? false);
|
||||
setNotifyCompleted(sn.completed ?? true);
|
||||
setNotifyCommented(sn.commented ?? false);
|
||||
setNotifyNewTask(sn.new_task ?? true);
|
||||
setNotifySlowTool(sn.slow_tool ?? false);
|
||||
setNotifyMode(sn.mode ?? "direct");
|
||||
const smr = s.member_requests ?? {};
|
||||
setMemberRequestsEnabled(smr.enabled ?? false);
|
||||
setMemberRequestsAutoDispatch(smr.auto_dispatch ?? false);
|
||||
setEscalationMode(s.escalation_mode ?? "");
|
||||
setEscalationActions(s.escalation_actions ?? []);
|
||||
const sbe = s.blocker_escalation ?? {};
|
||||
setBlockerEscalationEnabled(sbe.enabled ?? true);
|
||||
setFollowupInterval(s.followup_interval_minutes ?? 30);
|
||||
setFollowupMaxReminders(s.followup_max_reminders ?? 0);
|
||||
setWorkspaceScope(s.workspace_scope ?? "isolated");
|
||||
}, [team]);
|
||||
reset(deriveDefaults(team));
|
||||
}, [team, reset]);
|
||||
|
||||
const notifyDispatched = watch("notifyDispatched");
|
||||
const notifyProgress = watch("notifyProgress");
|
||||
const notifyFailed = watch("notifyFailed");
|
||||
const notifyCompleted = watch("notifyCompleted");
|
||||
const notifyCommented = watch("notifyCommented");
|
||||
const notifyNewTask = watch("notifyNewTask");
|
||||
const notifySlowTool = watch("notifySlowTool");
|
||||
const notifyMode = watch("notifyMode");
|
||||
const workspaceScope = watch("workspaceScope");
|
||||
const memberRequestsEnabled = watch("memberRequestsEnabled");
|
||||
const memberRequestsAutoDispatch = watch("memberRequestsAutoDispatch");
|
||||
const blockerEscalationEnabled = watch("blockerEscalationEnabled");
|
||||
const followupInterval = watch("followupInterval");
|
||||
const followupMaxReminders = watch("followupMaxReminders");
|
||||
const allowUserIds = watch("allowUserIds");
|
||||
const denyUserIds = watch("denyUserIds");
|
||||
const allowChannels = watch("allowChannels");
|
||||
const denyChannels = watch("denyChannels");
|
||||
|
||||
// escalationMode / escalationActions remain uncontrolled (not in schema) — read from team settings directly
|
||||
const initial = (team.settings ?? {}) as TeamAccessSettings;
|
||||
const escalationMode: EscalationMode | "" = initial.escalation_mode ?? "";
|
||||
const escalationActions: EscalationAction[] = initial.escalation_actions ?? [];
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = form.getValues();
|
||||
const settings: TeamAccessSettings = {};
|
||||
if (allowUserIds.length > 0) settings.allow_user_ids = allowUserIds;
|
||||
if (denyUserIds.length > 0) settings.deny_user_ids = denyUserIds;
|
||||
if (allowChannels.length > 0) settings.allow_channels = allowChannels;
|
||||
if (denyChannels.length > 0) settings.deny_channels = denyChannels;
|
||||
if (data.allowUserIds.length > 0) settings.allow_user_ids = data.allowUserIds;
|
||||
if (data.denyUserIds.length > 0) settings.deny_user_ids = data.denyUserIds;
|
||||
if (data.allowChannels.length > 0) settings.allow_channels = data.allowChannels;
|
||||
if (data.denyChannels.length > 0) settings.deny_channels = data.denyChannels;
|
||||
const notifications: TeamNotifyConfig = {
|
||||
dispatched: notifyDispatched,
|
||||
progress: notifyProgress,
|
||||
failed: notifyFailed,
|
||||
slow_tool: notifySlowTool,
|
||||
mode: notifyMode,
|
||||
completed: notifyCompleted,
|
||||
commented: notifyCommented,
|
||||
new_task: notifyNewTask,
|
||||
dispatched: data.notifyDispatched,
|
||||
progress: data.notifyProgress,
|
||||
failed: data.notifyFailed,
|
||||
slow_tool: data.notifySlowTool,
|
||||
mode: data.notifyMode,
|
||||
completed: data.notifyCompleted,
|
||||
commented: data.notifyCommented,
|
||||
new_task: data.notifyNewTask,
|
||||
};
|
||||
settings.notifications = notifications;
|
||||
if (memberRequestsEnabled) {
|
||||
settings.member_requests = { enabled: true, auto_dispatch: memberRequestsAutoDispatch };
|
||||
if (data.memberRequestsEnabled) {
|
||||
settings.member_requests = { enabled: true, auto_dispatch: data.memberRequestsAutoDispatch };
|
||||
}
|
||||
if (escalationMode) {
|
||||
settings.escalation_mode = escalationMode;
|
||||
if (escalationActions.length > 0) settings.escalation_actions = escalationActions;
|
||||
}
|
||||
settings.blocker_escalation = { enabled: blockerEscalationEnabled };
|
||||
if (followupInterval !== 30) settings.followup_interval_minutes = followupInterval;
|
||||
if (followupMaxReminders !== 0) settings.followup_max_reminders = followupMaxReminders;
|
||||
settings.workspace_scope = workspaceScope || "isolated";
|
||||
settings.blocker_escalation = { enabled: data.blockerEscalationEnabled };
|
||||
if (data.followupInterval !== 30) settings.followup_interval_minutes = data.followupInterval;
|
||||
if (data.followupMaxReminders !== 0) settings.followup_max_reminders = data.followupMaxReminders;
|
||||
settings.workspace_scope = data.workspaceScope || "isolated";
|
||||
await updateTeamSettings(teamId, settings);
|
||||
onSaved();
|
||||
} catch { // toast shown by hook
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [teamId, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifyCompleted, notifyCommented, notifyNewTask, notifySlowTool, notifyMode, memberRequestsEnabled, memberRequestsAutoDispatch, escalationMode, escalationActions, blockerEscalationEnabled, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved]);
|
||||
}, [teamId, form, escalationMode, escalationActions, updateTeamSettings, onSaved]);
|
||||
|
||||
const channelOptions = CHANNEL_TYPES.map((c) => ({ value: c.value, label: c.label }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TeamNotificationsSection
|
||||
notifyDispatched={notifyDispatched} setNotifyDispatched={setNotifyDispatched}
|
||||
notifyProgress={notifyProgress} setNotifyProgress={setNotifyProgress}
|
||||
notifyFailed={notifyFailed} setNotifyFailed={setNotifyFailed}
|
||||
notifyCompleted={notifyCompleted} setNotifyCompleted={setNotifyCompleted}
|
||||
notifyCommented={notifyCommented} setNotifyCommented={setNotifyCommented}
|
||||
notifyNewTask={notifyNewTask} setNotifyNewTask={setNotifyNewTask}
|
||||
notifySlowTool={notifySlowTool} setNotifySlowTool={setNotifySlowTool}
|
||||
notifyMode={notifyMode} setNotifyMode={setNotifyMode}
|
||||
notifyDispatched={notifyDispatched} setNotifyDispatched={(v) => setValue("notifyDispatched", v)}
|
||||
notifyProgress={notifyProgress} setNotifyProgress={(v) => setValue("notifyProgress", v)}
|
||||
notifyFailed={notifyFailed} setNotifyFailed={(v) => setValue("notifyFailed", v)}
|
||||
notifyCompleted={notifyCompleted} setNotifyCompleted={(v) => setValue("notifyCompleted", v)}
|
||||
notifyCommented={notifyCommented} setNotifyCommented={(v) => setValue("notifyCommented", v)}
|
||||
notifyNewTask={notifyNewTask} setNotifyNewTask={(v) => setValue("notifyNewTask", v)}
|
||||
notifySlowTool={notifySlowTool} setNotifySlowTool={(v) => setValue("notifySlowTool", v)}
|
||||
notifyMode={notifyMode} setNotifyMode={(v) => setValue("notifyMode", v)}
|
||||
/>
|
||||
|
||||
<TeamOrchestrationSection
|
||||
workspaceScope={workspaceScope} setWorkspaceScope={setWorkspaceScope}
|
||||
memberRequestsEnabled={memberRequestsEnabled} setMemberRequestsEnabled={setMemberRequestsEnabled}
|
||||
memberRequestsAutoDispatch={memberRequestsAutoDispatch} setMemberRequestsAutoDispatch={setMemberRequestsAutoDispatch}
|
||||
blockerEscalationEnabled={blockerEscalationEnabled} setBlockerEscalationEnabled={setBlockerEscalationEnabled}
|
||||
followupInterval={followupInterval} setFollowupInterval={setFollowupInterval}
|
||||
followupMaxReminders={followupMaxReminders} setFollowupMaxReminders={setFollowupMaxReminders}
|
||||
workspaceScope={workspaceScope} setWorkspaceScope={(v) => setValue("workspaceScope", v)}
|
||||
memberRequestsEnabled={memberRequestsEnabled} setMemberRequestsEnabled={(v) => setValue("memberRequestsEnabled", v)}
|
||||
memberRequestsAutoDispatch={memberRequestsAutoDispatch} setMemberRequestsAutoDispatch={(v) => setValue("memberRequestsAutoDispatch", v)}
|
||||
blockerEscalationEnabled={blockerEscalationEnabled} setBlockerEscalationEnabled={(v) => setValue("blockerEscalationEnabled", v)}
|
||||
followupInterval={followupInterval} setFollowupInterval={(v) => setValue("followupInterval", v)}
|
||||
followupMaxReminders={followupMaxReminders} setFollowupMaxReminders={(v) => setValue("followupMaxReminders", v)}
|
||||
/>
|
||||
|
||||
<TeamAccessControlSection
|
||||
allowUserIds={allowUserIds} setAllowUserIds={setAllowUserIds}
|
||||
denyUserIds={denyUserIds} setDenyUserIds={setDenyUserIds}
|
||||
allowChannels={allowChannels} setAllowChannels={setAllowChannels}
|
||||
denyChannels={denyChannels} setDenyChannels={setDenyChannels}
|
||||
allowUserIds={allowUserIds} setAllowUserIds={(v) => setValue("allowUserIds", v)}
|
||||
denyUserIds={denyUserIds} setDenyUserIds={(v) => setValue("denyUserIds", v)}
|
||||
allowChannels={allowChannels} setAllowChannels={(v) => setValue("allowChannels", v)}
|
||||
denyChannels={denyChannels} setDenyChannels={(v) => setValue("denyChannels", v)}
|
||||
channelOptions={channelOptions}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const cronAdvancedSchema = z.object({
|
||||
timezone: z.string(),
|
||||
deliver: z.boolean(),
|
||||
channel: z.string(),
|
||||
to: z.string(),
|
||||
wakeHeartbeat: z.boolean(),
|
||||
deleteAfterRun: z.boolean(),
|
||||
stateless: z.boolean(),
|
||||
});
|
||||
|
||||
export type CronAdvancedFormData = z.infer<typeof cronAdvancedSchema>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const mcpUserCredentialsSchema = z.object({
|
||||
apiKey: z.string(),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
env: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export type MCPUserCredentialsFormData = z.infer<typeof mcpUserCredentialsSchema>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const mcpFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
displayName: z.string(),
|
||||
transport: z.enum(["stdio", "sse", "streamable-http"]),
|
||||
command: z.string(),
|
||||
args: z.string(),
|
||||
url: z.string(),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
env: z.record(z.string(), z.string()),
|
||||
toolPrefix: z.string(),
|
||||
timeout: z.number().min(1),
|
||||
enabled: z.boolean(),
|
||||
requireUserCreds: z.boolean(),
|
||||
});
|
||||
|
||||
export type MCPFormData = z.infer<typeof mcpFormSchema>;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const memoryCreateSchema = z.object({
|
||||
selectedAgentId: z.string(),
|
||||
path: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
scopeMode: z.enum(["global", "existing", "custom"]),
|
||||
selectedUserId: z.string(),
|
||||
customUserId: z.string(),
|
||||
autoIndex: z.boolean(),
|
||||
});
|
||||
|
||||
export type MemoryCreateFormData = z.infer<typeof memoryCreateSchema>;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const teamSettingsSchema = z.object({
|
||||
// Notifications
|
||||
notifyDispatched: z.boolean(),
|
||||
notifyProgress: z.boolean(),
|
||||
notifyFailed: z.boolean(),
|
||||
notifyCompleted: z.boolean(),
|
||||
notifyCommented: z.boolean(),
|
||||
notifyNewTask: z.boolean(),
|
||||
notifySlowTool: z.boolean(),
|
||||
notifyMode: z.enum(["direct", "leader"]),
|
||||
// Orchestration
|
||||
workspaceScope: z.string(),
|
||||
memberRequestsEnabled: z.boolean(),
|
||||
memberRequestsAutoDispatch: z.boolean(),
|
||||
blockerEscalationEnabled: z.boolean(),
|
||||
followupInterval: z.number().min(1).max(1440),
|
||||
followupMaxReminders: z.number().min(0).max(100),
|
||||
// Access control
|
||||
allowUserIds: z.array(z.string()),
|
||||
denyUserIds: z.array(z.string()),
|
||||
allowChannels: z.array(z.string()),
|
||||
denyChannels: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type TeamSettingsFormData = z.infer<typeof teamSettingsSchema>;
|
||||
Reference in new issue
Block a user