feat(config): admin Configuration page (registry-driven)
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.
This commit is contained in:
parent
8e4754b0b0
commit
007458e61f
12 changed files with 311 additions and 2 deletions
|
|
@ -30,6 +30,7 @@ import Channels, { type ChannelStatusFilter, type ChannelsView } from "./compone
|
|||
import Playlists from "./components/Playlists";
|
||||
import Stats from "./components/Stats";
|
||||
import Scheduler from "./components/Scheduler";
|
||||
import ConfigPanel from "./components/ConfigPanel";
|
||||
import SettingsPanel from "./components/SettingsPanel";
|
||||
import NotificationsPanel from "./components/NotificationsPanel";
|
||||
import OnboardingWizard from "./components/OnboardingWizard";
|
||||
|
|
@ -469,6 +470,8 @@ export default function App() {
|
|||
<Stats me={meQuery.data!} />
|
||||
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
|
||||
<Scheduler />
|
||||
) : page === "config" && meQuery.data!.role === "admin" ? (
|
||||
<ConfigPanel />
|
||||
) : page === "playlists" ? (
|
||||
<Playlists canWrite={meQuery.data!.can_write} />
|
||||
) : page === "notifications" ? (
|
||||
|
|
|
|||
177
frontend/src/components/ConfigPanel.tsx
Normal file
177
frontend/src/components/ConfigPanel.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -75,7 +75,9 @@ export default function Header({
|
|||
? t("settings.title")
|
||||
: page === "scheduler"
|
||||
? t("header.scheduler")
|
||||
: t("header.channelManager")}
|
||||
: page === "config"
|
||||
? t("header.configuration")
|
||||
: t("header.channelManager")}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
LogOut,
|
||||
Settings,
|
||||
Shield,
|
||||
SlidersHorizontal,
|
||||
Tv,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
|
|
@ -138,7 +139,10 @@ export default function NavSidebar({
|
|||
];
|
||||
const systemItems: NavItem[] =
|
||||
me.role === "admin"
|
||||
? [{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") }]
|
||||
? [
|
||||
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
|
||||
{ page: "config", icon: SlidersHorizontal, label: t("header.account.config") },
|
||||
]
|
||||
: [];
|
||||
|
||||
const rowBase =
|
||||
|
|
|
|||
30
frontend/src/i18n/locales/de/config.json
Normal file
30
frontend/src/i18n/locales/de/config.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"intro": "In der Datenbank gespeicherte Betriebseinstellungen — sie überschreiben die Datei-/Env-Standardwerte und wirken ohne erneutes Deployment. Lass ein Feld leer (oder setze es zurück), um auf den Standard zurückzufallen.",
|
||||
"loading": "Konfiguration wird geladen…",
|
||||
"groups": {
|
||||
"email": "E-Mail / SMTP"
|
||||
},
|
||||
"fields": {
|
||||
"smtp_host": { "label": "SMTP-Host", "hint": "z. B. smtp.gmail.com" },
|
||||
"smtp_port": { "label": "SMTP-Port", "hint": "Üblicherweise 587 (STARTTLS)." },
|
||||
"smtp_user": { "label": "SMTP-Benutzername", "hint": "Das sendende Konto / der Login." },
|
||||
"smtp_from": { "label": "Absenderadresse", "hint": "z. B. Siftlode <addr@gmail.com>. Standard ist der Benutzername." },
|
||||
"smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." }
|
||||
},
|
||||
"save": "Speichern",
|
||||
"saved": "Gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"reset": "Zurücksetzen",
|
||||
"resetHint": "Diese Überschreibung entfernen und auf den Datei-/Env-Standard zurückfallen.",
|
||||
"resetDone": "Auf Standard zurückgesetzt",
|
||||
"usingDefault": "Standard wird verwendet",
|
||||
"overridden": "in der DB überschrieben",
|
||||
"secretSet": "•••••••• gespeichert",
|
||||
"secretEnv": "Env-Wert wird verwendet",
|
||||
"secretUnset": "nicht gesetzt",
|
||||
"secretsDisabled": "Setze TOKEN_ENCRYPTION_KEY, um Secrets in der Datenbank zu speichern.",
|
||||
"testEmail": "Test-E-Mail senden",
|
||||
"testEmailHint": "Sende eine Test-E-Mail an deine eigene Adresse mit den obigen Einstellungen, um zu prüfen, ob sie funktionieren.",
|
||||
"testSent": "Test-E-Mail gesendet an {{to}}",
|
||||
"testFailed": "Test-E-Mail fehlgeschlagen — prüfe die Einstellungen"
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
"channelManager": "Kanalverwaltung",
|
||||
"usageStats": "Nutzung & Statistik",
|
||||
"scheduler": "Planer",
|
||||
"configuration": "Konfiguration",
|
||||
"scope": {
|
||||
"label": "Feed-Quelle",
|
||||
"my": "Meine",
|
||||
|
|
@ -18,6 +19,7 @@
|
|||
"playlists": "Wiedergabelisten",
|
||||
"stats": "Statistik",
|
||||
"scheduler": "Planer",
|
||||
"config": "Konfiguration",
|
||||
"settings": "Einstellungen",
|
||||
"about": "Über",
|
||||
"signOut": "Abmelden",
|
||||
|
|
|
|||
30
frontend/src/i18n/locales/en/config.json
Normal file
30
frontend/src/i18n/locales/en/config.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"intro": "Operational settings stored in the database — these override the file/env defaults and take effect without a redeploy. Leave a field empty (or reset it) to fall back to the default.",
|
||||
"loading": "Loading configuration…",
|
||||
"groups": {
|
||||
"email": "Email / SMTP"
|
||||
},
|
||||
"fields": {
|
||||
"smtp_host": { "label": "SMTP host", "hint": "e.g. smtp.gmail.com" },
|
||||
"smtp_port": { "label": "SMTP port", "hint": "Usually 587 (STARTTLS)." },
|
||||
"smtp_user": { "label": "SMTP username", "hint": "The sending account / login." },
|
||||
"smtp_from": { "label": "From address", "hint": "e.g. Siftlode <addr@gmail.com>. Defaults to the username." },
|
||||
"smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." }
|
||||
},
|
||||
"save": "Save",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Couldn't save",
|
||||
"reset": "Reset",
|
||||
"resetHint": "Remove this override and fall back to the file/env default.",
|
||||
"resetDone": "Reset to default",
|
||||
"usingDefault": "using default",
|
||||
"overridden": "overridden in DB",
|
||||
"secretSet": "•••••••• stored",
|
||||
"secretEnv": "using env value",
|
||||
"secretUnset": "not set",
|
||||
"secretsDisabled": "Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
|
||||
"testEmail": "Send test email",
|
||||
"testEmailHint": "Send a test email to your own address using the settings above, to confirm they work.",
|
||||
"testSent": "Test email sent to {{to}}",
|
||||
"testFailed": "Test email failed — check the settings"
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
"channelManager": "Channel manager",
|
||||
"usageStats": "Usage & stats",
|
||||
"scheduler": "Scheduler",
|
||||
"configuration": "Configuration",
|
||||
"scope": {
|
||||
"label": "Feed source",
|
||||
"my": "Mine",
|
||||
|
|
@ -18,6 +19,7 @@
|
|||
"playlists": "Playlists",
|
||||
"stats": "Stats",
|
||||
"scheduler": "Scheduler",
|
||||
"config": "Configuration",
|
||||
"settings": "Settings",
|
||||
"about": "About",
|
||||
"signOut": "Sign out",
|
||||
|
|
|
|||
30
frontend/src/i18n/locales/hu/config.json
Normal file
30
frontend/src/i18n/locales/hu/config.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"intro": "Az adatbázisban tárolt működési beállítások — felülírják a fájl/env alapértékeket, és újratelepítés nélkül lépnek életbe. Hagyd üresen (vagy állítsd vissza) a mezőt az alapértékhez való visszatéréshez.",
|
||||
"loading": "Konfiguráció betöltése…",
|
||||
"groups": {
|
||||
"email": "E-mail / SMTP"
|
||||
},
|
||||
"fields": {
|
||||
"smtp_host": { "label": "SMTP-kiszolgáló", "hint": "pl. smtp.gmail.com" },
|
||||
"smtp_port": { "label": "SMTP-port", "hint": "Általában 587 (STARTTLS)." },
|
||||
"smtp_user": { "label": "SMTP-felhasználónév", "hint": "A küldő fiók / bejelentkezés." },
|
||||
"smtp_from": { "label": "Feladó cím", "hint": "pl. Siftlode <addr@gmail.com>. Alapból a felhasználónév." },
|
||||
"smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." }
|
||||
},
|
||||
"save": "Mentés",
|
||||
"saved": "Elmentve",
|
||||
"saveFailed": "Nem sikerült menteni",
|
||||
"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",
|
||||
"usingDefault": "alapérték használatban",
|
||||
"overridden": "felülírva az adatbázisban",
|
||||
"secretSet": "•••••••• tárolva",
|
||||
"secretEnv": "env-érték használatban",
|
||||
"secretUnset": "nincs beállítva",
|
||||
"secretsDisabled": "Állítsd be a TOKEN_ENCRYPTION_KEY-t a titkok adatbázisban tárolásához.",
|
||||
"testEmail": "Teszt-email küldése",
|
||||
"testEmailHint": "Teszt-email küldése a saját címedre a fenti beállításokkal, hogy ellenőrizd, működnek-e.",
|
||||
"testSent": "Teszt-email elküldve ide: {{to}}",
|
||||
"testFailed": "A teszt-email sikertelen — ellenőrizd a beállításokat"
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
"channelManager": "Csatornakezelő",
|
||||
"usageStats": "Használat és statisztika",
|
||||
"scheduler": "Ütemező",
|
||||
"configuration": "Konfiguráció",
|
||||
"scope": {
|
||||
"label": "Hírfolyam forrása",
|
||||
"my": "Sajátom",
|
||||
|
|
@ -18,6 +19,7 @@
|
|||
"playlists": "Lejátszási listák",
|
||||
"stats": "Statisztika",
|
||||
"scheduler": "Ütemező",
|
||||
"config": "Konfiguráció",
|
||||
"settings": "Beállítások",
|
||||
"about": "Névjegy",
|
||||
"signOut": "Kijelentkezés",
|
||||
|
|
|
|||
|
|
@ -440,6 +440,24 @@ export interface AppNotification {
|
|||
created_at: string | null;
|
||||
}
|
||||
|
||||
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
|
||||
export interface ConfigItem {
|
||||
key: string;
|
||||
type: "int" | "str" | "bool";
|
||||
group: string;
|
||||
secret: boolean;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
is_set: boolean; // a DB override row exists (else using the env/config default)
|
||||
value?: string | number | boolean; // non-secret: effective value
|
||||
default?: string | number | boolean; // non-secret: env/config default
|
||||
default_is_set?: boolean; // secret: whether an env default exists
|
||||
}
|
||||
export interface SystemConfigData {
|
||||
groups: Record<string, ConfigItem[]>;
|
||||
secrets_manageable: boolean;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: (): Promise<Me> => req("/api/me"),
|
||||
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
||||
|
|
@ -548,6 +566,14 @@ export const api = {
|
|||
// --- quota usage ---
|
||||
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
||||
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
||||
// --- admin: system configuration ---
|
||||
adminConfig: (): Promise<SystemConfigData> => req("/api/admin/config"),
|
||||
setConfig: (key: string, value: string | number | boolean): Promise<SystemConfigData> =>
|
||||
req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }),
|
||||
resetConfig: (key: string): Promise<SystemConfigData> =>
|
||||
req(`/api/admin/config/${key}`, { method: "DELETE" }),
|
||||
testEmail: (): Promise<{ sent: boolean; to: string }> =>
|
||||
req("/api/admin/config/test-email", { method: "POST" }),
|
||||
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
|
||||
updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
|
||||
req(`/api/admin/scheduler/jobs/${jobId}`, {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export const PAGES = [
|
|||
"playlists",
|
||||
"settings",
|
||||
"scheduler",
|
||||
"config",
|
||||
"notifications",
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue