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 ( +
+
{title}
+ {children} +
+ ); +} + +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")} + )} + + + +
+ ); + })} +
+ )} +
+ ); +} + +// --- 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} + /> + ))} +
+ )} + +
{ + e.preventDefault(); + if (newEmail.trim()) add.mutate(newEmail.trim()); + }} + className="flex items-center gap-2 mt-3" + > + setNewEmail(e.target.value)} + placeholder={t("settings.invites.addPlaceholder")} + className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" + /> + +
+ + {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 })} +
+ )} +
+ + + +
+ ))} +
+ )} + +
{ + e.preventDefault(); + if (newEmail.trim()) add.mutate(newEmail.trim()); + }} + className="flex items-center gap-2" + > + setNewEmail(e.target.value)} + placeholder={t("settings.demo.addPlaceholder")} + className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" + /> + +
+ +
+ + + +
+
+ ); +} + +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 && ( + ); +} 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} - /> - ))} -
- )} - -
{ - e.preventDefault(); - if (newEmail.trim()) add.mutate(newEmail.trim()); - }} - className="flex items-center gap-2 mt-3" - > - setNewEmail(e.target.value)} - placeholder={t("settings.invites.addPlaceholder")} - className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" - /> - -
- - {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 })} -
- )} -
- - - -
- ))} -
- )} - -
{ - e.preventDefault(); - if (newEmail.trim()) add.mutate(newEmail.trim()); - }} - className="flex items-center gap-2" - > - setNewEmail(e.target.value)} - placeholder={t("settings.demo.addPlaceholder")} - className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" - /> - -
- -
- - - -
-
- ); -} - -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;