From 8198c7712e2f81aa76ad81a7c03aa6683db16800 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 12:40:23 +0200 Subject: [PATCH] refactor(config): one Save/Discard bar instead of per-field Save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-field Save buttons were an unconventional, endpoint-driven choice. Replace them with a single drafted Save/Discard bar (consistent with the Settings page): edits are buffered and applied together on Save (each via the existing per-key PATCH/DELETE), Discard reverts. Reset-to-default is a per-field affordance applied on Save too — clearing a non-secret falls back to default; a secret (which can't be cleared by emptying) gets a toggle that resets it on save. Backend unchanged. --- frontend/src/components/ConfigPanel.tsx | 240 +++++++++++++++-------- frontend/src/i18n/locales/de/config.json | 4 + frontend/src/i18n/locales/en/config.json | 4 + frontend/src/i18n/locales/hu/config.json | 4 + 4 files changed, 171 insertions(+), 81 deletions(-) diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index deba458..49d69c9 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -1,15 +1,18 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { RotateCcw, Save, Send } from "lucide-react"; -import { api, type ConfigItem, type SystemConfigData } from "../lib/api"; +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). Group order is fixed -// where known so logically related settings (e.g. Email/SMTP) stay together. +// 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(); @@ -17,32 +20,71 @@ export default function ConfigPanel() { const q = useQuery({ queryKey: ["admin-config"], queryFn: api.adminConfig }); const data = q.data; - const apply = (fresh: SystemConfigData) => qc.setQueryData(["admin-config"], fresh); - const save = useMutation({ - mutationFn: ({ key, value }: { key: string; value: string | number | boolean }) => - api.setConfig(key, value), - onSuccess: (fresh) => { - apply(fresh); - notify({ level: "success", message: t("config.saved") }); - }, - onError: () => notify({ level: "error", message: t("config.saveFailed") }), - }); - const reset = useMutation({ - mutationFn: (key: string) => api.resetConfig(key), - onSuccess: (fresh) => { - apply(fresh); - notify({ level: "success", message: t("config.resetDone") }); - }, - onError: () => notify({ level: "error", message: t("config.saveFailed") }), - }); + 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") }), }); - const busy = save.isPending || reset.isPending; - if (q.isLoading || !data) { return
{t("config.loading")}
; } @@ -52,7 +94,7 @@ export default function ConfigPanel() { ); return ( -
+

{t("config.intro")}

{groupKeys.map((g) => ( @@ -63,12 +105,16 @@ export default function ConfigPanel() {
{data.groups[g].map((item) => ( save.mutate({ key: item.key, value })} - onReset={() => reset.mutate(item.key)} + pendingSecretReset={!!secretReset[item.key]} + onChange={(v) => setDraft((d) => ({ ...d, [item.key]: v }))} + onResetField={() => { + if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] })); + else setDraft((d) => ({ ...d, [item.key]: "" })); + }} /> ))}
@@ -78,7 +124,7 @@ export default function ConfigPanel() {
))} + + {(dirty || saveState !== "idle") && ( +
+ + {saveState === "saved" ? ( + + + {t("config.saved")} + + ) : saveState === "error" ? ( + {t("config.saveFailed")} + ) : ( + t("config.unsaved") + )} + +
+ + +
+
+ )}
); } function Field({ item, + value, secretsManageable, - busy, - onSave, - onReset, + pendingSecretReset, + onChange, + onResetField, }: { item: ConfigItem; + value: string; secretsManageable: boolean; - busy: boolean; - onSave: (value: string | number) => void; - onReset: () => void; + pendingSecretReset: boolean; + onChange: (v: string) => void; + onResetField: () => void; }) { const { t } = useTranslation(); const isNum = item.type === "int"; - const [draft, setDraft] = useState(item.secret ? "" : String(item.value ?? "")); - const secretDisabled = item.secret && !secretsManageable; - // Status line for secrets (never reveal the value): stored override / env value / unset. - const secretStatus = item.is_set - ? t("config.secretSet") - : item.default_is_set - ? t("config.secretEnv") - : t("config.secretUnset"); - const canSave = item.secret ? draft.trim().length > 0 && !secretDisabled : true; + // 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 (
@@ -127,50 +220,35 @@ function Field({

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

- {item.secret ? ( -

- {secretDisabled ? t("config.secretsDisabled") : secretStatus} -

- ) : ( -

- {item.is_set ? t("config.overridden") : t("config.usingDefault")} -

- )} +

{status}

setDraft(e.target.value)} + value={value} + disabled={secretDisabled || (item.secret && pendingSecretReset)} + onChange={(e) => 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" /> -
- {item.is_set && ( - - - - )} - -
+ {showReset && !secretDisabled && ( + + + + )}
); diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json index ce9e69e..772bcfb 100644 --- a/frontend/src/i18n/locales/de/config.json +++ b/frontend/src/i18n/locales/de/config.json @@ -12,8 +12,12 @@ "smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." } }, "save": "Speichern", + "saving": "Speichern…", "saved": "Gespeichert", "saveFailed": "Speichern fehlgeschlagen", + "unsaved": "Nicht gespeicherte Änderungen", + "discard": "Verwerfen", + "willReset": "wird beim Speichern auf den Standard zurückgesetzt", "reset": "Zurücksetzen", "resetHint": "Diese Überschreibung entfernen und auf den Datei-/Env-Standard zurückfallen.", "resetDone": "Auf Standard zurückgesetzt", diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index c466900..cd5c4b1 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -12,8 +12,12 @@ "smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." } }, "save": "Save", + "saving": "Saving…", "saved": "Saved", "saveFailed": "Couldn't save", + "unsaved": "Unsaved changes", + "discard": "Discard", + "willReset": "will reset to default on save", "reset": "Reset", "resetHint": "Remove this override and fall back to the file/env default.", "resetDone": "Reset to default", diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index 0dbb1ee..2cdcb8b 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -12,8 +12,12 @@ "smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." } }, "save": "Mentés", + "saving": "Mentés…", "saved": "Elmentve", "saveFailed": "Nem sikerült menteni", + "unsaved": "Nem mentett változások", + "discard": "Elvetés", + "willReset": "mentéskor visszaáll alapértékre", "reset": "Visszaállítás", "resetHint": "Eltávolítja ezt a felülírást, és visszatér a fájl/env alapértékhez.", "resetDone": "Visszaállítva az alapértékre",