From a71257f33f9f87a9d27cf65b4541ee89badb5bf2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 12:23:00 +0200 Subject: [PATCH] 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. --- frontend/src/App.tsx | 3 + frontend/src/components/ConfigPanel.tsx | 177 +++++++++++++++++++++++ frontend/src/components/Header.tsx | 4 +- frontend/src/components/NavSidebar.tsx | 6 +- frontend/src/i18n/locales/de/config.json | 30 ++++ frontend/src/i18n/locales/de/header.json | 2 + frontend/src/i18n/locales/en/config.json | 30 ++++ frontend/src/i18n/locales/en/header.json | 2 + frontend/src/i18n/locales/hu/config.json | 30 ++++ frontend/src/i18n/locales/hu/header.json | 2 + frontend/src/lib/api.ts | 26 ++++ frontend/src/lib/urlState.ts | 1 + 12 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/ConfigPanel.tsx create mode 100644 frontend/src/i18n/locales/de/config.json create mode 100644 frontend/src/i18n/locales/en/config.json create mode 100644 frontend/src/i18n/locales/hu/config.json diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e85e97..b807b22 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { ) : page === "scheduler" && meQuery.data!.role === "admin" ? ( + ) : page === "config" && meQuery.data!.role === "admin" ? ( + ) : page === "playlists" ? ( ) : page === "notifications" ? ( diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx new file mode 100644 index 0000000..deba458 --- /dev/null +++ b/frontend/src/components/ConfigPanel.tsx @@ -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
{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) => ( + save.mutate({ key: item.key, value })} + onReset={() => reset.mutate(item.key)} + /> + ))} +
+ + {g === "email" && ( +
+ + + +
+ )} +
+ ))} +
+ ); +} + +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(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 ( +
+
+
{t(`config.fields.${item.key}.label`, item.key)}
+

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

+ {item.secret ? ( +

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

+ ) : ( +

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

+ )} +
+ +
+ 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" + /> +
+ {item.is_set && ( + + + + )} + +
+
+
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 72d498e..c366c1c 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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")} )} diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index e229011..269c0b7 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -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 = diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json new file mode 100644 index 0000000..ce9e69e --- /dev/null +++ b/frontend/src/i18n/locales/de/config.json @@ -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 . 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" +} diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json index 2fcc887..7c10542 100644 --- a/frontend/src/i18n/locales/de/header.json +++ b/frontend/src/i18n/locales/de/header.json @@ -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", diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json new file mode 100644 index 0000000..c466900 --- /dev/null +++ b/frontend/src/i18n/locales/en/config.json @@ -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 . 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" +} diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index 0c027fe..ce2923c 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json new file mode 100644 index 0000000..0dbb1ee --- /dev/null +++ b/frontend/src/i18n/locales/hu/config.json @@ -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 . 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" +} diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index f2d6531..ec12128 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -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", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 57e24b6..7372458 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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; + secrets_manageable: boolean; +} + export const api = { me: (): Promise => req("/api/me"), accounts: (): Promise => req("/api/me/accounts"), @@ -548,6 +566,14 @@ export const api = { // --- quota usage --- myUsage: (): Promise => req("/api/quota/my-usage"), adminQuota: (days = 30): Promise => req(`/api/quota/admin?days=${days}`), + // --- admin: system configuration --- + adminConfig: (): Promise => req("/api/admin/config"), + setConfig: (key: string, value: string | number | boolean): Promise => + req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }), + resetConfig: (key: string): Promise => + req(`/api/admin/config/${key}`, { method: "DELETE" }), + testEmail: (): Promise<{ sent: boolean; to: string }> => + req("/api/admin/config/test-email", { method: "POST" }), schedulerStatus: (): Promise => req("/api/admin/scheduler"), updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> => req(`/api/admin/scheduler/jobs/${jobId}`, { diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 8483ee7..e1ff998 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -88,6 +88,7 @@ export const PAGES = [ "playlists", "settings", "scheduler", + "config", "notifications", ] as const;