New admin-only Configuration page that renders the system_config registry grouped (Email/SMTP first), with per-field save/reset, write-only encrypted secret fields (disabled with a hint when TOKEN_ENCRYPTION_KEY is unset), and a Send-test-email button. New 'config' page + sidebar nav item + header title; api methods and EN/HU/DE strings.
177 lines
6.7 KiB
TypeScript
177 lines
6.7 KiB
TypeScript
import { 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 { 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.
|
|
const GROUP_ORDER = ["email"];
|
|
|
|
export default function ConfigPanel() {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
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 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 <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>;
|
|
}
|
|
|
|
const groupKeys = Object.keys(data.groups).sort(
|
|
(a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99)
|
|
);
|
|
|
|
return (
|
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
|
<p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p>
|
|
|
|
{groupKeys.map((g) => (
|
|
<div key={g} className="glass rounded-2xl p-4 mb-4">
|
|
<div className="text-xs uppercase tracking-wide text-muted mb-3">
|
|
{t(`config.groups.${g}`, g)}
|
|
</div>
|
|
<div className="divide-y divide-border/60">
|
|
{data.groups[g].map((item) => (
|
|
<Field
|
|
key={`${item.key}:${item.is_set}:${String(item.value ?? "")}`}
|
|
item={item}
|
|
secretsManageable={data.secrets_manageable}
|
|
busy={busy}
|
|
onSave={(value) => save.mutate({ key: item.key, value })}
|
|
onReset={() => reset.mutate(item.key)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{g === "email" && (
|
|
<div className="mt-3 pt-3 border-t border-border/60">
|
|
<Tooltip hint={t("config.testEmailHint")}>
|
|
<button
|
|
onClick={() => testEmail.mutate()}
|
|
disabled={testEmail.isPending}
|
|
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" : ""}`} />
|
|
{t("config.testEmail")}
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({
|
|
item,
|
|
secretsManageable,
|
|
busy,
|
|
onSave,
|
|
onReset,
|
|
}: {
|
|
item: ConfigItem;
|
|
secretsManageable: boolean;
|
|
busy: boolean;
|
|
onSave: (value: string | number) => void;
|
|
onReset: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const isNum = item.type === "int";
|
|
const [draft, setDraft] = useState<string>(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;
|
|
|
|
return (
|
|
<div className="flex items-start justify-between gap-3 py-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium">{t(`config.fields.${item.key}.label`, item.key)}</div>
|
|
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
|
{t(`config.fields.${item.key}.hint`, "")}
|
|
</p>
|
|
{item.secret ? (
|
|
<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 className="flex flex-col items-end gap-1.5 shrink-0">
|
|
<input
|
|
type={item.secret ? "password" : isNum ? "number" : "text"}
|
|
value={draft}
|
|
disabled={secretDisabled}
|
|
onChange={(e) => setDraft(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"
|
|
/>
|
|
<div className="flex items-center gap-1.5">
|
|
{item.is_set && (
|
|
<Tooltip hint={t("config.resetHint")}>
|
|
<button
|
|
onClick={onReset}
|
|
disabled={busy}
|
|
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"
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
{t("config.reset")}
|
|
</button>
|
|
</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>
|
|
);
|
|
}
|