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:
viettranx committed 2026-04-03 22:50:04 +07:00
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>
+99 -59
View File
@@ -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_&#123;prefix&#125;__&#123;tool&#125;</code></p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="mcp-timeout">{t("form.timeout")}</Label>
<Input id="mcp-timeout" type="number" value={timeout} onChange={(e) => 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>
);
}
+104 -86
View File
@@ -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>;
+18
View File
@@ -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>;
+13
View File
@@ -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>;