refactor(config): one Save/Discard bar instead of per-field Save

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.
This commit is contained in:
npeter83 2026-06-19 12:40:23 +02:00
parent 2fc3552aa7
commit 2837574196
4 changed files with 171 additions and 81 deletions

View file

@ -1,15 +1,18 @@
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { RotateCcw, Save, Send } from "lucide-react"; import { Check, RotateCcw, Save, Send } from "lucide-react";
import { api, type ConfigItem, type SystemConfigData } from "../lib/api"; import { api, type ConfigItem } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from // Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// the backend — adding a key there makes it appear here automatically). Group order is fixed // backend — adding a key there makes it appear here automatically). Edits are drafted and
// where known so logically related settings (e.g. Email/SMTP) stay together. // 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"]; const GROUP_ORDER = ["email"];
type SaveState = "idle" | "saving" | "saved" | "error";
export default function ConfigPanel() { export default function ConfigPanel() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -17,32 +20,71 @@ export default function ConfigPanel() {
const q = useQuery({ queryKey: ["admin-config"], queryFn: api.adminConfig }); const q = useQuery({ queryKey: ["admin-config"], queryFn: api.adminConfig });
const data = q.data; const data = q.data;
const apply = (fresh: SystemConfigData) => qc.setQueryData(["admin-config"], fresh); const items = useMemo(() => (data ? Object.values(data.groups).flat() : []), [data]);
const save = useMutation({ const byKey = useMemo(() => Object.fromEntries(items.map((i) => [i.key, i])), [items]);
mutationFn: ({ key, value }: { key: string; value: string | number | boolean }) => // Baseline draft from the server: non-secrets show their effective value; secrets start empty
api.setConfig(key, value), // (write-only — we never load them back).
onSuccess: (fresh) => { const baseline = useMemo(() => {
apply(fresh); const m: Record<string, string> = {};
notify({ level: "success", message: t("config.saved") }); for (const it of items) m[it.key] = it.secret ? "" : String(it.value ?? "");
}, return m;
onError: () => notify({ level: "error", message: t("config.saveFailed") }), }, [items]);
});
const reset = useMutation({ const [draft, setDraft] = useState<Record<string, string>>({});
mutationFn: (key: string) => api.resetConfig(key), // Secrets the admin marked to reset-to-default on the next Save (can't be expressed by
onSuccess: (fresh) => { // clearing the field, since an empty secret field means "leave unchanged").
apply(fresh); const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
notify({ level: "success", message: t("config.resetDone") }); const [saveState, setSaveState] = useState<SaveState>("idle");
},
onError: () => notify({ level: "error", message: t("config.saveFailed") }), // 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({ const testEmail = useMutation({
mutationFn: () => api.testEmail(), mutationFn: () => api.testEmail(),
onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }), onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }),
onError: () => notify({ level: "error", message: t("config.testFailed") }), onError: () => notify({ level: "error", message: t("config.testFailed") }),
}); });
const busy = save.isPending || reset.isPending;
if (q.isLoading || !data) { if (q.isLoading || !data) {
return <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>; return <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>;
} }
@ -52,7 +94,7 @@ export default function ConfigPanel() {
); );
return ( return (
<div className="p-4 max-w-3xl w-full mx-auto"> <div className="p-4 max-w-3xl w-full mx-auto pb-24">
<p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p> <p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p>
{groupKeys.map((g) => ( {groupKeys.map((g) => (
@ -63,12 +105,16 @@ export default function ConfigPanel() {
<div className="divide-y divide-border/60"> <div className="divide-y divide-border/60">
{data.groups[g].map((item) => ( {data.groups[g].map((item) => (
<Field <Field
key={`${item.key}:${item.is_set}:${String(item.value ?? "")}`} key={item.key}
item={item} item={item}
value={draft[item.key] ?? ""}
secretsManageable={data.secrets_manageable} secretsManageable={data.secrets_manageable}
busy={busy} pendingSecretReset={!!secretReset[item.key]}
onSave={(value) => save.mutate({ key: item.key, value })} onChange={(v) => setDraft((d) => ({ ...d, [item.key]: v }))}
onReset={() => reset.mutate(item.key)} onResetField={() => {
if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] }));
else setDraft((d) => ({ ...d, [item.key]: "" }));
}}
/> />
))} ))}
</div> </div>
@ -78,7 +124,7 @@ export default function ConfigPanel() {
<Tooltip hint={t("config.testEmailHint")}> <Tooltip hint={t("config.testEmailHint")}>
<button <button
onClick={() => testEmail.mutate()} onClick={() => testEmail.mutate()}
disabled={testEmail.isPending} disabled={testEmail.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
> >
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} /> <Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
@ -89,36 +135,83 @@ export default function ConfigPanel() {
)} )}
</div> </div>
))} ))}
{(dirty || saveState !== "idle") && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]">
<span className="text-sm text-muted">
{saveState === "saved" ? (
<span className="text-emerald-500 inline-flex items-center gap-1.5">
<Check className="w-4 h-4" />
{t("config.saved")}
</span>
) : saveState === "error" ? (
<span className="text-red-500">{t("config.saveFailed")}</span>
) : (
t("config.unsaved")
)}
</span>
<div className="flex items-center gap-2">
<button
onClick={discard}
disabled={saveState === "saving" || !dirty}
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
>
<RotateCcw className="w-4 h-4" />
{t("config.discard")}
</button>
<button
onClick={save}
disabled={saveState === "saving" || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
>
<Save className="w-4 h-4" />
{saveState === "saving" ? t("config.saving") : t("config.save")}
</button>
</div>
</div>
)}
</div> </div>
); );
} }
function Field({ function Field({
item, item,
value,
secretsManageable, secretsManageable,
busy, pendingSecretReset,
onSave, onChange,
onReset, onResetField,
}: { }: {
item: ConfigItem; item: ConfigItem;
value: string;
secretsManageable: boolean; secretsManageable: boolean;
busy: boolean; pendingSecretReset: boolean;
onSave: (value: string | number) => void; onChange: (v: string) => void;
onReset: () => void; onResetField: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isNum = item.type === "int"; const isNum = item.type === "int";
const [draft, setDraft] = useState<string>(item.secret ? "" : String(item.value ?? ""));
const secretDisabled = item.secret && !secretsManageable; const secretDisabled = item.secret && !secretsManageable;
// Status line for secrets (never reveal the value): stored override / env value / unset.
const secretStatus = item.is_set // 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") ? t("config.secretSet")
: item.default_is_set : item.default_is_set
? t("config.secretEnv") ? t("config.secretEnv")
: t("config.secretUnset"); : t("config.secretUnset");
} else {
status = item.is_set ? t("config.overridden") : t("config.usingDefault");
}
const canSave = item.secret ? draft.trim().length > 0 && !secretDisabled : true; // 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 ( return (
<div className="flex items-start justify-between gap-3 py-3"> <div className="flex items-start justify-between gap-3 py-3">
@ -127,50 +220,35 @@ function Field({
<p className="text-xs text-muted leading-relaxed mt-0.5"> <p className="text-xs text-muted leading-relaxed mt-0.5">
{t(`config.fields.${item.key}.hint`, "")} {t(`config.fields.${item.key}.hint`, "")}
</p> </p>
{item.secret ? ( <p className="text-[11px] text-muted mt-1">{status}</p>
<p className="text-[11px] text-muted mt-1">
{secretDisabled ? t("config.secretsDisabled") : secretStatus}
</p>
) : (
<p className="text-[11px] text-muted mt-1">
{item.is_set ? t("config.overridden") : t("config.usingDefault")}
</p>
)}
</div> </div>
<div className="flex flex-col items-end gap-1.5 shrink-0"> <div className="flex flex-col items-end gap-1.5 shrink-0">
<input <input
type={item.secret ? "password" : isNum ? "number" : "text"} type={item.secret ? "password" : isNum ? "number" : "text"}
value={draft} value={value}
disabled={secretDisabled} disabled={secretDisabled || (item.secret && pendingSecretReset)}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => onChange(e.target.value)}
min={isNum && item.min != null ? item.min : undefined} min={isNum && item.min != null ? item.min : undefined}
max={isNum && item.max != null ? item.max : undefined} max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")} 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" 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"
/> />
<div className="flex items-center gap-1.5"> {showReset && !secretDisabled && (
{item.is_set && (
<Tooltip hint={t("config.resetHint")}> <Tooltip hint={t("config.resetHint")}>
<button <button
onClick={onReset} onClick={onResetField}
disabled={busy} className={`inline-flex items-center gap-1 text-xs transition ${
className="glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition" item.secret && pendingSecretReset
? "text-accent"
: "text-muted hover:text-fg"
}`}
> >
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
{t("config.reset")} {t("config.reset")}
</button> </button>
</Tooltip> </Tooltip>
)} )}
<button
onClick={() => onSave(isNum ? Number(draft) : draft)}
disabled={busy || !canSave}
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs bg-accent text-accent-fg font-medium disabled:opacity-40 transition"
>
<Save className="w-3.5 h-3.5" />
{t("config.save")}
</button>
</div>
</div> </div>
</div> </div>
); );

View file

@ -12,8 +12,12 @@
"smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." } "smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." }
}, },
"save": "Speichern", "save": "Speichern",
"saving": "Speichern…",
"saved": "Gespeichert", "saved": "Gespeichert",
"saveFailed": "Speichern fehlgeschlagen", "saveFailed": "Speichern fehlgeschlagen",
"unsaved": "Nicht gespeicherte Änderungen",
"discard": "Verwerfen",
"willReset": "wird beim Speichern auf den Standard zurückgesetzt",
"reset": "Zurücksetzen", "reset": "Zurücksetzen",
"resetHint": "Diese Überschreibung entfernen und auf den Datei-/Env-Standard zurückfallen.", "resetHint": "Diese Überschreibung entfernen und auf den Datei-/Env-Standard zurückfallen.",
"resetDone": "Auf Standard zurückgesetzt", "resetDone": "Auf Standard zurückgesetzt",

View file

@ -12,8 +12,12 @@
"smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." } "smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." }
}, },
"save": "Save", "save": "Save",
"saving": "Saving…",
"saved": "Saved", "saved": "Saved",
"saveFailed": "Couldn't save", "saveFailed": "Couldn't save",
"unsaved": "Unsaved changes",
"discard": "Discard",
"willReset": "will reset to default on save",
"reset": "Reset", "reset": "Reset",
"resetHint": "Remove this override and fall back to the file/env default.", "resetHint": "Remove this override and fall back to the file/env default.",
"resetDone": "Reset to default", "resetDone": "Reset to default",

View file

@ -12,8 +12,12 @@
"smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." } "smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." }
}, },
"save": "Mentés", "save": "Mentés",
"saving": "Mentés…",
"saved": "Elmentve", "saved": "Elmentve",
"saveFailed": "Nem sikerült menteni", "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", "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.", "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", "resetDone": "Visszaállítva az alapértékre",