diff --git a/backend/alembic/versions/0022_password_auth.py b/backend/alembic/versions/0022_password_auth.py
new file mode 100644
index 0000000..decb549
--- /dev/null
+++ b/backend/alembic/versions/0022_password_auth.py
@@ -0,0 +1,63 @@
+"""email+password auth foundations
+
+Revision ID: 0022_password_auth
+Revises: 0021_system_config
+Create Date: 2026-06-19
+
+Adds password-auth columns to users (password_hash, email_verified, is_active), makes
+google_sub nullable (password-only accounts have no Google sub until they link), and a
+single-use auth_tokens table for email verification + password reset. Existing rows are all
+Google/demo accounts → backfilled email_verified=true (is_active defaults true).
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0022_password_auth"
+down_revision: Union[str, None] = "0021_system_config"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("users", sa.Column("password_hash", sa.String(length=255), nullable=True))
+ op.add_column(
+ "users",
+ sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"),
+ )
+ op.add_column(
+ "users",
+ sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
+ )
+ # Existing accounts are all Google/demo — their email is already proven.
+ op.execute("UPDATE users SET email_verified = true")
+ # Password-only accounts have no Google sub until they link one.
+ op.alter_column("users", "google_sub", existing_type=sa.String(length=64), nullable=True)
+
+ op.create_table(
+ "auth_tokens",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column(
+ "user_id",
+ sa.Integer(),
+ sa.ForeignKey("users.id", ondelete="CASCADE"),
+ nullable=False,
+ index=True,
+ ),
+ sa.Column("kind", sa.String(length=16), nullable=False),
+ sa.Column("token_hash", sa.String(length=64), nullable=False, unique=True),
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column(
+ "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("auth_tokens")
+ op.alter_column("users", "google_sub", existing_type=sa.String(length=64), nullable=False)
+ op.drop_column("users", "is_active")
+ op.drop_column("users", "email_verified")
+ op.drop_column("users", "password_hash")
diff --git a/backend/app/auth.py b/backend/app/auth.py
index 8ebf762..4139b4f 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -1,6 +1,8 @@
+import hashlib
import logging
import re
-from datetime import datetime, timezone
+import secrets
+from datetime import datetime, timedelta, timezone
from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
@@ -9,11 +11,20 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app import email as email_mod
+from app import sysconfig
from app.config import settings
from app.db import get_db
-from app.models import DemoWhitelist, Invite, OAuthToken, User
+from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
from app.ratelimit import RateLimiter
-from app.security import encrypt
+from app.security import encrypt, hash_password, verify_password
+
+# Email+password auth tuning.
+PASSWORD_MIN_LEN = 10
+VERIFY_TTL = timedelta(hours=24)
+RESET_TTL = timedelta(hours=1)
+_register_limiter = RateLimiter(max_events=5, window_seconds=300)
+_login_limiter = RateLimiter(max_events=10, window_seconds=300)
+_reset_limiter = RateLimiter(max_events=5, window_seconds=300)
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@@ -291,12 +302,189 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -
return {"authenticated": False}
+def _establish_session(request: Request, user: User) -> None:
+ """Sign `user` in for this browser session, mirroring the Google callback's multi-account
+ handling so password sign-in joins the same account switcher."""
+ accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
+ accounts.append(user.id)
+ request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
+ request.session["user_id"] = user.id
+
+
+def _app_base() -> str:
+ """Public origin of the deployed app (OAUTH_REDIRECT_URL is .../auth/callback)."""
+ u = settings.oauth_redirect_url
+ return u.split("/auth/")[0] if "/auth/" in u else u.rstrip("/")
+
+
+def _hash_token(raw: str) -> str:
+ return hashlib.sha256(raw.encode()).hexdigest()
+
+
+def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
+ """Create a single-use token of `kind`; only its hash is stored. Returns the raw token
+ (goes only into the emailed link)."""
+ raw = secrets.token_urlsafe(32)
+ db.add(
+ AuthToken(
+ user_id=user.id,
+ kind=kind,
+ token_hash=_hash_token(raw),
+ expires_at=datetime.now(timezone.utc) + ttl,
+ )
+ )
+ db.commit()
+ return raw
+
+
+def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
+ """Validate + burn a token. Returns its user, or None if missing/expired/used/wrong kind."""
+ if not raw:
+ return None
+ row = db.execute(
+ select(AuthToken).where(
+ AuthToken.token_hash == _hash_token(raw), AuthToken.kind == kind
+ )
+ ).scalar_one_or_none()
+ if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc):
+ return None
+ row.used_at = datetime.now(timezone.utc)
+ db.commit()
+ return db.get(User, row.user_id)
+
+
+@router.post("/register")
+def register(
+ payload: dict,
+ request: Request,
+ background: BackgroundTasks,
+ db: Session = Depends(get_db),
+) -> dict:
+ """Email+password registration. Creates a pending (inactive, unverified) account, sends a
+ verification link, and records an access request for admin approval. Two gates before
+ sign-in: verified email AND admin approval. Responses are uniform once input is valid, so
+ the endpoint can't be used to discover which emails are registered."""
+ email = (payload.get("email") or "").strip().lower()
+ password = payload.get("password") or ""
+ # Input validation is safe to reveal (independent of whether the email exists).
+ if not _EMAIL_RE.match(email):
+ raise HTTPException(status_code=400, detail="Enter a valid email address.")
+ if len(password) < PASSWORD_MIN_LEN:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
+ )
+ if not sysconfig.get_bool(db, "allow_registration"):
+ raise HTTPException(status_code=403, detail="Registration is currently closed.")
+ if not _register_limiter.allow(_client_ip(request)):
+ return {"status": "ok"} # silently throttle; uniform response
+
+ existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
+ if existing is None:
+ user = User(
+ email=email,
+ password_hash=hash_password(password),
+ is_active=False,
+ email_verified=False,
+ )
+ db.add(user)
+ db.flush()
+ upsert_pending_invite(db, email) # admin-approval gate
+ raw = _issue_token(db, user, "verify", VERIFY_TTL)
+ background.add_task(email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}")
+ if settings.admin_email_set:
+ background.add_task(
+ email_mod.send_admin_new_request, sorted(settings.admin_email_set), email
+ )
+ # Existing email → do nothing visible (no enumeration). Uniform success either way.
+ return {"status": "ok"}
+
+
+@router.get("/verify")
+def verify_email(token: str, db: Session = Depends(get_db)):
+ """Confirm an email-verification link, then bounce to a friendly status on the app."""
+ user = _consume_token(db, token, "verify")
+ if user is None:
+ return RedirectResponse(url="/?verify=invalid")
+ user.email_verified = True
+ db.commit()
+ return RedirectResponse(url="/?verify=ok")
+
+
+@router.post("/password-login")
+def password_login(
+ payload: dict, request: Request, db: Session = Depends(get_db)
+) -> dict:
+ """Email+password sign-in. Wrong email/password give a single uniform 401 (no enumeration).
+ Once the password is proven correct, the owner gets a specific reason if their account is
+ still pending — that's not an enumeration leak (they already hold the password)."""
+ if not _login_limiter.allow(_client_ip(request)):
+ raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
+ email = (payload.get("email") or "").strip().lower()
+ password = payload.get("password") or ""
+ user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
+ if user is None or user.is_demo or not verify_password(password, user.password_hash):
+ raise HTTPException(status_code=401, detail="Invalid email or password.")
+ if not user.email_verified:
+ raise HTTPException(status_code=403, detail="Please verify your email first (check your inbox).")
+ if not user.is_active:
+ raise HTTPException(status_code=403, detail="Your account is awaiting admin approval.")
+ _establish_session(request, user)
+ log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role)
+ return {"ok": True}
+
+
+@router.post("/password-reset/request")
+def password_reset_request(
+ payload: dict,
+ request: Request,
+ background: BackgroundTasks,
+ db: Session = Depends(get_db),
+) -> dict:
+ """Request a password-reset link. Uniform response regardless of whether the email has a
+ password account, so it can't probe for registered emails."""
+ if not _reset_limiter.allow(_client_ip(request)):
+ return {"status": "ok"}
+ email = (payload.get("email") or "").strip().lower()
+ if _EMAIL_RE.match(email):
+ user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
+ if user is not None and user.password_hash and not user.is_demo:
+ raw = _issue_token(db, user, "reset", RESET_TTL)
+ background.add_task(email_mod.send_password_reset, email, f"{_app_base()}/?reset={raw}")
+ return {"status": "ok"}
+
+
+@router.post("/password-reset/confirm")
+def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
+ """Set a new password from a valid reset token, then invalidate any other reset tokens."""
+ token = payload.get("token")
+ new_password = payload.get("password") or ""
+ if len(new_password) < PASSWORD_MIN_LEN:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
+ )
+ user = _consume_token(db, token, "reset")
+ if user is None:
+ raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
+ user.password_hash = hash_password(new_password)
+ # Burn any other outstanding reset tokens for this user.
+ for row in db.execute(
+ select(AuthToken).where(
+ AuthToken.user_id == user.id, AuthToken.kind == "reset", AuthToken.used_at.is_(None)
+ )
+ ).scalars():
+ row.used_at = datetime.now(timezone.utc)
+ db.commit()
+ return {"ok": True}
+
+
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated")
user = db.get(User, user_id)
- if user is None:
+ if user is None or not user.is_active:
request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated")
# Always keep the active account in the switchable list — covers sessions created before
diff --git a/backend/app/config.py b/backend/app/config.py
index af60b2b..ee8aa4a 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -45,6 +45,10 @@ class Settings(BaseSettings):
allowed_emails: str = ""
admin_emails: str = ""
+ # Whether anyone may submit an email+password registration (still gated by email
+ # verification + admin approval before they can sign in). Admin-tunable via the DB.
+ allow_registration: bool = True
+
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
frontend_origin: str = ""
diff --git a/backend/app/email.py b/backend/app/email.py
index 27119ca..d1af768 100644
--- a/backend/app/email.py
+++ b/backend/app/email.py
@@ -92,6 +92,28 @@ def send_admin_new_request(admins: list[str], requester: str) -> bool:
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
+def send_verify_email(to: str, link: str) -> bool:
+ body = (
+ "Hi,\n\n"
+ "Confirm your email to finish creating your Siftlode account:\n\n"
+ f"{link}\n\n"
+ "If you didn't sign up, you can ignore this message.\n\n"
+ "— Siftlode"
+ )
+ return _send([to], "Confirm your Siftlode email", body)
+
+
+def send_password_reset(to: str, link: str) -> bool:
+ body = (
+ "Hi,\n\n"
+ "Use this link to set a new Siftlode password (it expires in an hour):\n\n"
+ f"{link}\n\n"
+ "If you didn't request this, you can ignore this message — your password is unchanged.\n\n"
+ "— Siftlode"
+ )
+ return _send([to], "Reset your Siftlode password", body)
+
+
def send_test(to: str) -> bool:
body = (
"This is a test email from Siftlode.\n\n"
diff --git a/backend/app/models.py b/backend/app/models.py
index 0952533..4f12e9f 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -23,8 +23,20 @@ class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
- google_sub: Mapped[str] = mapped_column(String(64), unique=True, index=True)
+ # NULL for an email+password account that hasn't linked Google yet (Postgres allows
+ # multiple NULLs under a unique index). Set on Google sign-in / in-app linking.
+ google_sub: Mapped[str | None] = mapped_column(String(64), unique=True, index=True)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
+ # argon2id hash for email+password login; NULL for Google-only / demo accounts.
+ password_hash: Mapped[str | None] = mapped_column(String(255))
+ # Email ownership proven (Google sign-in is implicitly verified; password registration
+ # verifies via a link). Login requires this AND is_active.
+ email_verified: Mapped[bool] = mapped_column(
+ Boolean, default=False, server_default="false"
+ )
+ # Admin-approved & enabled. A pending password registration starts inactive; admin
+ # approval activates it. A deactivated account can't log in.
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
display_name: Mapped[str | None] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(1024))
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
@@ -70,6 +82,26 @@ class OAuthToken(Base):
user: Mapped["User"] = relationship(back_populates="token")
+class AuthToken(Base):
+ """Single-use, expiring token for email verification or password reset. Only the SHA-256
+ HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token
+ lives only in the emailed link. Consumed (used_at set) on first valid use."""
+
+ __tablename__ = "auth_tokens"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ user_id: Mapped[int] = mapped_column(
+ ForeignKey("users.id", ondelete="CASCADE"), index=True
+ )
+ kind: Mapped[str] = mapped_column(String(16)) # "verify" | "reset"
+ token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+ used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
+
+
class Invite(Base):
"""Access-request / whitelist row. Source of truth for who may sign in; the env
ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed)."""
diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py
index 545a1ce..7151788 100644
--- a/backend/app/routes/admin.py
+++ b/backend/app/routes/admin.py
@@ -4,7 +4,7 @@ import re
from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
-from sqlalchemy import delete, select
+from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app import email as email_mod
@@ -59,6 +59,15 @@ def list_invites(
return [_serialize(i) for i in rows]
+def _activate_user_by_email(db: Session, email: str) -> None:
+ """Approving access activates a matching (e.g. pending password-registration) account so
+ it can sign in. No-op if no such user exists yet (a Google user is created at first login)."""
+ user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
+ if user is not None and not user.is_active:
+ user.is_active = True
+ db.commit()
+
+
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
inv = db.get(Invite, invite_id)
if inv is None:
@@ -78,6 +87,7 @@ def approve_invite(
db: Session = Depends(get_db),
) -> dict:
inv = _decide(db, invite_id, admin, approved=True)
+ _activate_user_by_email(db, inv.email)
background.add_task(email_mod.send_access_approved, inv.email)
return _serialize(inv)
@@ -109,9 +119,61 @@ def add_invite(
inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email
db.commit()
+ _activate_user_by_email(db, email)
return _serialize(inv)
+# --- Users & roles --------------------------------------------------------------------
+
+def _serialize_user(u: User) -> dict:
+ return {
+ "id": u.id,
+ "email": u.email,
+ "display_name": u.display_name,
+ "role": u.role,
+ "is_active": u.is_active,
+ "email_verified": u.email_verified,
+ "is_demo": u.is_demo,
+ "has_password": u.password_hash is not None,
+ "has_google": u.google_sub is not None,
+ "created_at": u.created_at.isoformat() if u.created_at else None,
+ }
+
+
+@router.get("/users")
+def list_users(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]:
+ rows = db.execute(select(User).order_by(User.created_at.desc())).scalars().all()
+ return [_serialize_user(u) for u in rows]
+
+
+@router.patch("/users/{user_id}/role")
+def set_user_role(
+ user_id: int,
+ payload: dict,
+ admin: User = Depends(admin_user),
+ db: Session = Depends(get_db),
+) -> dict:
+ """Promote/demote a user. Guards: can't change your own role, can't touch the demo
+ account, and can't remove the last remaining admin."""
+ role = payload.get("role")
+ if role not in ("user", "admin"):
+ raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
+ target = db.get(User, user_id)
+ if target is None:
+ raise HTTPException(status_code=404, detail="No such user")
+ if target.is_demo:
+ raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
+ if target.id == admin.id:
+ raise HTTPException(status_code=400, detail="You can't change your own role.")
+ if target.role == "admin" and role == "user":
+ admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin"))
+ if (admin_count or 0) <= 1:
+ raise HTTPException(status_code=400, detail="Can't remove the last admin.")
+ target.role = role
+ db.commit()
+ return _serialize_user(target)
+
+
# --- Demo account: email whitelist + state reset -------------------------------------
def _serialize_demo(row: DemoWhitelist) -> dict:
diff --git a/backend/app/security.py b/backend/app/security.py
index 00d2fc6..4693582 100644
--- a/backend/app/security.py
+++ b/backend/app/security.py
@@ -1,7 +1,25 @@
+from argon2 import PasswordHasher
from cryptography.fernet import Fernet
from app.config import settings
+# argon2id with the library defaults (sensible memory/time cost). One shared hasher instance.
+_ph = PasswordHasher()
+
+
+def hash_password(password: str) -> str:
+ return _ph.hash(password)
+
+
+def verify_password(password: str, hashed: str | None) -> bool:
+ if not hashed:
+ return False
+ try:
+ return _ph.verify(hashed, password)
+ except Exception:
+ # Any failure (mismatch, malformed hash) is a non-match — never raise to the caller.
+ return False
+
_fernet: Fernet | None = (
Fernet(settings.token_encryption_key.encode()) if settings.token_encryption_key else None
)
diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py
index abe117f..b5db3dc 100644
--- a/backend/app/sysconfig.py
+++ b/backend/app/sysconfig.py
@@ -50,6 +50,8 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
+ # --- Access / registration ---
+ ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
)
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 6ba02a4..67f14ad 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -12,3 +12,4 @@ apscheduler>=3.10,<4.0
py3langid>=0.3,<1.0
itsdangerous>=2.1,<3.0
cryptography>=46.0.7,<47
+argon2-cffi>=23.1,<26
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b807b22..2e2eb22 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -31,6 +31,7 @@ import Playlists from "./components/Playlists";
import Stats from "./components/Stats";
import Scheduler from "./components/Scheduler";
import ConfigPanel from "./components/ConfigPanel";
+import AdminUsers from "./components/AdminUsers";
import SettingsPanel from "./components/SettingsPanel";
import NotificationsPanel from "./components/NotificationsPanel";
import OnboardingWizard from "./components/OnboardingWizard";
@@ -472,6 +473,8 @@ export default function App() {
) : page === "config" && meQuery.data!.role === "admin" ? (
+ ) : page === "users" && meQuery.data!.role === "admin" ? (
+
) : page === "playlists" ? (
) : page === "notifications" ? (
diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx
new file mode 100644
index 0000000..d3172b1
--- /dev/null
+++ b/frontend/src/components/AdminUsers.tsx
@@ -0,0 +1,383 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
+import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
+import { notify } from "../lib/notifications";
+import Tooltip from "./Tooltip";
+import { useConfirm } from "./ConfirmProvider";
+
+// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
+// demo whitelist + reset. Moved out of Settings → Account into its own admin page; the Invite
+// and demo data are unchanged (same tables) — only the UI relocated.
+export default function AdminUsers({ me }: { me: Me }) {
+ return (
+
+ );
+}
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) {
+ const cls =
+ tone === "accent"
+ ? "border-accent/40 text-accent"
+ : tone === "warning"
+ ? "border-amber-500/40 text-amber-500"
+ : "border-border text-muted";
+ return (
+ {children}
+ );
+}
+
+function UsersRoles({ me }: { me: Me }) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const confirm = useConfirm();
+ const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
+ const setRole = useMutation({
+ mutationFn: ({ id, role }: { id: number; role: "user" | "admin" }) => api.setUserRole(id, role),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["admin-users"] });
+ notify({ level: "success", message: t("users.roles.updated") });
+ },
+ // Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail.
+ });
+
+ const onToggle = async (u: AdminUserRow) => {
+ const makeAdmin = u.role !== "admin";
+ const ok = await confirm({
+ title: makeAdmin ? t("users.roles.promoteTitle") : t("users.roles.demoteTitle"),
+ message: t(makeAdmin ? "users.roles.promoteConfirm" : "users.roles.demoteConfirm", {
+ email: u.email,
+ }),
+ confirmLabel: makeAdmin ? t("users.roles.makeAdmin") : t("users.roles.makeUser"),
+ danger: !makeAdmin,
+ });
+ if (ok) setRole.mutate({ id: u.id, role: makeAdmin ? "admin" : "user" });
+ };
+
+ const rows = q.data ?? [];
+
+ return (
+
+ {t("users.roles.intro")}
+ {rows.length === 0 ? (
+ {t("users.roles.empty")}
+ ) : (
+
+ {rows.map((u) => {
+ const self = u.id === me.id;
+ return (
+
+
+
+ {u.display_name ?? u.email.split("@")[0]}
+ {self && · {t("users.roles.you")} }
+
+
{u.email}
+
+ {u.role === "admin" &&
{t("users.roles.admin")} }
+ {u.is_demo &&
{t("users.roles.demo")} }
+ {!u.is_active &&
{t("users.roles.pending")} }
+ {u.is_active && !u.email_verified && (
+
{t("users.roles.unverified")}
+ )}
+
+ onToggle(u)}
+ disabled={u.is_demo || self || setRole.isPending}
+ className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
+ >
+
+ {u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+// --- Access requests (the Invite whitelist) — moved verbatim from Settings → Account -------
+
+function AdminInvites() {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
+ const [newEmail, setNewEmail] = useState("");
+ const refresh = () => {
+ qc.invalidateQueries({ queryKey: ["admin-invites"] });
+ qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge
+ };
+ const approve = useMutation({
+ mutationFn: (id: number) => api.approveInvite(id),
+ onSuccess: () => {
+ refresh();
+ notify({ level: "success", message: t("settings.invites.approved") });
+ },
+ onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }),
+ });
+ const deny = useMutation({
+ mutationFn: (id: number) => api.denyInvite(id),
+ onSuccess: refresh,
+ onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
+ });
+ const add = useMutation({
+ mutationFn: (email: string) => api.addInvite(email),
+ onSuccess: () => {
+ setNewEmail("");
+ refresh();
+ notify({ level: "success", message: t("settings.invites.addedToWhitelist") });
+ },
+ onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }),
+ });
+
+ const list = invites.data ?? [];
+ const pending = list.filter((i) => i.status === "pending");
+ const decided = list.filter((i) => i.status !== "pending");
+
+ return (
+
+ {pending.length === 0 ? (
+ {t("settings.invites.noPending")}
+ ) : (
+
+ {pending.map((i) => (
+ approve.mutate(i.id)}
+ onDeny={() => deny.mutate(i.id)}
+ busy={approve.isPending || deny.isPending}
+ />
+ ))}
+
+ )}
+
+
+
+ {decided.length > 0 && (
+
+
+ {t("settings.invites.decided", { count: decided.length })}
+
+
+ {decided.map((i) => (
+
+ {i.email}
+
+ {i.status}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function AdminDemo() {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const confirm = useConfirm();
+ const list = useQuery({ queryKey: ["demo-whitelist"], queryFn: () => api.adminDemoWhitelist() });
+ const [newEmail, setNewEmail] = useState("");
+
+ const add = useMutation({
+ mutationFn: (email: string) => api.addDemoWhitelist(email),
+ onSuccess: () => {
+ setNewEmail("");
+ qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
+ notify({ level: "success", message: t("settings.demo.added") });
+ },
+ onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
+ });
+ const remove = useMutation({
+ mutationFn: (id: number) => api.removeDemoWhitelist(id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["demo-whitelist"] }),
+ onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
+ });
+ const reset = useMutation({
+ mutationFn: () => api.resetDemo(),
+ onSuccess: (r: { playlists_seeded: number }) =>
+ notify({
+ level: "success",
+ message: t("settings.demo.resetDone", { count: r.playlists_seeded }),
+ }),
+ onError: () => notify({ level: "error", message: t("settings.demo.resetFailed") }),
+ });
+
+ const rows = list.data ?? [];
+
+ return (
+
+ {t("settings.demo.intro")}
+
+ {rows.length > 0 && (
+
+ {rows.map((r) => (
+
+
+
{r.email}
+ {r.added_by && (
+
+ {t("settings.demo.addedBy", { who: r.added_by })}
+
+ )}
+
+
+ remove.mutate(r.id)}
+ disabled={remove.isPending}
+ className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
+ aria-label={t("settings.demo.remove")}
+ >
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ {
+ if (
+ await confirm({
+ title: t("settings.demo.resetTitle"),
+ message: t("settings.demo.resetConfirm"),
+ confirmLabel: t("settings.demo.reset"),
+ danger: true,
+ })
+ )
+ reset.mutate();
+ }}
+ disabled={reset.isPending}
+ className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
+ >
+
+ {t("settings.demo.reset")}
+
+
+
+
+ );
+}
+
+function InviteRow({
+ inv,
+ onApprove,
+ onDeny,
+ busy,
+}: {
+ inv: Invite;
+ onApprove: () => void;
+ onDeny: () => void;
+ busy: boolean;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+
{inv.email}
+ {inv.requested_at && (
+
+ {t("settings.invites.requested", {
+ time: new Date(inv.requested_at).toLocaleString(),
+ })}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx
index 66d1d4e..9141265 100644
--- a/frontend/src/components/ConfigPanel.tsx
+++ b/frontend/src/components/ConfigPanel.tsx
@@ -11,7 +11,7 @@ import Tooltip from "./Tooltip";
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
// the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together.
-const GROUP_ORDER = ["email", "youtube", "quota", "backfill", "shorts", "batch"];
+const GROUP_ORDER = ["access", "email", "youtube", "quota", "backfill", "shorts", "batch"];
type SaveState = "idle" | "saving" | "saved" | "error";
export default function ConfigPanel() {
@@ -58,6 +58,8 @@ export default function ConfigPanel() {
if (it.secret) {
if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
else if (raw.trim()) await api.setConfig(key, raw);
+ } else if (it.type === "bool") {
+ await api.setConfig(key, raw === "true");
} else if (raw === "") {
if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
} else {
@@ -191,6 +193,7 @@ function Field({
}) {
const { t } = useTranslation();
const isNum = item.type === "int";
+ const isBool = item.type === "bool";
const secretDisabled = item.secret && !secretsManageable;
// Status line: never reveal a secret's value.
@@ -210,8 +213,9 @@ function Field({
}
// Reset affordance shows when a DB override exists: clears a non-secret to its default, or
- // toggles a secret's reset-on-save (applied on Save, not immediately).
- const showReset = item.is_set;
+ // toggles a secret's reset-on-save (applied on Save, not immediately). A boolean toggle is
+ // its own control — storing its value is equivalent to the default, so no reset link.
+ const showReset = item.is_set && !isBool;
return (
@@ -224,16 +228,20 @@ function Field({
- onChange(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"
- />
+ {isBool ? (
+ onChange(v ? "true" : "false")} />
+ ) : (
+ onChange(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"
+ />
+ )}
{showReset && !secretDisabled && (
);
}
+
+function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
+ return (
+ onChange(!checked)}
+ className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
+ >
+
+
+ );
+}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index c366c1c..b0f1b4b 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -77,7 +77,9 @@ export default function Header({
? t("header.scheduler")
: page === "config"
? t("header.configuration")
- : t("header.channelManager")}
+ : page === "users"
+ ? t("header.users")
+ : t("header.channelManager")}
)}
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx
index 269c0b7..5e6251f 100644
--- a/frontend/src/components/NavSidebar.tsx
+++ b/frontend/src/components/NavSidebar.tsx
@@ -15,6 +15,7 @@ import {
Settings,
Shield,
SlidersHorizontal,
+ Users,
Tv,
UserPlus,
} from "lucide-react";
@@ -142,6 +143,7 @@ export default function NavSidebar({
? [
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
{ page: "config", icon: SlidersHorizontal, label: t("header.account.config") },
+ { page: "users", icon: Users, label: t("header.account.users") },
]
: [];
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index c92ddbe..03e108b 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -1,13 +1,11 @@
-import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
+import { useState } from "react";
import { useTranslation } from "react-i18next";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react";
+import { Bell, Check, Monitor, RotateCcw, Save, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
-import { api, type Invite, type Me } from "../lib/api";
+import { type Me } from "../lib/api";
import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip";
-import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally
// for instant preview but reach the server only on an explicit Save (or revert on Discard).
@@ -412,265 +410,6 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
)}
- {me.role === "admin" && }
- {me.role === "admin" && }
>
);
}
-
-function AdminInvites() {
- const { t } = useTranslation();
- const qc = useQueryClient();
- const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
- const [newEmail, setNewEmail] = useState("");
- const refresh = () => {
- qc.invalidateQueries({ queryKey: ["admin-invites"] });
- qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge
- };
- const approve = useMutation({
- mutationFn: (id: number) => api.approveInvite(id),
- onSuccess: () => {
- refresh();
- notify({ level: "success", message: t("settings.invites.approved") });
- },
- onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }),
- });
- const deny = useMutation({
- mutationFn: (id: number) => api.denyInvite(id),
- onSuccess: refresh,
- onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
- });
- const add = useMutation({
- mutationFn: (email: string) => api.addInvite(email),
- onSuccess: () => {
- setNewEmail("");
- refresh();
- notify({ level: "success", message: t("settings.invites.addedToWhitelist") });
- },
- onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }),
- });
-
- const list = invites.data ?? [];
- const pending = list.filter((i) => i.status === "pending");
- const decided = list.filter((i) => i.status !== "pending");
-
- return (
-
- {pending.length === 0 ? (
- {t("settings.invites.noPending")}
- ) : (
-
- {pending.map((i) => (
- approve.mutate(i.id)}
- onDeny={() => deny.mutate(i.id)}
- busy={approve.isPending || deny.isPending}
- />
- ))}
-
- )}
-
-
-
- {decided.length > 0 && (
-
-
- {t("settings.invites.decided", { count: decided.length })}
-
-
- {decided.map((i) => (
-
- {i.email}
-
- {i.status}
-
-
- ))}
-
-
- )}
-
- );
-}
-
-function AdminDemo() {
- const { t } = useTranslation();
- const qc = useQueryClient();
- const confirm = useConfirm();
- const list = useQuery({ queryKey: ["demo-whitelist"], queryFn: () => api.adminDemoWhitelist() });
- const [newEmail, setNewEmail] = useState("");
-
- const add = useMutation({
- mutationFn: (email: string) => api.addDemoWhitelist(email),
- onSuccess: () => {
- setNewEmail("");
- qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
- notify({ level: "success", message: t("settings.demo.added") });
- },
- onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
- });
- const remove = useMutation({
- mutationFn: (id: number) => api.removeDemoWhitelist(id),
- onSuccess: () => qc.invalidateQueries({ queryKey: ["demo-whitelist"] }),
- onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
- });
- const reset = useMutation({
- mutationFn: () => api.resetDemo(),
- onSuccess: (r: { playlists_seeded: number }) =>
- notify({
- level: "success",
- message: t("settings.demo.resetDone", { count: r.playlists_seeded }),
- }),
- onError: () => notify({ level: "error", message: t("settings.demo.resetFailed") }),
- });
-
- const rows = list.data ?? [];
-
- return (
-
- {t("settings.demo.intro")}
-
- {rows.length > 0 && (
-
- {rows.map((r) => (
-
-
-
{r.email}
- {r.added_by && (
-
- {t("settings.demo.addedBy", { who: r.added_by })}
-
- )}
-
-
- remove.mutate(r.id)}
- disabled={remove.isPending}
- className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
- aria-label={t("settings.demo.remove")}
- >
-
-
-
-
- ))}
-
- )}
-
-
-
-
-
- {
- if (
- await confirm({
- title: t("settings.demo.resetTitle"),
- message: t("settings.demo.resetConfirm"),
- confirmLabel: t("settings.demo.reset"),
- danger: true,
- })
- )
- reset.mutate();
- }}
- disabled={reset.isPending}
- className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
- >
-
- {t("settings.demo.reset")}
-
-
-
-
- );
-}
-
-function InviteRow({
- inv,
- onApprove,
- onDeny,
- busy,
-}: {
- inv: Invite;
- onApprove: () => void;
- onDeny: () => void;
- busy: boolean;
-}) {
- const { t } = useTranslation();
- return (
-
-
-
{inv.email}
- {inv.requested_at && (
-
- {t("settings.invites.requested", {
- time: new Date(inv.requested_at).toLocaleString(),
- })}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/i18n/locales/de/config.json b/frontend/src/i18n/locales/de/config.json
index e9913cd..2391983 100644
--- a/frontend/src/i18n/locales/de/config.json
+++ b/frontend/src/i18n/locales/de/config.json
@@ -2,6 +2,7 @@
"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": {
+ "access": "Zugang",
"email": "E-Mail / SMTP",
"youtube": "YouTube-API",
"quota": "Kontingent",
@@ -23,7 +24,8 @@
"shorts_probe_batch": { "label": "Shorts-Prüfung — Stapelgröße", "hint": "Wie viele Videos pro Lauf klassifiziert werden." },
"enrich_batch_size": { "label": "Anreicherungs-Stapelgröße", "hint": "videos.list-IDs pro Aufruf (YouTube begrenzt auf 50)." },
"autotag_title_sample": { "label": "Auto-Tag-Titelstichprobe", "hint": "Pro Kanal abgetastete aktuelle Videotitel zur Spracherkennung." },
- "youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." }
+ "youtube_api_key": { "label": "YouTube-API-Schlüssel", "hint": "Optional. Für öffentliche Lesezugriffe (Kanäle/Videos), damit gemeinsames Nachladen nicht vom OAuth eines Nutzers abhängt. Verschlüsselt gespeichert; nur schreibbar." },
+ "allow_registration": { "label": "Registrierung erlauben", "hint": "Wenn aktiviert, kann jeder eine E-Mail+Passwort-Registrierung einreichen. Für die Anmeldung sind weiterhin E-Mail-Bestätigung und Admin-Freigabe nötig." }
},
"save": "Speichern",
"saving": "Speichern…",
diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json
index 7c10542..9f43be2 100644
--- a/frontend/src/i18n/locales/de/header.json
+++ b/frontend/src/i18n/locales/de/header.json
@@ -5,6 +5,7 @@
"usageStats": "Nutzung & Statistik",
"scheduler": "Planer",
"configuration": "Konfiguration",
+ "users": "Benutzer",
"scope": {
"label": "Feed-Quelle",
"my": "Meine",
@@ -20,6 +21,7 @@
"stats": "Statistik",
"scheduler": "Planer",
"config": "Konfiguration",
+ "users": "Benutzer",
"settings": "Einstellungen",
"about": "Über",
"signOut": "Abmelden",
diff --git a/frontend/src/i18n/locales/de/users.json b/frontend/src/i18n/locales/de/users.json
new file mode 100644
index 0000000..e115bb7
--- /dev/null
+++ b/frontend/src/i18n/locales/de/users.json
@@ -0,0 +1,21 @@
+{
+ "roles": {
+ "title": "Benutzer & Rollen",
+ "intro": "Alle, die sich anmelden können. Mache einen Benutzer zum Admin oder wieder zum normalen Benutzer.",
+ "empty": "Noch keine Benutzer.",
+ "you": "du",
+ "admin": "Admin",
+ "demo": "Demo",
+ "pending": "Wartet auf Freigabe",
+ "unverified": "E-Mail nicht bestätigt",
+ "demoLocked": "Die Rolle des Demo-Kontos kann nicht geändert werden.",
+ "selfLocked": "Du kannst deine eigene Rolle nicht ändern.",
+ "makeAdmin": "Zum Admin machen",
+ "makeUser": "Zum Benutzer machen",
+ "updated": "Rolle aktualisiert",
+ "promoteTitle": "Zum Admin machen?",
+ "demoteTitle": "Admin entfernen?",
+ "promoteConfirm": "{{email}} vollen Admin-Zugriff geben (Einstellungen, Planer, Benutzerverwaltung)?",
+ "demoteConfirm": "{{email}} den Admin-Zugriff entziehen? Wird zum normalen Benutzer."
+ }
+}
diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json
index c189cea..a035167 100644
--- a/frontend/src/i18n/locales/en/config.json
+++ b/frontend/src/i18n/locales/en/config.json
@@ -2,6 +2,7 @@
"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": {
+ "access": "Access",
"email": "Email / SMTP",
"youtube": "YouTube API",
"quota": "Quota",
@@ -23,7 +24,8 @@
"shorts_probe_batch": { "label": "Shorts probe — batch size", "hint": "How many videos to classify per run." },
"enrich_batch_size": { "label": "Enrichment batch size", "hint": "videos.list ids per call (YouTube caps this at 50)." },
"autotag_title_sample": { "label": "Auto-tag title sample", "hint": "Recent video titles sampled per channel for language detection." },
- "youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." }
+ "youtube_api_key": { "label": "YouTube API key", "hint": "Optional. Used for public reads (channels/videos) so shared backfill doesn't depend on a user's OAuth. Stored encrypted; write-only." },
+ "allow_registration": { "label": "Allow registration", "hint": "When on, anyone can submit an email+password registration. They still need to verify their email and be approved by an admin before they can sign in." }
},
"save": "Save",
"saving": "Saving…",
diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json
index ce2923c..869c892 100644
--- a/frontend/src/i18n/locales/en/header.json
+++ b/frontend/src/i18n/locales/en/header.json
@@ -5,6 +5,7 @@
"usageStats": "Usage & stats",
"scheduler": "Scheduler",
"configuration": "Configuration",
+ "users": "Users",
"scope": {
"label": "Feed source",
"my": "Mine",
@@ -20,6 +21,7 @@
"stats": "Stats",
"scheduler": "Scheduler",
"config": "Configuration",
+ "users": "Users",
"settings": "Settings",
"about": "About",
"signOut": "Sign out",
diff --git a/frontend/src/i18n/locales/en/users.json b/frontend/src/i18n/locales/en/users.json
new file mode 100644
index 0000000..47535f3
--- /dev/null
+++ b/frontend/src/i18n/locales/en/users.json
@@ -0,0 +1,21 @@
+{
+ "roles": {
+ "title": "Users & roles",
+ "intro": "Everyone who can sign in. Promote a user to admin, or back to a regular user.",
+ "empty": "No users yet.",
+ "you": "you",
+ "admin": "Admin",
+ "demo": "Demo",
+ "pending": "Pending approval",
+ "unverified": "Unverified email",
+ "demoLocked": "The demo account's role can't be changed.",
+ "selfLocked": "You can't change your own role.",
+ "makeAdmin": "Make admin",
+ "makeUser": "Make user",
+ "updated": "Role updated",
+ "promoteTitle": "Make admin?",
+ "demoteTitle": "Remove admin?",
+ "promoteConfirm": "Give {{email}} full admin access (settings, scheduler, user management)?",
+ "demoteConfirm": "Remove admin access from {{email}}? They'll become a regular user."
+ }
+}
diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json
index 21db136..13795e2 100644
--- a/frontend/src/i18n/locales/hu/config.json
+++ b/frontend/src/i18n/locales/hu/config.json
@@ -2,6 +2,7 @@
"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": {
+ "access": "Hozzáférés",
"email": "E-mail / SMTP",
"youtube": "YouTube API",
"quota": "Kvóta",
@@ -23,7 +24,8 @@
"shorts_probe_batch": { "label": "Shorts-vizsgálat — kötegméret", "hint": "Futásonként ennyi videót osztályozunk." },
"enrich_batch_size": { "label": "Gazdagítási kötegméret", "hint": "videos.list azonosítók hívásonként (a YouTube 50-ben maximálja)." },
"autotag_title_sample": { "label": "Auto-címke címmintavétel", "hint": "Csatornánként ennyi friss videócímet mintázunk a nyelvfelismeréshez." },
- "youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." }
+ "youtube_api_key": { "label": "YouTube API-kulcs", "hint": "Opcionális. Publikus olvasásokhoz (csatornák/videók), hogy a megosztott letöltés ne függjön egy felhasználó OAuth-jától. Titkosítva tárolva; csak írható." },
+ "allow_registration": { "label": "Regisztráció engedélyezése", "hint": "Bekapcsolva bárki beküldhet email+jelszó regisztrációt. Belépéshez továbbra is email-igazolás és admin-jóváhagyás szükséges." }
},
"save": "Mentés",
"saving": "Mentés…",
diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json
index ec12128..db08d44 100644
--- a/frontend/src/i18n/locales/hu/header.json
+++ b/frontend/src/i18n/locales/hu/header.json
@@ -5,6 +5,7 @@
"usageStats": "Használat és statisztika",
"scheduler": "Ütemező",
"configuration": "Konfiguráció",
+ "users": "Felhasználók",
"scope": {
"label": "Hírfolyam forrása",
"my": "Sajátom",
@@ -20,6 +21,7 @@
"stats": "Statisztika",
"scheduler": "Ütemező",
"config": "Konfiguráció",
+ "users": "Felhasználók",
"settings": "Beállítások",
"about": "Névjegy",
"signOut": "Kijelentkezés",
diff --git a/frontend/src/i18n/locales/hu/users.json b/frontend/src/i18n/locales/hu/users.json
new file mode 100644
index 0000000..2a8713a
--- /dev/null
+++ b/frontend/src/i18n/locales/hu/users.json
@@ -0,0 +1,21 @@
+{
+ "roles": {
+ "title": "Felhasználók és szerepek",
+ "intro": "Mindenki, aki be tud lépni. Léptess egy felhasználót adminná, vagy vissza sima felhasználóvá.",
+ "empty": "Még nincs felhasználó.",
+ "you": "te",
+ "admin": "Admin",
+ "demo": "Demo",
+ "pending": "Jóváhagyásra vár",
+ "unverified": "Nem igazolt e-mail",
+ "demoLocked": "A demo fiók szerepe nem módosítható.",
+ "selfLocked": "A saját szerepedet nem módosíthatod.",
+ "makeAdmin": "Adminná tesz",
+ "makeUser": "Felhasználóvá tesz",
+ "updated": "Szerep frissítve",
+ "promoteTitle": "Adminná teszed?",
+ "demoteTitle": "Elveszed az admin jogot?",
+ "promoteConfirm": "Teljes admin hozzáférést adsz neki: {{email}} (beállítások, ütemező, felhasználókezelés)?",
+ "demoteConfirm": "Elveszed az admin jogot tőle: {{email}}? Sima felhasználó lesz."
+ }
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 7372458..eb4676b 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -458,6 +458,20 @@ export interface SystemConfigData {
secrets_manageable: boolean;
}
+// Admin Users & roles page.
+export interface AdminUserRow {
+ id: number;
+ email: string;
+ display_name: string | null;
+ role: string; // "user" | "admin"
+ is_active: boolean;
+ email_verified: boolean;
+ is_demo: boolean;
+ has_password: boolean;
+ has_google: boolean;
+ created_at: string | null;
+}
+
export const api = {
me: (): Promise => req("/api/me"),
accounts: (): Promise => req("/api/me/accounts"),
@@ -574,6 +588,10 @@ export const api = {
req(`/api/admin/config/${key}`, { method: "DELETE" }),
testEmail: (): Promise<{ sent: boolean; to: string }> =>
req("/api/admin/config/test-email", { method: "POST" }),
+ // --- admin: users & roles ---
+ adminUsers: (): Promise => req("/api/admin/users"),
+ setUserRole: (id: number, role: "user" | "admin"): Promise =>
+ req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
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 e1ff998..c80d343 100644
--- a/frontend/src/lib/urlState.ts
+++ b/frontend/src/lib/urlState.ts
@@ -89,6 +89,7 @@ export const PAGES = [
"settings",
"scheduler",
"config",
+ "users",
"notifications",
] as const;