Merge feature/auth-5a (email+password auth foundations + admin Users page)
Backend: argon2 email+password auth with two-gate (verify + admin approval) activation, single-use hashed auth_tokens, allow_registration flag, admin users-list + role endpoint, current_user is_active. Frontend: admin Users page (roles + migrated Access requests / Demo), Settings>Account now personal-only, ConfigPanel boolean field. Migration 0022. Public login UI redesign is 5b.
This commit is contained in:
commit
732acfce52
26 changed files with 926 additions and 287 deletions
63
backend/alembic/versions/0022_password_auth.py
Normal file
63
backend/alembic/versions/0022_password_auth.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||||
|
|
@ -9,11 +11,20 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
|
from app import sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db
|
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.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]+$")
|
_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}
|
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:
|
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||||
user_id = request.session.get("user_id")
|
user_id = request.session.get("user_id")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
user = db.get(User, user_id)
|
user = db.get(User, user_id)
|
||||||
if user is None:
|
if user is None or not user.is_active:
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
# Always keep the active account in the switchable list — covers sessions created before
|
# Always keep the active account in the switchable list — covers sessions created before
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,10 @@ class Settings(BaseSettings):
|
||||||
allowed_emails: str = ""
|
allowed_emails: str = ""
|
||||||
admin_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.
|
# Origin of a separately served frontend dev server (enables CORS). Empty in production.
|
||||||
frontend_origin: str = ""
|
frontend_origin: str = ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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:
|
def send_test(to: str) -> bool:
|
||||||
body = (
|
body = (
|
||||||
"This is a test email from Siftlode.\n\n"
|
"This is a test email from Siftlode.\n\n"
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,20 @@ class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
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)
|
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))
|
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
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")
|
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):
|
class Invite(Base):
|
||||||
"""Access-request / whitelist row. Source of truth for who may sign in; the env
|
"""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)."""
|
ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed)."""
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
|
|
@ -59,6 +59,15 @@ def list_invites(
|
||||||
return [_serialize(i) for i in rows]
|
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:
|
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
||||||
inv = db.get(Invite, invite_id)
|
inv = db.get(Invite, invite_id)
|
||||||
if inv is None:
|
if inv is None:
|
||||||
|
|
@ -78,6 +87,7 @@ def approve_invite(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
inv = _decide(db, invite_id, admin, approved=True)
|
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)
|
background.add_task(email_mod.send_access_approved, inv.email)
|
||||||
return _serialize(inv)
|
return _serialize(inv)
|
||||||
|
|
||||||
|
|
@ -109,9 +119,61 @@ def add_invite(
|
||||||
inv.decided_at = datetime.now(timezone.utc)
|
inv.decided_at = datetime.now(timezone.utc)
|
||||||
inv.decided_by = admin.email
|
inv.decided_by = admin.email
|
||||||
db.commit()
|
db.commit()
|
||||||
|
_activate_user_by_email(db, email)
|
||||||
return _serialize(inv)
|
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 -------------------------------------
|
# --- Demo account: email whitelist + state reset -------------------------------------
|
||||||
|
|
||||||
def _serialize_demo(row: DemoWhitelist) -> dict:
|
def _serialize_demo(row: DemoWhitelist) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,25 @@
|
||||||
|
from argon2 import PasswordHasher
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
from app.config import settings
|
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: Fernet | None = (
|
||||||
Fernet(settings.token_encryption_key.encode()) if settings.token_encryption_key else None
|
Fernet(settings.token_encryption_key.encode()) if settings.token_encryption_key else None
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||||
ConfigSpec("autotag_title_sample", "int", "batch", "autotag_title_sample", min=1, max=1_000),
|
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) ---
|
# --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
|
||||||
ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
|
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}
|
_BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS}
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,4 @@ apscheduler>=3.10,<4.0
|
||||||
py3langid>=0.3,<1.0
|
py3langid>=0.3,<1.0
|
||||||
itsdangerous>=2.1,<3.0
|
itsdangerous>=2.1,<3.0
|
||||||
cryptography>=46.0.7,<47
|
cryptography>=46.0.7,<47
|
||||||
|
argon2-cffi>=23.1,<26
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import Playlists from "./components/Playlists";
|
||||||
import Stats from "./components/Stats";
|
import Stats from "./components/Stats";
|
||||||
import Scheduler from "./components/Scheduler";
|
import Scheduler from "./components/Scheduler";
|
||||||
import ConfigPanel from "./components/ConfigPanel";
|
import ConfigPanel from "./components/ConfigPanel";
|
||||||
|
import AdminUsers from "./components/AdminUsers";
|
||||||
import SettingsPanel from "./components/SettingsPanel";
|
import SettingsPanel from "./components/SettingsPanel";
|
||||||
import NotificationsPanel from "./components/NotificationsPanel";
|
import NotificationsPanel from "./components/NotificationsPanel";
|
||||||
import OnboardingWizard from "./components/OnboardingWizard";
|
import OnboardingWizard from "./components/OnboardingWizard";
|
||||||
|
|
@ -472,6 +473,8 @@ export default function App() {
|
||||||
<Scheduler />
|
<Scheduler />
|
||||||
) : page === "config" && meQuery.data!.role === "admin" ? (
|
) : page === "config" && meQuery.data!.role === "admin" ? (
|
||||||
<ConfigPanel />
|
<ConfigPanel />
|
||||||
|
) : page === "users" && meQuery.data!.role === "admin" ? (
|
||||||
|
<AdminUsers me={meQuery.data!} />
|
||||||
) : page === "playlists" ? (
|
) : page === "playlists" ? (
|
||||||
<Playlists canWrite={meQuery.data!.can_write} />
|
<Playlists canWrite={meQuery.data!.can_write} />
|
||||||
) : page === "notifications" ? (
|
) : page === "notifications" ? (
|
||||||
|
|
|
||||||
383
frontend/src/components/AdminUsers.tsx
Normal file
383
frontend/src/components/AdminUsers.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||||
|
<UsersRoles me={me} />
|
||||||
|
<AdminInvites />
|
||||||
|
<AdminDemo />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="glass rounded-2xl p-4 mb-4">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted mb-3">{title}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<span className={`shrink-0 text-[11px] px-2 py-0.5 rounded-full border ${cls}`}>{children}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Section title={t("users.roles.title")}>
|
||||||
|
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{rows.map((u) => {
|
||||||
|
const self = u.id === me.id;
|
||||||
|
return (
|
||||||
|
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm truncate">
|
||||||
|
{u.display_name ?? u.email.split("@")[0]}
|
||||||
|
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted truncate">{u.email}</div>
|
||||||
|
</div>
|
||||||
|
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
|
||||||
|
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
|
||||||
|
{!u.is_active && <Badge tone="warning">{t("users.roles.pending")}</Badge>}
|
||||||
|
{u.is_active && !u.email_verified && (
|
||||||
|
<Badge tone="warning">{t("users.roles.unverified")}</Badge>
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
hint={
|
||||||
|
u.is_demo
|
||||||
|
? t("users.roles.demoLocked")
|
||||||
|
: self
|
||||||
|
? t("users.roles.selfLocked")
|
||||||
|
: u.role === "admin"
|
||||||
|
? t("users.roles.makeUser")
|
||||||
|
: t("users.roles.makeAdmin")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<Shield className="w-3.5 h-3.5" />
|
||||||
|
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 (
|
||||||
|
<Section title={t("settings.invites.title")}>
|
||||||
|
{pending.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{pending.map((i) => (
|
||||||
|
<InviteRow
|
||||||
|
key={i.id}
|
||||||
|
inv={i}
|
||||||
|
onApprove={() => approve.mutate(i.id)}
|
||||||
|
onDeny={() => deny.mutate(i.id)}
|
||||||
|
busy={approve.isPending || deny.isPending}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newEmail.trim()) add.mutate(newEmail.trim());
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 mt-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{decided.length > 0 && (
|
||||||
|
<details className="mt-3">
|
||||||
|
<summary className="text-xs text-muted cursor-pointer">
|
||||||
|
{t("settings.invites.decided", { count: decided.length })}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||||
|
{decided.map((i) => (
|
||||||
|
<div key={i.id} className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate">{i.email}</span>
|
||||||
|
<span className={i.status === "approved" ? "text-accent" : "text-red-400"}>
|
||||||
|
{i.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Section title={t("settings.demo.title")}>
|
||||||
|
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="space-y-1.5 mb-3">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.id} className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm truncate">{r.email}</div>
|
||||||
|
{r.added_by && (
|
||||||
|
<div className="text-[11px] text-muted truncate">
|
||||||
|
{t("settings.demo.addedBy", { who: r.added_by })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Tooltip hint={t("settings.demo.removeHint")}>
|
||||||
|
<button
|
||||||
|
onClick={() => 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")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newEmail.trim()) add.mutate(newEmail.trim());
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-3 border-t border-border">
|
||||||
|
<Tooltip hint={t("settings.demo.resetHint")}>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
|
||||||
|
{t("settings.demo.reset")}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InviteRow({
|
||||||
|
inv,
|
||||||
|
onApprove,
|
||||||
|
onDeny,
|
||||||
|
busy,
|
||||||
|
}: {
|
||||||
|
inv: Invite;
|
||||||
|
onApprove: () => void;
|
||||||
|
onDeny: () => void;
|
||||||
|
busy: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm truncate">{inv.email}</div>
|
||||||
|
{inv.requested_at && (
|
||||||
|
<div className="text-[11px] text-muted">
|
||||||
|
{t("settings.invites.requested", {
|
||||||
|
time: new Date(inv.requested_at).toLocaleString(),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Tooltip hint={t("settings.invites.approveHint")}>
|
||||||
|
<button
|
||||||
|
onClick={onApprove}
|
||||||
|
disabled={busy}
|
||||||
|
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
|
||||||
|
aria-label={t("settings.invites.approve")}
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip hint={t("settings.invites.denyHint")}>
|
||||||
|
<button
|
||||||
|
onClick={onDeny}
|
||||||
|
disabled={busy}
|
||||||
|
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.invites.deny")}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ import Tooltip from "./Tooltip";
|
||||||
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
|
// 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.
|
// the server until Save. Group order is fixed where known so logically related settings (e.g.
|
||||||
// Email/SMTP) stay together.
|
// 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";
|
type SaveState = "idle" | "saving" | "saved" | "error";
|
||||||
|
|
||||||
export default function ConfigPanel() {
|
export default function ConfigPanel() {
|
||||||
|
|
@ -58,6 +58,8 @@ export default function ConfigPanel() {
|
||||||
if (it.secret) {
|
if (it.secret) {
|
||||||
if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
|
if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
|
||||||
else if (raw.trim()) await api.setConfig(key, raw);
|
else if (raw.trim()) await api.setConfig(key, raw);
|
||||||
|
} else if (it.type === "bool") {
|
||||||
|
await api.setConfig(key, raw === "true");
|
||||||
} else if (raw === "") {
|
} else if (raw === "") {
|
||||||
if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
|
if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -191,6 +193,7 @@ function Field({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isNum = item.type === "int";
|
const isNum = item.type === "int";
|
||||||
|
const isBool = item.type === "bool";
|
||||||
const secretDisabled = item.secret && !secretsManageable;
|
const secretDisabled = item.secret && !secretsManageable;
|
||||||
|
|
||||||
// Status line: never reveal a secret's value.
|
// 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
|
// 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).
|
// toggles a secret's reset-on-save (applied on Save, not immediately). A boolean toggle is
|
||||||
const showReset = item.is_set;
|
// its own control — storing its value is equivalent to the default, so no reset link.
|
||||||
|
const showReset = item.is_set && !isBool;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-3 py-3">
|
<div className="flex items-start justify-between gap-3 py-3">
|
||||||
|
|
@ -224,16 +228,20 @@ function Field({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||||
<input
|
{isBool ? (
|
||||||
type={item.secret ? "password" : isNum ? "number" : "text"}
|
<Toggle checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} />
|
||||||
value={value}
|
) : (
|
||||||
disabled={secretDisabled || (item.secret && pendingSecretReset)}
|
<input
|
||||||
onChange={(e) => onChange(e.target.value)}
|
type={item.secret ? "password" : isNum ? "number" : "text"}
|
||||||
min={isNum && item.min != null ? item.min : undefined}
|
value={value}
|
||||||
max={isNum && item.max != null ? item.max : undefined}
|
disabled={secretDisabled || (item.secret && pendingSecretReset)}
|
||||||
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
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"
|
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 && (
|
{showReset && !secretDisabled && (
|
||||||
<Tooltip hint={t("config.resetHint")}>
|
<Tooltip hint={t("config.resetHint")}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -253,3 +261,19 @@ function Field({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
|
||||||
|
checked ? "left-[18px]" : "left-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,9 @@ export default function Header({
|
||||||
? t("header.scheduler")
|
? t("header.scheduler")
|
||||||
: page === "config"
|
: page === "config"
|
||||||
? t("header.configuration")
|
? t("header.configuration")
|
||||||
: t("header.channelManager")}
|
: page === "users"
|
||||||
|
? t("header.users")
|
||||||
|
: t("header.channelManager")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
Users,
|
||||||
Tv,
|
Tv,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -142,6 +143,7 @@ export default function NavSidebar({
|
||||||
? [
|
? [
|
||||||
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
|
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
|
||||||
{ page: "config", icon: SlidersHorizontal, label: t("header.account.config") },
|
{ page: "config", icon: SlidersHorizontal, label: t("header.account.config") },
|
||||||
|
{ page: "users", icon: Users, label: t("header.account.users") },
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { Bell, Check, Monitor, RotateCcw, Save, User } from "lucide-react";
|
||||||
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react";
|
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
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 Avatar from "./Avatar";
|
||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
|
||||||
|
|
||||||
// The Settings page edits server-persisted preferences as a draft: changes apply locally
|
// 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).
|
// 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 }) {
|
||||||
</button>
|
</button>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
{me.role === "admin" && <AdminInvites />}
|
|
||||||
{me.role === "admin" && <AdminDemo />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
|
||||||
<Section title={t("settings.invites.title")}>
|
|
||||||
{pending.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{pending.map((i) => (
|
|
||||||
<InviteRow
|
|
||||||
key={i.id}
|
|
||||||
inv={i}
|
|
||||||
onApprove={() => approve.mutate(i.id)}
|
|
||||||
onDeny={() => deny.mutate(i.id)}
|
|
||||||
busy={approve.isPending || deny.isPending}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (newEmail.trim()) add.mutate(newEmail.trim());
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 mt-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={newEmail}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
|
||||||
>
|
|
||||||
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{decided.length > 0 && (
|
|
||||||
<details className="mt-3">
|
|
||||||
<summary className="text-xs text-muted cursor-pointer">
|
|
||||||
{t("settings.invites.decided", { count: decided.length })}
|
|
||||||
</summary>
|
|
||||||
<div className="mt-2 space-y-1 text-xs text-muted">
|
|
||||||
{decided.map((i) => (
|
|
||||||
<div key={i.id} className="flex items-center justify-between gap-2">
|
|
||||||
<span className="truncate">{i.email}</span>
|
|
||||||
<span className={i.status === "approved" ? "text-accent" : "text-red-400"}>
|
|
||||||
{i.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Section title={t("settings.demo.title")}>
|
|
||||||
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
|
|
||||||
|
|
||||||
{rows.length > 0 && (
|
|
||||||
<div className="space-y-1.5 mb-3">
|
|
||||||
{rows.map((r) => (
|
|
||||||
<div key={r.id} className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm truncate">{r.email}</div>
|
|
||||||
{r.added_by && (
|
|
||||||
<div className="text-[11px] text-muted truncate">
|
|
||||||
{t("settings.demo.addedBy", { who: r.added_by })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Tooltip hint={t("settings.demo.removeHint")}>
|
|
||||||
<button
|
|
||||||
onClick={() => 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")}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (newEmail.trim()) add.mutate(newEmail.trim());
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={newEmail}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
|
||||||
>
|
|
||||||
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-border">
|
|
||||||
<Tooltip hint={t("settings.demo.resetHint")}>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
|
|
||||||
{t("settings.demo.reset")}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InviteRow({
|
|
||||||
inv,
|
|
||||||
onApprove,
|
|
||||||
onDeny,
|
|
||||||
busy,
|
|
||||||
}: {
|
|
||||||
inv: Invite;
|
|
||||||
onApprove: () => void;
|
|
||||||
onDeny: () => void;
|
|
||||||
busy: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm truncate">{inv.email}</div>
|
|
||||||
{inv.requested_at && (
|
|
||||||
<div className="text-[11px] text-muted">
|
|
||||||
{t("settings.invites.requested", {
|
|
||||||
time: new Date(inv.requested_at).toLocaleString(),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Tooltip hint={t("settings.invites.approveHint")}>
|
|
||||||
<button
|
|
||||||
onClick={onApprove}
|
|
||||||
disabled={busy}
|
|
||||||
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
|
|
||||||
aria-label={t("settings.invites.approve")}
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip hint={t("settings.invites.denyHint")}>
|
|
||||||
<button
|
|
||||||
onClick={onDeny}
|
|
||||||
disabled={busy}
|
|
||||||
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.invites.deny")}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
"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…",
|
"loading": "Konfiguration wird geladen…",
|
||||||
"groups": {
|
"groups": {
|
||||||
|
"access": "Zugang",
|
||||||
"email": "E-Mail / SMTP",
|
"email": "E-Mail / SMTP",
|
||||||
"youtube": "YouTube-API",
|
"youtube": "YouTube-API",
|
||||||
"quota": "Kontingent",
|
"quota": "Kontingent",
|
||||||
|
|
@ -23,7 +24,8 @@
|
||||||
"shorts_probe_batch": { "label": "Shorts-Prüfung — Stapelgröße", "hint": "Wie viele Videos pro Lauf klassifiziert werden." },
|
"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)." },
|
"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." },
|
"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",
|
"save": "Speichern",
|
||||||
"saving": "Speichern…",
|
"saving": "Speichern…",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"usageStats": "Nutzung & Statistik",
|
"usageStats": "Nutzung & Statistik",
|
||||||
"scheduler": "Planer",
|
"scheduler": "Planer",
|
||||||
"configuration": "Konfiguration",
|
"configuration": "Konfiguration",
|
||||||
|
"users": "Benutzer",
|
||||||
"scope": {
|
"scope": {
|
||||||
"label": "Feed-Quelle",
|
"label": "Feed-Quelle",
|
||||||
"my": "Meine",
|
"my": "Meine",
|
||||||
|
|
@ -20,6 +21,7 @@
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
"scheduler": "Planer",
|
"scheduler": "Planer",
|
||||||
"config": "Konfiguration",
|
"config": "Konfiguration",
|
||||||
|
"users": "Benutzer",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"about": "Über",
|
"about": "Über",
|
||||||
"signOut": "Abmelden",
|
"signOut": "Abmelden",
|
||||||
|
|
|
||||||
21
frontend/src/i18n/locales/de/users.json
Normal file
21
frontend/src/i18n/locales/de/users.json
Normal file
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
"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…",
|
"loading": "Loading configuration…",
|
||||||
"groups": {
|
"groups": {
|
||||||
|
"access": "Access",
|
||||||
"email": "Email / SMTP",
|
"email": "Email / SMTP",
|
||||||
"youtube": "YouTube API",
|
"youtube": "YouTube API",
|
||||||
"quota": "Quota",
|
"quota": "Quota",
|
||||||
|
|
@ -23,7 +24,8 @@
|
||||||
"shorts_probe_batch": { "label": "Shorts probe — batch size", "hint": "How many videos to classify per run." },
|
"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)." },
|
"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." },
|
"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",
|
"save": "Save",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"usageStats": "Usage & stats",
|
"usageStats": "Usage & stats",
|
||||||
"scheduler": "Scheduler",
|
"scheduler": "Scheduler",
|
||||||
"configuration": "Configuration",
|
"configuration": "Configuration",
|
||||||
|
"users": "Users",
|
||||||
"scope": {
|
"scope": {
|
||||||
"label": "Feed source",
|
"label": "Feed source",
|
||||||
"my": "Mine",
|
"my": "Mine",
|
||||||
|
|
@ -20,6 +21,7 @@
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
"scheduler": "Scheduler",
|
"scheduler": "Scheduler",
|
||||||
"config": "Configuration",
|
"config": "Configuration",
|
||||||
|
"users": "Users",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"signOut": "Sign out",
|
"signOut": "Sign out",
|
||||||
|
|
|
||||||
21
frontend/src/i18n/locales/en/users.json
Normal file
21
frontend/src/i18n/locales/en/users.json
Normal file
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
"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…",
|
"loading": "Konfiguráció betöltése…",
|
||||||
"groups": {
|
"groups": {
|
||||||
|
"access": "Hozzáférés",
|
||||||
"email": "E-mail / SMTP",
|
"email": "E-mail / SMTP",
|
||||||
"youtube": "YouTube API",
|
"youtube": "YouTube API",
|
||||||
"quota": "Kvóta",
|
"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." },
|
"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)." },
|
"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." },
|
"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",
|
"save": "Mentés",
|
||||||
"saving": "Mentés…",
|
"saving": "Mentés…",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"usageStats": "Használat és statisztika",
|
"usageStats": "Használat és statisztika",
|
||||||
"scheduler": "Ütemező",
|
"scheduler": "Ütemező",
|
||||||
"configuration": "Konfiguráció",
|
"configuration": "Konfiguráció",
|
||||||
|
"users": "Felhasználók",
|
||||||
"scope": {
|
"scope": {
|
||||||
"label": "Hírfolyam forrása",
|
"label": "Hírfolyam forrása",
|
||||||
"my": "Sajátom",
|
"my": "Sajátom",
|
||||||
|
|
@ -20,6 +21,7 @@
|
||||||
"stats": "Statisztika",
|
"stats": "Statisztika",
|
||||||
"scheduler": "Ütemező",
|
"scheduler": "Ütemező",
|
||||||
"config": "Konfiguráció",
|
"config": "Konfiguráció",
|
||||||
|
"users": "Felhasználók",
|
||||||
"settings": "Beállítások",
|
"settings": "Beállítások",
|
||||||
"about": "Névjegy",
|
"about": "Névjegy",
|
||||||
"signOut": "Kijelentkezés",
|
"signOut": "Kijelentkezés",
|
||||||
|
|
|
||||||
21
frontend/src/i18n/locales/hu/users.json
Normal file
21
frontend/src/i18n/locales/hu/users.json
Normal file
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -458,6 +458,20 @@ export interface SystemConfigData {
|
||||||
secrets_manageable: boolean;
|
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 = {
|
export const api = {
|
||||||
me: (): Promise<Me> => req("/api/me"),
|
me: (): Promise<Me> => req("/api/me"),
|
||||||
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
|
||||||
|
|
@ -574,6 +588,10 @@ export const api = {
|
||||||
req(`/api/admin/config/${key}`, { method: "DELETE" }),
|
req(`/api/admin/config/${key}`, { method: "DELETE" }),
|
||||||
testEmail: (): Promise<{ sent: boolean; to: string }> =>
|
testEmail: (): Promise<{ sent: boolean; to: string }> =>
|
||||||
req("/api/admin/config/test-email", { method: "POST" }),
|
req("/api/admin/config/test-email", { method: "POST" }),
|
||||||
|
// --- admin: users & roles ---
|
||||||
|
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
|
||||||
|
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
|
||||||
|
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
|
||||||
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
|
schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
|
||||||
updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
|
updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
|
||||||
req(`/api/admin/scheduler/jobs/${jobId}`, {
|
req(`/api/admin/scheduler/jobs/${jobId}`, {
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ export const PAGES = [
|
||||||
"settings",
|
"settings",
|
||||||
"scheduler",
|
"scheduler",
|
||||||
"config",
|
"config",
|
||||||
|
"users",
|
||||||
"notifications",
|
"notifications",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue