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:
commit
7d71c205b2
18 changed files with 625 additions and 7 deletions
38
backend/alembic/versions/0021_system_config.py
Normal file
38
backend/alembic/versions/0021_system_config.py
Normal 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")
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
68
backend/app/routes/config.py
Normal file
68
backend/app/routes/config.py
Normal 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
157
backend/app/sysconfig.py
Normal 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()}
|
||||||
|
|
@ -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" ? (
|
||||||
|
|
|
||||||
177
frontend/src/components/ConfigPanel.tsx
Normal file
177
frontend/src/components/ConfigPanel.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { RotateCcw, Save, Send } from "lucide-react";
|
||||||
|
import { api, type ConfigItem, type SystemConfigData } from "../lib/api";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
import Tooltip from "./Tooltip";
|
||||||
|
|
||||||
|
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from
|
||||||
|
// the backend — adding a key there makes it appear here automatically). Group order is fixed
|
||||||
|
// where known so logically related settings (e.g. Email/SMTP) stay together.
|
||||||
|
const GROUP_ORDER = ["email"];
|
||||||
|
|
||||||
|
export default function ConfigPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const q = useQuery({ queryKey: ["admin-config"], queryFn: api.adminConfig });
|
||||||
|
const data = q.data;
|
||||||
|
|
||||||
|
const apply = (fresh: SystemConfigData) => qc.setQueryData(["admin-config"], fresh);
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: ({ key, value }: { key: string; value: string | number | boolean }) =>
|
||||||
|
api.setConfig(key, value),
|
||||||
|
onSuccess: (fresh) => {
|
||||||
|
apply(fresh);
|
||||||
|
notify({ level: "success", message: t("config.saved") });
|
||||||
|
},
|
||||||
|
onError: () => notify({ level: "error", message: t("config.saveFailed") }),
|
||||||
|
});
|
||||||
|
const reset = useMutation({
|
||||||
|
mutationFn: (key: string) => api.resetConfig(key),
|
||||||
|
onSuccess: (fresh) => {
|
||||||
|
apply(fresh);
|
||||||
|
notify({ level: "success", message: t("config.resetDone") });
|
||||||
|
},
|
||||||
|
onError: () => notify({ level: "error", message: t("config.saveFailed") }),
|
||||||
|
});
|
||||||
|
const testEmail = useMutation({
|
||||||
|
mutationFn: () => api.testEmail(),
|
||||||
|
onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }),
|
||||||
|
onError: () => notify({ level: "error", message: t("config.testFailed") }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const busy = save.isPending || reset.isPending;
|
||||||
|
|
||||||
|
if (q.isLoading || !data) {
|
||||||
|
return <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupKeys = Object.keys(data.groups).sort(
|
||||||
|
(a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||||
|
<p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p>
|
||||||
|
|
||||||
|
{groupKeys.map((g) => (
|
||||||
|
<div key={g} className="glass rounded-2xl p-4 mb-4">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted mb-3">
|
||||||
|
{t(`config.groups.${g}`, g)}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{data.groups[g].map((item) => (
|
||||||
|
<Field
|
||||||
|
key={`${item.key}:${item.is_set}:${String(item.value ?? "")}`}
|
||||||
|
item={item}
|
||||||
|
secretsManageable={data.secrets_manageable}
|
||||||
|
busy={busy}
|
||||||
|
onSave={(value) => save.mutate({ key: item.key, value })}
|
||||||
|
onReset={() => reset.mutate(item.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{g === "email" && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-border/60">
|
||||||
|
<Tooltip hint={t("config.testEmailHint")}>
|
||||||
|
<button
|
||||||
|
onClick={() => testEmail.mutate()}
|
||||||
|
disabled={testEmail.isPending}
|
||||||
|
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
|
||||||
|
{t("config.testEmail")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
item,
|
||||||
|
secretsManageable,
|
||||||
|
busy,
|
||||||
|
onSave,
|
||||||
|
onReset,
|
||||||
|
}: {
|
||||||
|
item: ConfigItem;
|
||||||
|
secretsManageable: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onSave: (value: string | number) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isNum = item.type === "int";
|
||||||
|
const [draft, setDraft] = useState<string>(item.secret ? "" : String(item.value ?? ""));
|
||||||
|
|
||||||
|
const secretDisabled = item.secret && !secretsManageable;
|
||||||
|
// Status line for secrets (never reveal the value): stored override / env value / unset.
|
||||||
|
const secretStatus = item.is_set
|
||||||
|
? t("config.secretSet")
|
||||||
|
: item.default_is_set
|
||||||
|
? t("config.secretEnv")
|
||||||
|
: t("config.secretUnset");
|
||||||
|
|
||||||
|
const canSave = item.secret ? draft.trim().length > 0 && !secretDisabled : true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-3 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium">{t(`config.fields.${item.key}.label`, item.key)}</div>
|
||||||
|
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
||||||
|
{t(`config.fields.${item.key}.hint`, "")}
|
||||||
|
</p>
|
||||||
|
{item.secret ? (
|
||||||
|
<p className="text-[11px] text-muted mt-1">
|
||||||
|
{secretDisabled ? t("config.secretsDisabled") : secretStatus}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-muted mt-1">
|
||||||
|
{item.is_set ? t("config.overridden") : t("config.usingDefault")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||||
|
<input
|
||||||
|
type={item.secret ? "password" : isNum ? "number" : "text"}
|
||||||
|
value={draft}
|
||||||
|
disabled={secretDisabled}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
min={isNum && item.min != null ? item.min : undefined}
|
||||||
|
max={isNum && item.max != null ? item.max : undefined}
|
||||||
|
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
|
||||||
|
className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-none focus:border-accent disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{item.is_set && (
|
||||||
|
<Tooltip hint={t("config.resetHint")}>
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
disabled={busy}
|
||||||
|
className="glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
{t("config.reset")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onSave(isNum ? Number(draft) : draft)}
|
||||||
|
disabled={busy || !canSave}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs bg-accent text-accent-fg font-medium disabled:opacity-40 transition"
|
||||||
|
>
|
||||||
|
<Save className="w-3.5 h-3.5" />
|
||||||
|
{t("config.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,8 @@ export default function Header({
|
||||||
? t("settings.title")
|
? t("settings.title")
|
||||||
: page === "scheduler"
|
: page === "scheduler"
|
||||||
? t("header.scheduler")
|
? t("header.scheduler")
|
||||||
|
: page === "config"
|
||||||
|
? t("header.configuration")
|
||||||
: t("header.channelManager")}
|
: t("header.channelManager")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
30
frontend/src/i18n/locales/de/config.json
Normal file
30
frontend/src/i18n/locales/de/config.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"intro": "In der Datenbank gespeicherte Betriebseinstellungen — sie überschreiben die Datei-/Env-Standardwerte und wirken ohne erneutes Deployment. Lass ein Feld leer (oder setze es zurück), um auf den Standard zurückzufallen.",
|
||||||
|
"loading": "Konfiguration wird geladen…",
|
||||||
|
"groups": {
|
||||||
|
"email": "E-Mail / SMTP"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"smtp_host": { "label": "SMTP-Host", "hint": "z. B. smtp.gmail.com" },
|
||||||
|
"smtp_port": { "label": "SMTP-Port", "hint": "Üblicherweise 587 (STARTTLS)." },
|
||||||
|
"smtp_user": { "label": "SMTP-Benutzername", "hint": "Das sendende Konto / der Login." },
|
||||||
|
"smtp_from": { "label": "Absenderadresse", "hint": "z. B. Siftlode <addr@gmail.com>. Standard ist der Benutzername." },
|
||||||
|
"smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." }
|
||||||
|
},
|
||||||
|
"save": "Speichern",
|
||||||
|
"saved": "Gespeichert",
|
||||||
|
"saveFailed": "Speichern fehlgeschlagen",
|
||||||
|
"reset": "Zurücksetzen",
|
||||||
|
"resetHint": "Diese Überschreibung entfernen und auf den Datei-/Env-Standard zurückfallen.",
|
||||||
|
"resetDone": "Auf Standard zurückgesetzt",
|
||||||
|
"usingDefault": "Standard wird verwendet",
|
||||||
|
"overridden": "in der DB überschrieben",
|
||||||
|
"secretSet": "•••••••• gespeichert",
|
||||||
|
"secretEnv": "Env-Wert wird verwendet",
|
||||||
|
"secretUnset": "nicht gesetzt",
|
||||||
|
"secretsDisabled": "Setze TOKEN_ENCRYPTION_KEY, um Secrets in der Datenbank zu speichern.",
|
||||||
|
"testEmail": "Test-E-Mail senden",
|
||||||
|
"testEmailHint": "Sende eine Test-E-Mail an deine eigene Adresse mit den obigen Einstellungen, um zu prüfen, ob sie funktionieren.",
|
||||||
|
"testSent": "Test-E-Mail gesendet an {{to}}",
|
||||||
|
"testFailed": "Test-E-Mail fehlgeschlagen — prüfe die Einstellungen"
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"channelManager": "Kanalverwaltung",
|
"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",
|
||||||
|
|
|
||||||
30
frontend/src/i18n/locales/en/config.json
Normal file
30
frontend/src/i18n/locales/en/config.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"intro": "Operational settings stored in the database — these override the file/env defaults and take effect without a redeploy. Leave a field empty (or reset it) to fall back to the default.",
|
||||||
|
"loading": "Loading configuration…",
|
||||||
|
"groups": {
|
||||||
|
"email": "Email / SMTP"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"smtp_host": { "label": "SMTP host", "hint": "e.g. smtp.gmail.com" },
|
||||||
|
"smtp_port": { "label": "SMTP port", "hint": "Usually 587 (STARTTLS)." },
|
||||||
|
"smtp_user": { "label": "SMTP username", "hint": "The sending account / login." },
|
||||||
|
"smtp_from": { "label": "From address", "hint": "e.g. Siftlode <addr@gmail.com>. Defaults to the username." },
|
||||||
|
"smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." }
|
||||||
|
},
|
||||||
|
"save": "Save",
|
||||||
|
"saved": "Saved",
|
||||||
|
"saveFailed": "Couldn't save",
|
||||||
|
"reset": "Reset",
|
||||||
|
"resetHint": "Remove this override and fall back to the file/env default.",
|
||||||
|
"resetDone": "Reset to default",
|
||||||
|
"usingDefault": "using default",
|
||||||
|
"overridden": "overridden in DB",
|
||||||
|
"secretSet": "•••••••• stored",
|
||||||
|
"secretEnv": "using env value",
|
||||||
|
"secretUnset": "not set",
|
||||||
|
"secretsDisabled": "Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
|
||||||
|
"testEmail": "Send test email",
|
||||||
|
"testEmailHint": "Send a test email to your own address using the settings above, to confirm they work.",
|
||||||
|
"testSent": "Test email sent to {{to}}",
|
||||||
|
"testFailed": "Test email failed — check the settings"
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"channelManager": "Channel manager",
|
"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",
|
||||||
|
|
|
||||||
30
frontend/src/i18n/locales/hu/config.json
Normal file
30
frontend/src/i18n/locales/hu/config.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"intro": "Az adatbázisban tárolt működési beállítások — felülírják a fájl/env alapértékeket, és újratelepítés nélkül lépnek életbe. Hagyd üresen (vagy állítsd vissza) a mezőt az alapértékhez való visszatéréshez.",
|
||||||
|
"loading": "Konfiguráció betöltése…",
|
||||||
|
"groups": {
|
||||||
|
"email": "E-mail / SMTP"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"smtp_host": { "label": "SMTP-kiszolgáló", "hint": "pl. smtp.gmail.com" },
|
||||||
|
"smtp_port": { "label": "SMTP-port", "hint": "Általában 587 (STARTTLS)." },
|
||||||
|
"smtp_user": { "label": "SMTP-felhasználónév", "hint": "A küldő fiók / bejelentkezés." },
|
||||||
|
"smtp_from": { "label": "Feladó cím", "hint": "pl. Siftlode <addr@gmail.com>. Alapból a felhasználónév." },
|
||||||
|
"smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." }
|
||||||
|
},
|
||||||
|
"save": "Mentés",
|
||||||
|
"saved": "Elmentve",
|
||||||
|
"saveFailed": "Nem sikerült menteni",
|
||||||
|
"reset": "Visszaállítás",
|
||||||
|
"resetHint": "Eltávolítja ezt a felülírást, és visszatér a fájl/env alapértékhez.",
|
||||||
|
"resetDone": "Visszaállítva az alapértékre",
|
||||||
|
"usingDefault": "alapérték használatban",
|
||||||
|
"overridden": "felülírva az adatbázisban",
|
||||||
|
"secretSet": "•••••••• tárolva",
|
||||||
|
"secretEnv": "env-érték használatban",
|
||||||
|
"secretUnset": "nincs beállítva",
|
||||||
|
"secretsDisabled": "Állítsd be a TOKEN_ENCRYPTION_KEY-t a titkok adatbázisban tárolásához.",
|
||||||
|
"testEmail": "Teszt-email küldése",
|
||||||
|
"testEmailHint": "Teszt-email küldése a saját címedre a fenti beállításokkal, hogy ellenőrizd, működnek-e.",
|
||||||
|
"testSent": "Teszt-email elküldve ide: {{to}}",
|
||||||
|
"testFailed": "A teszt-email sikertelen — ellenőrizd a beállításokat"
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
"channelManager": "Csatornakezelő",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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}`, {
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ export const PAGES = [
|
||||||
"playlists",
|
"playlists",
|
||||||
"settings",
|
"settings",
|
||||||
"scheduler",
|
"scheduler",
|
||||||
|
"config",
|
||||||
"notifications",
|
"notifications",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue