Merge feature/config-db-admin (epic 4a)

DB-backed system_config infrastructure (registry + resolver + Fernet-encrypted
secrets + admin API) and a registry-driven admin Configuration page, with the
Email/SMTP group wired end-to-end as the first vertical slice.
This commit is contained in:
npeter83 2026-06-19 12:23:08 +02:00
commit 7d71c205b2
18 changed files with 625 additions and 7 deletions

View file

@ -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")

View file

@ -10,13 +10,29 @@ import ssl
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import formatdate from email.utils import formatdate
from app import sysconfig
from app.config import settings from app.config import settings
from app.db import SessionLocal
log = logging.getLogger("subfeed.email") 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: 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: 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] recipients = [e for e in to if e]
if not recipients: if not recipients:
return False 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) log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
return False return False
msg = EmailMessage() msg = EmailMessage()
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = settings.smtp_from or settings.smtp_user msg["From"] = c["from"] or c["user"]
msg["To"] = ", ".join(recipients) msg["To"] = ", ".join(recipients)
# A real Date and a Reply-To (so it's a conversation, not a no-reply blast) both # 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. # 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["Reply-To"] = reply_to
msg.set_content(body) msg.set_content(body)
try: 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.starttls(context=ssl.create_default_context())
s.login(settings.smtp_user, settings.smtp_password) s.login(c["user"], c["password"])
s.send_message(msg) s.send_message(msg)
log.info("Sent email %r to %s", subject, recipients) log.info("Sent email %r to %s", subject, recipients)
return True 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" "(Reply to this email to reach the requester directly.)\n"
) )
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester) 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)

View file

@ -29,6 +29,7 @@ from app.config import settings
from app.routes import ( from app.routes import (
admin, admin,
channels, channels,
config as config_routes,
feed, feed,
health, health,
me, me,
@ -82,6 +83,7 @@ app.include_router(notifications.router)
app.include_router(channels.router) app.include_router(channels.router)
app.include_router(playlists.router) app.include_router(playlists.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(config_routes.router)
app.include_router(scheduler_routes.router) app.include_router(scheduler_routes.router)
app.include_router(quota.router) app.include_router(quota.router)
app.include_router(version.router) app.include_router(version.router)

View file

@ -358,6 +358,24 @@ class SchedulerSetting(Base):
interval_minutes: Mapped[int] = mapped_column(Integer) 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): class Playlist(Base):
"""A per-user named, ordered collection of videos from the shared catalog. """A per-user named, ordered collection of videos from the shared catalog.

View file

@ -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}

157
backend/app/sysconfig.py Normal file
View file

@ -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.<default_attr>`` (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()}

View file

@ -30,6 +30,7 @@ import Channels, { type ChannelStatusFilter, type ChannelsView } from "./compone
import Playlists from "./components/Playlists"; import Playlists from "./components/Playlists";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
import Scheduler from "./components/Scheduler"; import Scheduler from "./components/Scheduler";
import ConfigPanel from "./components/ConfigPanel";
import SettingsPanel from "./components/SettingsPanel"; import SettingsPanel from "./components/SettingsPanel";
import NotificationsPanel from "./components/NotificationsPanel"; import NotificationsPanel from "./components/NotificationsPanel";
import OnboardingWizard from "./components/OnboardingWizard"; import OnboardingWizard from "./components/OnboardingWizard";
@ -469,6 +470,8 @@ export default function App() {
<Stats me={meQuery.data!} /> <Stats me={meQuery.data!} />
) : page === "scheduler" && meQuery.data!.role === "admin" ? ( ) : page === "scheduler" && meQuery.data!.role === "admin" ? (
<Scheduler /> <Scheduler />
) : page === "config" && meQuery.data!.role === "admin" ? (
<ConfigPanel />
) : page === "playlists" ? ( ) : page === "playlists" ? (
<Playlists canWrite={meQuery.data!.can_write} /> <Playlists canWrite={meQuery.data!.can_write} />
) : page === "notifications" ? ( ) : page === "notifications" ? (

View 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>
);
}

View file

@ -75,7 +75,9 @@ export default function Header({
? t("settings.title") ? t("settings.title")
: page === "scheduler" : page === "scheduler"
? t("header.scheduler") ? t("header.scheduler")
: t("header.channelManager")} : page === "config"
? t("header.configuration")
: t("header.channelManager")}
</div> </div>
)} )}
</header> </header>

View file

@ -14,6 +14,7 @@ import {
LogOut, LogOut,
Settings, Settings,
Shield, Shield,
SlidersHorizontal,
Tv, Tv,
UserPlus, UserPlus,
} from "lucide-react"; } from "lucide-react";
@ -138,7 +139,10 @@ export default function NavSidebar({
]; ];
const systemItems: NavItem[] = const systemItems: NavItem[] =
me.role === "admin" 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 = const rowBase =

View 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"
}

View file

@ -4,6 +4,7 @@
"channelManager": "Kanalverwaltung", "channelManager": "Kanalverwaltung",
"usageStats": "Nutzung & Statistik", "usageStats": "Nutzung & Statistik",
"scheduler": "Planer", "scheduler": "Planer",
"configuration": "Konfiguration",
"scope": { "scope": {
"label": "Feed-Quelle", "label": "Feed-Quelle",
"my": "Meine", "my": "Meine",
@ -18,6 +19,7 @@
"playlists": "Wiedergabelisten", "playlists": "Wiedergabelisten",
"stats": "Statistik", "stats": "Statistik",
"scheduler": "Planer", "scheduler": "Planer",
"config": "Konfiguration",
"settings": "Einstellungen", "settings": "Einstellungen",
"about": "Über", "about": "Über",
"signOut": "Abmelden", "signOut": "Abmelden",

View 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"
}

View file

@ -4,6 +4,7 @@
"channelManager": "Channel manager", "channelManager": "Channel manager",
"usageStats": "Usage & stats", "usageStats": "Usage & stats",
"scheduler": "Scheduler", "scheduler": "Scheduler",
"configuration": "Configuration",
"scope": { "scope": {
"label": "Feed source", "label": "Feed source",
"my": "Mine", "my": "Mine",
@ -18,6 +19,7 @@
"playlists": "Playlists", "playlists": "Playlists",
"stats": "Stats", "stats": "Stats",
"scheduler": "Scheduler", "scheduler": "Scheduler",
"config": "Configuration",
"settings": "Settings", "settings": "Settings",
"about": "About", "about": "About",
"signOut": "Sign out", "signOut": "Sign out",

View 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"
}

View file

@ -4,6 +4,7 @@
"channelManager": "Csatornakezelő", "channelManager": "Csatornakezelő",
"usageStats": "Használat és statisztika", "usageStats": "Használat és statisztika",
"scheduler": "Ütemező", "scheduler": "Ütemező",
"configuration": "Konfiguráció",
"scope": { "scope": {
"label": "Hírfolyam forrása", "label": "Hírfolyam forrása",
"my": "Sajátom", "my": "Sajátom",
@ -18,6 +19,7 @@
"playlists": "Lejátszási listák", "playlists": "Lejátszási listák",
"stats": "Statisztika", "stats": "Statisztika",
"scheduler": "Ütemező", "scheduler": "Ütemező",
"config": "Konfiguráció",
"settings": "Beállítások", "settings": "Beállítások",
"about": "Névjegy", "about": "Névjegy",
"signOut": "Kijelentkezés", "signOut": "Kijelentkezés",

View file

@ -440,6 +440,24 @@ export interface AppNotification {
created_at: string | null; 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 = { export const api = {
me: (): Promise<Me> => req("/api/me"), me: (): Promise<Me> => req("/api/me"),
accounts: (): Promise<Account[]> => req("/api/me/accounts"), accounts: (): Promise<Account[]> => req("/api/me/accounts"),
@ -548,6 +566,14 @@ export const api = {
// --- quota usage --- // --- quota usage ---
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"), myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`), 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"), schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> => updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
req(`/api/admin/scheduler/jobs/${jobId}`, { req(`/api/admin/scheduler/jobs/${jobId}`, {

View file

@ -88,6 +88,7 @@ export const PAGES = [
"playlists", "playlists",
"settings", "settings",
"scheduler", "scheduler",
"config",
"notifications", "notifications",
] as const; ] as const;