import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, RotateCcw, Save, Send } from "lucide-react"; import { api, type ConfigItem } from "../lib/api"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; // Admin Configuration page: edit DB-overridable operational settings (registry-driven from the // backend — adding a key there makes it appear here automatically). Edits are drafted and // applied together via one Save/Discard bar (consistent with the Settings page); nothing hits // the server until Save. Group order is fixed where known so logically related settings (e.g. // Email/SMTP) stay together. const GROUP_ORDER = ["email"]; type SaveState = "idle" | "saving" | "saved" | "error"; export default function ConfigPanel() { const { t } = useTranslation(); const qc = useQueryClient(); const q = useQuery({ queryKey: ["admin-config"], queryFn: api.adminConfig }); const data = q.data; const items = useMemo(() => (data ? Object.values(data.groups).flat() : []), [data]); const byKey = useMemo(() => Object.fromEntries(items.map((i) => [i.key, i])), [items]); // Baseline draft from the server: non-secrets show their effective value; secrets start empty // (write-only — we never load them back). const baseline = useMemo(() => { const m: Record = {}; for (const it of items) m[it.key] = it.secret ? "" : String(it.value ?? ""); return m; }, [items]); const [draft, setDraft] = useState>({}); // Secrets the admin marked to reset-to-default on the next Save (can't be expressed by // clearing the field, since an empty secret field means "leave unchanged"). const [secretReset, setSecretReset] = useState>({}); const [saveState, setSaveState] = useState("idle"); // Re-seed the draft whenever the server data changes (initial load + after a save/reset). useEffect(() => { setDraft(baseline); setSecretReset({}); }, [baseline]); const isDirty = (it: ConfigItem): boolean => { if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key]; return (draft[it.key] ?? "") !== (baseline[it.key] ?? ""); }; const dirtyKeys = items.filter(isDirty).map((i) => i.key); const dirty = dirtyKeys.length > 0; async function save() { setSaveState("saving"); try { for (const key of dirtyKeys) { const it = byKey[key]; const raw = draft[key] ?? ""; if (it.secret) { if (secretReset[key] && !raw.trim()) await api.resetConfig(key); else if (raw.trim()) await api.setConfig(key, raw); } else if (raw === "") { if (it.is_set) await api.resetConfig(key); // cleared → fall back to default } else { await api.setConfig(key, it.type === "int" ? Number(raw) : raw); } } await qc.invalidateQueries({ queryKey: ["admin-config"] }); setSaveState("saved"); setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500); } catch { setSaveState("error"); notify({ level: "error", message: t("config.saveFailed") }); } } function discard() { setDraft(baseline); setSecretReset({}); setSaveState("idle"); } const testEmail = useMutation({ mutationFn: () => api.testEmail(), onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }), onError: () => notify({ level: "error", message: t("config.testFailed") }), }); if (q.isLoading || !data) { return
{t("config.loading")}
; } const groupKeys = Object.keys(data.groups).sort( (a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99) ); return (

{t("config.intro")}

{groupKeys.map((g) => (
{t(`config.groups.${g}`, g)}
{data.groups[g].map((item) => ( setDraft((d) => ({ ...d, [item.key]: v }))} onResetField={() => { if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] })); else setDraft((d) => ({ ...d, [item.key]: "" })); }} /> ))}
{g === "email" && (
)}
))} {(dirty || saveState !== "idle") && (
{saveState === "saved" ? ( {t("config.saved")} ) : saveState === "error" ? ( {t("config.saveFailed")} ) : ( t("config.unsaved") )}
)}
); } function Field({ item, value, secretsManageable, pendingSecretReset, onChange, onResetField, }: { item: ConfigItem; value: string; secretsManageable: boolean; pendingSecretReset: boolean; onChange: (v: string) => void; onResetField: () => void; }) { const { t } = useTranslation(); const isNum = item.type === "int"; const secretDisabled = item.secret && !secretsManageable; // Status line: never reveal a secret's value. let status: string; if (item.secret) { status = secretDisabled ? t("config.secretsDisabled") : pendingSecretReset ? t("config.willReset") : item.is_set ? t("config.secretSet") : item.default_is_set ? t("config.secretEnv") : t("config.secretUnset"); } else { status = item.is_set ? t("config.overridden") : t("config.usingDefault"); } // Reset affordance shows when a DB override exists: clears a non-secret to its default, or // toggles a secret's reset-on-save (applied on Save, not immediately). const showReset = item.is_set; return (
{t(`config.fields.${item.key}.label`, item.key)}

{t(`config.fields.${item.key}.hint`, "")}

{status}

onChange(e.target.value)} min={isNum && item.min != null ? item.min : undefined} max={isNum && item.max != null ? item.max : undefined} placeholder={item.secret ? "••••••••" : String(item.default ?? "")} className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-none focus:border-accent disabled:opacity-50" /> {showReset && !secretDisabled && ( )}
); }