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;