From 8e4754b0b02113382b6dfbc3a58c9a451a78e6d0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 12:22:36 +0200 Subject: [PATCH 1/2] feat(config): DB-backed system_config infrastructure + SMTP group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a generic admin-editable config layer (epic 4a): system_config KV table (migration 0021), a sysconfig registry that is the single source of truth for DB-overridable keys (type/group/default/bounds/secret) + a DB-override-or-env resolver, and admin endpoints (GET/PATCH/DELETE /api/admin/config + a test-email probe). Secrets are Fernet-encrypted at rest (TOKEN_ENCRYPTION_KEY); without it they stay env-only. First group wired end-to-end: Email/SMTP — email.py now reads host/port/user/from/password via the resolver (own session, since email is sent from background tasks). --- .../alembic/versions/0021_system_config.py | 38 +++++ backend/app/email.py | 36 +++- backend/app/main.py | 2 + backend/app/models.py | 18 ++ backend/app/routes/config.py | 68 ++++++++ backend/app/sysconfig.py | 157 ++++++++++++++++++ 6 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/0021_system_config.py create mode 100644 backend/app/routes/config.py create mode 100644 backend/app/sysconfig.py diff --git a/backend/alembic/versions/0021_system_config.py b/backend/alembic/versions/0021_system_config.py new file mode 100644 index 0000000..5981675 --- /dev/null +++ b/backend/alembic/versions/0021_system_config.py @@ -0,0 +1,38 @@ +"""generic admin-editable system_config key/value store + +Revision ID: 0021_system_config +Revises: 0020_rename_quota_actions +Create Date: 2026-06-19 + +Adds the system_config table: admin overrides for operational config that otherwise comes +from env/config defaults (see app.sysconfig for the registry of known keys). Secret values +are stored Fernet-encrypted (encrypted flag). Purely additive; absent rows = use defaults. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0021_system_config" +down_revision: Union[str, None] = "0020_rename_quota_actions" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "system_config", + sa.Column("key", sa.String(length=64), primary_key=True), + sa.Column("value", sa.Text(), nullable=True), + sa.Column("encrypted", sa.Boolean(), nullable=False, server_default="false"), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + + +def downgrade() -> None: + op.drop_table("system_config") diff --git a/backend/app/email.py b/backend/app/email.py index 74fcf75..27119ca 100644 --- a/backend/app/email.py +++ b/backend/app/email.py @@ -10,13 +10,29 @@ import ssl from email.message import EmailMessage from email.utils import formatdate +from app import sysconfig from app.config import settings +from app.db import SessionLocal log = logging.getLogger("subfeed.email") +def _smtp() -> dict: + """Effective SMTP config (admin DB override over env defaults). Opens its own short + session because email is often sent from a BackgroundTask, outside a request's db.""" + with SessionLocal() as db: + return { + "host": sysconfig.get_str(db, "smtp_host"), + "port": sysconfig.get_int(db, "smtp_port"), + "user": sysconfig.get_str(db, "smtp_user"), + "password": sysconfig.get_str(db, "smtp_password"), + "from": sysconfig.get_str(db, "smtp_from"), + } + + def email_enabled() -> bool: - return bool(settings.smtp_host and settings.smtp_user and settings.smtp_password) + c = _smtp() + return bool(c["host"] and c["user"] and c["password"]) def _admin_contact() -> str | None: @@ -27,12 +43,13 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) - recipients = [e for e in to if e] if not recipients: return False - if not email_enabled(): + c = _smtp() + if not (c["host"] and c["user"] and c["password"]): log.info("SMTP not configured; skipping email %r to %s", subject, recipients) return False msg = EmailMessage() msg["Subject"] = subject - msg["From"] = settings.smtp_from or settings.smtp_user + msg["From"] = c["from"] or c["user"] msg["To"] = ", ".join(recipients) # A real Date and a Reply-To (so it's a conversation, not a no-reply blast) both # nudge spam filters the right way; reputation still does most of the work. @@ -41,9 +58,9 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) - msg["Reply-To"] = reply_to msg.set_content(body) try: - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=20) as s: + with smtplib.SMTP(c["host"], c["port"], timeout=20) as s: s.starttls(context=ssl.create_default_context()) - s.login(settings.smtp_user, settings.smtp_password) + s.login(c["user"], c["password"]) s.send_message(msg) log.info("Sent email %r to %s", subject, recipients) return True @@ -73,3 +90,12 @@ def send_admin_new_request(admins: list[str], requester: str) -> bool: "(Reply to this email to reach the requester directly.)\n" ) return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester) + + +def send_test(to: str) -> bool: + body = ( + "This is a test email from Siftlode.\n\n" + "If you're reading this, your SMTP settings work.\n\n" + "— Siftlode" + ) + return _send([to], "Siftlode SMTP test", body) diff --git a/backend/app/main.py b/backend/app/main.py index d134beb..77c2a5e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -29,6 +29,7 @@ from app.config import settings from app.routes import ( admin, channels, + config as config_routes, feed, health, me, @@ -82,6 +83,7 @@ app.include_router(notifications.router) app.include_router(channels.router) app.include_router(playlists.router) app.include_router(admin.router) +app.include_router(config_routes.router) app.include_router(scheduler_routes.router) app.include_router(quota.router) app.include_router(version.router) diff --git a/backend/app/models.py b/backend/app/models.py index f6185a8..0952533 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -358,6 +358,24 @@ class SchedulerSetting(Base): interval_minutes: Mapped[int] = mapped_column(Integer) +class SystemConfig(Base): + """Admin-set overrides for operational config that otherwise comes from env/config + defaults. Generic key/value store (one row per key); absent = use the env/config + default. DB-backed so it's editable from the admin Configuration page at runtime without + a redeploy. Secret values (e.g. SMTP password) are stored Fernet-encrypted (`encrypted` + flag) using TOKEN_ENCRYPTION_KEY; non-secrets are plaintext. The set of known keys + their + types/groups/defaults lives in app.sysconfig (the single source of truth).""" + + __tablename__ = "system_config" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + value: Mapped[str | None] = mapped_column(Text) + encrypted: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=func.now(), onupdate=func.now() + ) + + class Playlist(Base): """A per-user named, ordered collection of videos from the shared catalog. diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py new file mode 100644 index 0000000..3d05345 --- /dev/null +++ b/backend/app/routes/config.py @@ -0,0 +1,68 @@ +"""Admin Configuration page API: read/edit the DB-overridable operational settings defined in +app.sysconfig. Secret values (e.g. SMTP password) are write-only — never returned.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import email as email_mod +from app import sysconfig +from app.db import get_db +from app.models import User +from app.routes.admin import admin_user + +router = APIRouter(prefix="/api/admin/config", tags=["admin"]) + + +@router.get("") +def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: + return sysconfig.describe(db) + + +@router.patch("/{key}") +def set_config( + key: str, + payload: dict, + _: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + s = sysconfig.spec(key) + if s is None: + raise HTTPException(status_code=404, detail="Unknown config key") + if "value" not in payload: + raise HTTPException(status_code=400, detail="Missing 'value'") + if s.secret and not sysconfig.secrets_manageable(): + raise HTTPException( + status_code=400, + detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.", + ) + try: + sysconfig.set_value(db, key, payload["value"]) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc) or "Invalid value") + return sysconfig.describe(db) + + +@router.delete("/{key}") +def reset_config( + key: str, + _: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + if sysconfig.spec(key) is None: + raise HTTPException(status_code=404, detail="Unknown config key") + sysconfig.reset(db, key) + return sysconfig.describe(db) + + +@router.post("/test-email") +def send_test_email( + user: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + """Send a test email to the requesting admin using the current (DB or env) SMTP config — + lets the admin verify the settings actually work.""" + if not email_mod.email_enabled(): + raise HTTPException(status_code=400, detail="SMTP is not configured.") + sent = email_mod.send_test(user.email) + if not sent: + raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.") + return {"sent": True, "to": user.email} diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py new file mode 100644 index 0000000..eabba05 --- /dev/null +++ b/backend/app/sysconfig.py @@ -0,0 +1,157 @@ +"""Admin-editable system configuration: a DB-override layer over the env/config defaults. + +The registry (SPECS) is the single source of truth for which config keys can be overridden +from the admin Configuration page, plus each key's type, admin-UI group, env/config default, +bounds, and whether it's a secret. The resolver returns the DB override if one is set, else +``settings.`` (same DB-override-or-default pattern as app.state). Secrets are +stored Fernet-encrypted at rest (requires TOKEN_ENCRYPTION_KEY; without it, secrets can't be +stored in the DB and stay env-only). +""" +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app import security +from app.config import settings +from app.models import SystemConfig + + +@dataclass(frozen=True) +class ConfigSpec: + key: str + type: str # "int" | "str" | "bool" + group: str # admin-UI grouping (logically related keys stay together) + default_attr: str # attribute on `settings` holding the env/config default + secret: bool = False + min: int | None = None + max: int | None = None + + +# Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it +# off env into the admin UI; the admin page renders it automatically from this list. +SPECS: tuple[ConfigSpec, ...] = ( + # --- Email / SMTP (one logical group; the password is the only secret) --- + ConfigSpec("smtp_host", "str", "email", "smtp_host"), + ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535), + ConfigSpec("smtp_user", "str", "email", "smtp_user"), + ConfigSpec("smtp_from", "str", "email", "smtp_from"), + ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True), +) + +_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS} + + +def spec(key: str) -> ConfigSpec | None: + return _BY_KEY.get(key) + + +def secrets_manageable() -> bool: + """Secret values can only be stored when Fernet is configured (TOKEN_ENCRYPTION_KEY).""" + return bool(settings.token_encryption_key) + + +def _default(s: ConfigSpec): + return getattr(settings, s.default_attr) + + +def _coerce(s: ConfigSpec, raw: str): + if s.type == "int": + return int(raw) + if s.type == "bool": + return raw == "true" + return raw + + +def _raw_override(db: Session, key: str) -> str | None: + """Stored (decrypted) override string for `key`, or None if no override is set.""" + row = db.get(SystemConfig, key) + if row is None or row.value is None: + return None + return security.decrypt(row.value) if row.encrypted else row.value + + +def get(db: Session, key: str): + """Effective value: the DB override (typed) if set, else the env/config default.""" + s = _BY_KEY[key] + raw = _raw_override(db, key) + if raw is None or raw == "": + return _default(s) + try: + return _coerce(s, raw) + except (TypeError, ValueError): + return _default(s) + + +def get_str(db: Session, key: str) -> str: + v = get(db, key) + return "" if v is None else str(v) + + +def get_int(db: Session, key: str) -> int: + return int(get(db, key)) + + +def get_bool(db: Session, key: str) -> bool: + return bool(get(db, key)) + + +def set_value(db: Session, key: str, value) -> None: + """Validate, coerce, and persist a DB override for `key`. Secrets are encrypted.""" + s = _BY_KEY[key] + if s.type == "int": + v = int(value) + if s.min is not None and v < s.min: + raise ValueError(f"{key} must be >= {s.min}") + if s.max is not None and v > s.max: + raise ValueError(f"{key} must be <= {s.max}") + raw = str(v) + elif s.type == "bool": + raw = "true" if value else "false" + else: + raw = "" if value is None else str(value) + + stored, encrypted = raw, False + if s.secret: + if not secrets_manageable(): + raise RuntimeError("TOKEN_ENCRYPTION_KEY is not configured") + stored, encrypted = security.encrypt(raw) or "", True + + row = db.get(SystemConfig, key) + if row is None: + row = SystemConfig(key=key) + db.add(row) + row.value = stored + row.encrypted = encrypted + db.commit() + + +def reset(db: Session, key: str) -> None: + """Remove the DB override for `key` (fall back to the env/config default).""" + row = db.get(SystemConfig, key) + if row is not None: + db.delete(row) + db.commit() + + +def describe(db: Session) -> dict: + """Admin-UI metadata + current values, grouped. Secret values are never echoed — + only whether an override is set and whether an env default exists.""" + groups: dict[str, list[dict]] = {} + for s in SPECS: + is_set = db.get(SystemConfig, s.key) is not None + item: dict = { + "key": s.key, + "type": s.type, + "group": s.group, + "secret": s.secret, + "min": s.min, + "max": s.max, + "is_set": is_set, # an override row exists + } + if s.secret: + item["default_is_set"] = bool(_default(s)) + else: + item["value"] = get(db, s.key) + item["default"] = _default(s) + groups.setdefault(s.group, []).append(item) + return {"groups": groups, "secrets_manageable": secrets_manageable()} From 007458e61ffd0d00432bd8959bab3f86feec39cf Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 12:23:00 +0200 Subject: [PATCH 2/2] 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;