Merge feature/auth-5e-google-link — complete epic 5 (auth/account) + admin user management

This commit is contained in:
npeter83 2026-06-19 19:52:38 +02:00
commit 5d3ef4f337
26 changed files with 1148 additions and 97 deletions

View file

@ -0,0 +1,30 @@
"""admin account suspension
Revision ID: 0023_user_suspension
Revises: 0022_password_auth
Create Date: 2026-06-19
Adds users.is_suspended an admin-controlled access block, distinct from is_active (the
approval lifecycle). A suspended account can't sign in (password or Google) and any live
session is rejected; the user is told why and given the operator's contact.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0023_user_suspension"
down_revision: Union[str, None] = "0022_password_auth"
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("is_suspended", sa.Boolean(), nullable=False, server_default="false"),
)
def downgrade() -> None:
op.drop_column("users", "is_suspended")

View file

@ -0,0 +1,31 @@
"""backfill email_verified for Google accounts
Revision ID: 0024_google_email_verified
Revises: 0023_user_suspension
Create Date: 2026-06-19
A Google sign-in proves the email, but earlier Google signups were created with
email_verified=false (the callback didn't set it). Backfill those so the flag reflects reality —
matters because password-login gates on email_verified, so a Google user who later adds a password
must count as verified. Demo/password-only rows (google_sub IS NULL) are left untouched.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0024_google_email_verified"
down_revision: Union[str, None] = "0023_user_suspension"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"UPDATE users SET email_verified = true "
"WHERE google_sub IS NOT NULL AND email_verified = false"
)
def downgrade() -> None:
# Not reversible — we can't tell which rows were flipped. No-op.
pass

View file

@ -4,10 +4,11 @@ import re
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import httpx
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
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy import select from sqlalchemy import delete, 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
@ -16,7 +17,7 @@ from app.config import settings
from app.db import get_db from app.db import get_db
from app.models import AuthToken, 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, hash_password, verify_password from app.security import decrypt, encrypt, hash_password, verify_password
# Email+password auth tuning. # Email+password auth tuning.
PASSWORD_MIN_LEN = 10 PASSWORD_MIN_LEN = 10
@ -25,6 +26,22 @@ RESET_TTL = timedelta(hours=1)
_register_limiter = RateLimiter(max_events=5, window_seconds=300) _register_limiter = RateLimiter(max_events=5, window_seconds=300)
_login_limiter = RateLimiter(max_events=10, window_seconds=300) _login_limiter = RateLimiter(max_events=10, window_seconds=300)
_reset_limiter = RateLimiter(max_events=5, window_seconds=300) _reset_limiter = RateLimiter(max_events=5, window_seconds=300)
# At most one "your account is suspended" email per address per hour, so a suspended user who
# keeps retrying (or a script with their valid credentials) can't be used to mailbomb them.
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
def operator_contact() -> str | None:
"""Who a blocked user should contact — the first configured admin email, if any."""
return next(iter(sorted(settings.admin_email_set)), None)
def _notify_suspended(background: BackgroundTasks, email: str) -> None:
"""Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid)
sign-in was just blocked. Gated by verified credentials at the call site, so this can't be
triggered by someone who doesn't control the account."""
if _suspend_email_limiter.allow(email):
background.add_task(email_mod.send_account_suspended, email, operator_contact())
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@ -190,7 +207,16 @@ async def callback(
if not userinfo or not userinfo.get("sub"): if not userinfo or not userinfo.get("sub"):
raise HTTPException(status_code=400, detail="No user info returned by Google") raise HTTPException(status_code=400, detail="No user info returned by Google")
sub = userinfo["sub"]
email = (userinfo.get("email") or "").lower() email = (userinfo.get("email") or "").lower()
# Linking mode: an already-authenticated account is attaching this Google identity (a
# password account enabling YouTube or connecting SSO). The marker is set by /auth/link and
# /auth/upgrade; when present we attach to the current user instead of identifying by sub.
link_uid = request.session.pop("oauth_link_uid", None)
if link_uid is not None:
return _complete_link(request, db, link_uid, sub, userinfo, token)
if not is_allowed(db, email): if not is_allowed(db, email):
log.warning("Login denied (not approved): %s", email or "<no email>") log.warning("Login denied (not approved): %s", email or "<no email>")
# A denied Google login doubles as an access request: record it for the admin and # A denied Google login doubles as an access request: record it for the admin and
@ -206,14 +232,49 @@ async def callback(
return RedirectResponse(url="/?access=requested") return RedirectResponse(url="/?access=requested")
return RedirectResponse(url="/?access=denied") return RedirectResponse(url="/?access=denied")
user = db.query(User).filter(User.google_sub == userinfo["sub"]).one_or_none() user = db.query(User).filter(User.google_sub == sub).one_or_none()
if user is None: if user is None:
user = User(google_sub=userinfo["sub"], email=email) # No account carries this Google identity yet. If one already exists for this email —
# e.g. an email+password registration — adopt the Google identity onto it instead of
# inserting a duplicate (which would collide on the unique email and 500). Google has
# verified the address, so this safely unifies password + Google sign-in on one account.
existing = db.query(User).filter(User.email == email).one_or_none()
if existing is not None:
if existing.google_sub is not None and existing.google_sub != sub:
# A different Google identity is already bound to this email — refuse rather than
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
log.warning("Google login email collision (different sub): %s", email)
return RedirectResponse(url="/?access=denied")
user = existing
user.google_sub = sub
# Google verified the email and is_allowed granted access, so finish activating a
# previously pending password registration (otherwise current_user would bounce it).
user.email_verified = True
user.is_active = True
else:
user = User(google_sub=sub, email=email)
db.add(user) db.add(user)
# A suspended account can't sign in by any method. Block before establishing the session and
# tell the (verified) owner why — a successful Google auth proves they control this account.
if user.is_suspended:
log.info("Suspended account blocked at Google login: %s", email)
_notify_suspended(background, user.email)
return RedirectResponse(url="/?login=suspended")
user.email = email user.email = email
user.display_name = userinfo.get("name") user.display_name = userinfo.get("name")
user.avatar_url = userinfo.get("picture") user.avatar_url = userinfo.get("picture")
user.role = "admin" if email in settings.admin_email_set else "user" # ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
# routine login must NOT reset it (that would clobber a UI promotion/demotion). Only seed a
# default for a brand-new account (its role attribute is still unset before flush).
if email in settings.admin_email_set:
user.role = "admin"
elif not user.role:
user.role = "user"
# Google attests the email, so a Google sign-in implicitly verifies it. Setting this also lets
# a Google user who later adds a password sign in (password-login gates on email_verified).
if userinfo.get("email_verified", True):
user.email_verified = True
# Default UI language from the Google-reported locale on first login (if the user hasn't # Default UI language from the Google-reported locale on first login (if the user hasn't
# picked one yet); unsupported locales fall back to English. # picked one yet); unsupported locales fall back to English.
prefs = dict(user.preferences or {}) prefs = dict(user.preferences or {})
@ -223,19 +284,7 @@ async def callback(
user.preferences = prefs user.preferences = prefs
db.flush() db.flush()
tok = user.token or OAuthToken(user=user) _store_token(db, user, token)
# Google only returns a refresh_token on (re)consent; keep the previous one otherwise.
if token.get("refresh_token"):
tok.refresh_token_enc = encrypt(token["refresh_token"])
tok.access_token = token.get("access_token")
expires_at = token.get("expires_at")
tok.expiry = (
datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
)
# include_granted_scopes=true means Google returns the union of all scopes the user
# has ever granted this app, so this correctly reflects read/write upgrades too.
tok.scopes = token.get("scope") or BASE_SCOPES
db.add(tok)
db.commit() db.commit()
# Multi-session: remember every account that has authenticated in this browser so the # Multi-session: remember every account that has authenticated in this browser so the
@ -249,6 +298,94 @@ async def callback(
return RedirectResponse(url="/") return RedirectResponse(url="/")
def _store_token(db: Session, user: User, token: dict) -> None:
"""Persist the OAuth grant on the user's single token row, creating it if absent. Google
only returns a refresh_token on (re)consent, so the previous one is kept otherwise.
include_granted_scopes=true means `scope` is the union of everything granted, so this
reflects read/write upgrades correctly."""
tok = user.token or OAuthToken(user=user)
if token.get("refresh_token"):
tok.refresh_token_enc = encrypt(token["refresh_token"])
tok.access_token = token.get("access_token")
expires_at = token.get("expires_at")
tok.expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
tok.scopes = token.get("scope") or BASE_SCOPES
db.add(tok)
def revoke_google_token(token: str | None) -> None:
"""Best-effort revocation of a Google OAuth grant at Google's endpoint. Revoking the refresh
token tears down the whole grant, so a later sign-in starts from a clean consent (no scopes
silently carried over via include_granted_scopes). Fail-soft: never blocks the caller used
from account deletion, where our own data is already gone regardless of Google's response."""
if not token:
return
try:
resp = httpx.post(
"https://oauth2.googleapis.com/revoke",
data={"token": token},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
log.info("Google token revoke: status=%s", resp.status_code)
except Exception:
log.warning("Google token revoke failed", exc_info=True)
def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
"""Hard-delete a user and ALL their personal data (subscriptions, tags, video states,
playlists, notifications, tokens all cascade on the users row), erase their access-request
row, and revoke their Google grant. Shared by GDPR self-deletion and admin deletion. Does NOT
touch any browser session each caller handles its own."""
email = user.email.lower()
user_id = user.id
tok = user.token
google_token = (decrypt(tok.refresh_token_enc) or tok.access_token) if tok else None
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
db.execute(delete(User).where(User.id == user_id))
db.execute(delete(Invite).where(Invite.email == email))
db.commit()
if google_token:
background.add_task(revoke_google_token, google_token)
# Confirm the erasure to the (now former) user — GDPR good practice, and covers both the
# self-service and admin deletion paths since both funnel through here.
background.add_task(email_mod.send_account_deleted, email, operator_contact())
def _complete_link(
request: Request, db: Session, link_uid: int, sub: str, userinfo: dict, token: dict
):
"""Attach the just-authorized Google identity to the already-signed-in account that started
the link/upgrade. No is_allowed gate they're already an active user. Refuses to hijack a
Google identity owned by another account, or to silently swap a different linked identity."""
# Consume the companion marker up front so it never lingers across the early returns below.
explicit = request.session.pop("oauth_link_explicit", False)
# The marker must match the live session user (guards a stale/forged marker).
if link_uid != request.session.get("user_id"):
return RedirectResponse(url="/?link=error")
user = db.get(User, link_uid)
if user is None or user.is_demo:
return RedirectResponse(url="/?link=error")
other = (
db.query(User).filter(User.google_sub == sub, User.id != user.id).one_or_none()
)
if other is not None:
return RedirectResponse(url="/?link=conflict")
if user.google_sub is not None and user.google_sub != sub:
return RedirectResponse(url="/?link=mismatch")
user.google_sub = sub
if not user.display_name:
user.display_name = userinfo.get("name")
if not user.avatar_url:
user.avatar_url = userinfo.get("picture")
_store_token(db, user, token)
db.commit()
log.info("Google linked: uid=%s scopes=%s", user.id, token.get("scope"))
# An explicit "Connect Google" lands on Settings with a confirmation; a YouTube upgrade
# (wizard / Settings access rows) returns to the app so the onboarding flow resumes as before.
return RedirectResponse(url="/?link=ok" if explicit else "/")
@router.post("/logout") @router.post("/logout")
async def logout(request: Request): async def logout(request: Request):
"""Sign out the active account. If other accounts have authenticated in this browser, """Sign out the active account. If other accounts have authenticated in this browser,
@ -313,8 +450,7 @@ def _establish_session(request: Request, user: User) -> None:
def _app_base() -> str: def _app_base() -> str:
"""Public origin of the deployed app (OAUTH_REDIRECT_URL is .../auth/callback).""" """Public origin of the deployed app (OAUTH_REDIRECT_URL is .../auth/callback)."""
u = settings.oauth_redirect_url return settings.app_base
return u.split("/auth/")[0] if "/auth/" in u else u.rstrip("/")
def _hash_token(raw: str) -> str: def _hash_token(raw: str) -> str:
@ -381,17 +517,24 @@ def register(
existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none() existing = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if existing is None: if existing is None:
# Without working SMTP we can't deliver a verification link, so email ownership can't
# gate sign-in. Admin approval stays the real gate (is_active=False); mark the account
# verified so the flow still completes on a no-SMTP self-host. See email.email_enabled().
email_ok = email_mod.email_enabled()
user = User( user = User(
email=email, email=email,
password_hash=hash_password(password), password_hash=hash_password(password),
is_active=False, is_active=False,
email_verified=False, email_verified=not email_ok,
) )
db.add(user) db.add(user)
db.flush() db.flush()
upsert_pending_invite(db, email) # admin-approval gate upsert_pending_invite(db, email) # admin-approval gate
if email_ok:
raw = _issue_token(db, user, "verify", VERIFY_TTL) raw = _issue_token(db, user, "verify", VERIFY_TTL)
background.add_task(email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}") background.add_task(
email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}"
)
if settings.admin_email_set: if settings.admin_email_set:
background.add_task( background.add_task(
email_mod.send_admin_new_request, sorted(settings.admin_email_set), email email_mod.send_admin_new_request, sorted(settings.admin_email_set), email
@ -413,11 +556,14 @@ def verify_email(token: str, db: Session = Depends(get_db)):
@router.post("/password-login") @router.post("/password-login")
def password_login( def password_login(
payload: dict, request: Request, db: Session = Depends(get_db) payload: dict,
request: Request,
background: BackgroundTasks,
db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Email+password sign-in. Wrong email/password give a single uniform 401 (no enumeration). """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 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).""" still pending or suspended that's not an enumeration leak (they already hold the password)."""
if not _login_limiter.allow(_client_ip(request)): if not _login_limiter.allow(_client_ip(request)):
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.") raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
email = (payload.get("email") or "").strip().lower() email = (payload.get("email") or "").strip().lower()
@ -425,6 +571,13 @@ def password_login(
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() 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): 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.") raise HTTPException(status_code=401, detail="Invalid email or password.")
if user.is_suspended:
_notify_suspended(background, user.email)
op = operator_contact()
detail = "Your account has been suspended."
if op:
detail += f" If you have questions, contact the operator at {op}."
raise HTTPException(status_code=403, detail=detail)
if not user.email_verified: if not user.email_verified:
raise HTTPException(status_code=403, detail="Please verify your email first (check your inbox).") raise HTTPException(status_code=403, detail="Please verify your email first (check your inbox).")
if not user.is_active: if not user.is_active:
@ -484,7 +637,8 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
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 or not user.is_active: if user is None or not user.is_active or user.is_suspended:
# Suspended/deactivated mid-session → drop the session so the block takes effect at once.
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
@ -506,6 +660,49 @@ def require_human(user: User = Depends(current_user)) -> User:
return user return user
@router.get("/link")
async def link_google(request: Request, user: User = Depends(current_user)):
"""Start linking a Google account to the signed-in (e.g. password) account. Requests only
the identity scopes YouTube access is granted separately via /auth/upgrade. The callback
sees `oauth_link_uid` and attaches the identity instead of creating a new account."""
if user.is_demo:
return RedirectResponse(url="/")
request.session["oauth_link_uid"] = user.id
request.session["oauth_link_explicit"] = True
return await oauth.google.authorize_redirect(
request,
settings.oauth_redirect_url,
access_type="offline",
prompt="select_account",
include_granted_scopes="true",
scope=BASE_SCOPES,
)
@router.post("/set-password")
def set_password(
payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Set or change the signed-in account's password. Setting a first password (Google-only
account) needs no current password the session already proves identity. Changing an
existing one requires the current password, so a hijacked session can't silently rotate it."""
if user.is_demo:
raise HTTPException(status_code=403, detail="Not available in the demo account.")
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.",
)
if user.password_hash:
if not verify_password(payload.get("current_password") or "", user.password_hash):
raise HTTPException(status_code=403, detail="Current password is incorrect.")
user.password_hash = hash_password(new_password)
db.commit()
log.info("Password set/changed: uid=%s", user.id)
return {"ok": True}
@router.get("/upgrade") @router.get("/upgrade")
async def upgrade( async def upgrade(
request: Request, access: str = "read", user: User = Depends(current_user) request: Request, access: str = "read", user: User = Depends(current_user)
@ -519,6 +716,9 @@ async def upgrade(
# straight home (no YouTube link is ever attached to it) rather than shown a raw 403. # straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
if user.is_demo: if user.is_demo:
return RedirectResponse(url="/") return RedirectResponse(url="/")
# Attach the grant to THIS account in the callback. Without it a password account (no
# google_sub) would get a brand-new, separate Google user created instead of being linked.
request.session["oauth_link_uid"] = user.id
scope = WRITE_SCOPES if access == "write" else READ_SCOPES scope = WRITE_SCOPES if access == "write" else READ_SCOPES
return await oauth.google.authorize_redirect( return await oauth.google.authorize_redirect(
request, request,

View file

@ -119,6 +119,14 @@ class Settings(BaseSettings):
def admin_email_set(self) -> set[str]: def admin_email_set(self) -> set[str]:
return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()} return {e.strip().lower() for e in self.admin_emails.split(",") if e.strip()}
@property
def app_base(self) -> str:
"""Public origin of the deployed app, derived from the OAuth redirect URL
(.../auth/callback). The single source for user-facing links (verification, reset,
approval emails); dev and prod differ, so links must always come from config."""
u = self.oauth_redirect_url
return u.split("/auth/")[0] if "/auth/" in u else u.rstrip("/")
@property @property
def session_https_only(self) -> bool: def session_https_only(self) -> bool:
"""Mark the session cookie Secure when we're served over HTTPS. We treat an """Mark the session cookie Secure when we're served over HTTPS. We treat an

View file

@ -74,8 +74,8 @@ def send_access_approved(email: str) -> bool:
body = ( body = (
"Hi,\n\n" "Hi,\n\n"
"Your request to use Siftlode has been approved — welcome aboard.\n\n" "Your request to use Siftlode has been approved — welcome aboard.\n\n"
"Just open Siftlode and sign in with this Google account, and your YouTube\n" "Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n"
"subscriptions feed will be ready.\n\n" f"{settings.app_base}\n\n"
"If you weren't expecting this, you can ignore the message.\n\n" "If you weren't expecting this, you can ignore the message.\n\n"
"— Siftlode" "— Siftlode"
+ (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "") + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
@ -83,6 +83,64 @@ def send_access_approved(email: str) -> bool:
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin) return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
def send_role_changed(to: str, role: str, operator: str | None) -> bool:
line = (
"You've been granted admin access on Siftlode — you can now manage settings, the "
"scheduler and users."
if role == "admin"
else "Your admin access on Siftlode has been removed — you're now a regular user."
)
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
body = "Hi,\n\n" + line + contact + "\n\n— Siftlode"
return _send([to], "Your Siftlode role has changed", body, reply_to=operator)
def send_account_unsuspended(to: str, operator: str | None) -> bool:
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
body = (
"Hi,\n\n"
"Your Siftlode account has been reinstated — the suspension was lifted and you can sign "
"in again."
+ contact
+ "\n\n— Siftlode"
)
return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator)
def send_account_deleted(to: str, operator: str | None) -> bool:
contact = (
f"\n\nIf you didn't request this, contact the Siftlode operator at {operator}."
if operator
else ""
)
body = (
"Hi,\n\n"
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
"Thanks for having used Siftlode."
+ contact
+ "\n\n— Siftlode"
)
return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator)
def send_account_suspended(to: str, operator: str | None) -> bool:
contact = (
f"If you think this is a mistake or have questions, contact the Siftlode operator "
f"at {operator}.\n\n"
if operator
else "If you think this is a mistake, please contact the Siftlode operator.\n\n"
)
body = (
"Hi,\n\n"
"A sign-in to your Siftlode account was just attempted, but the account is currently "
"suspended, so access was denied.\n\n"
+ contact
+ "— Siftlode"
)
return _send([to], "Your Siftlode account is suspended", body, reply_to=operator)
def send_admin_new_request(admins: list[str], requester: str) -> bool: def send_admin_new_request(admins: list[str], requester: str) -> bool:
body = ( body = (
f"{requester} just requested access to Siftlode.\n\n" f"{requester} just requested access to Siftlode.\n\n"

View file

@ -107,4 +107,11 @@ async def spa_fallback(full_path: str) -> FileResponse:
# Client-side routes fall back to index.html; real API/asset paths are matched above. # Client-side routes fall back to index.html; real API/asset paths are matched above.
if full_path.startswith(("api/", "auth/", "healthz", "assets/")): if full_path.startswith(("api/", "auth/", "healthz", "assets/")):
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
# Serve real files that live at the SPA root (Vite copies public/ there — e.g. the landing
# screenshots under /welcome/, favicon). /assets is already mounted above; everything else
# that isn't a real file is a client-side route → index.html. Guard against path traversal.
if full_path:
candidate = (STATIC_DIR / full_path).resolve()
if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents:
return FileResponse(candidate)
return FileResponse(STATIC_DIR / "index.html") return FileResponse(STATIC_DIR / "index.html")

View file

@ -37,6 +37,12 @@ class User(Base):
# Admin-approved & enabled. A pending password registration starts inactive; admin # Admin-approved & enabled. A pending password registration starts inactive; admin
# approval activates it. A deactivated account can't log in. # approval activates it. A deactivated account can't log in.
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
# Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended
# account can't sign in by any method and its live sessions are rejected; the user is told
# why and pointed at the operator's contact. The demo account is never suspendable.
is_suspended: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
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")

View file

@ -8,13 +8,14 @@ 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
from app.auth import current_user, get_or_create_demo_user from app.auth import current_user, get_or_create_demo_user, operator_contact, purge_user
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
DemoWhitelist, DemoWhitelist,
Invite, Invite,
Playlist, Playlist,
PlaylistItem, PlaylistItem,
Subscription,
User, User,
Video, Video,
VideoState, VideoState,
@ -132,6 +133,7 @@ def _serialize_user(u: User) -> dict:
"display_name": u.display_name, "display_name": u.display_name,
"role": u.role, "role": u.role,
"is_active": u.is_active, "is_active": u.is_active,
"is_suspended": u.is_suspended,
"email_verified": u.email_verified, "email_verified": u.email_verified,
"is_demo": u.is_demo, "is_demo": u.is_demo,
"has_password": u.password_hash is not None, "has_password": u.password_hash is not None,
@ -150,11 +152,12 @@ def list_users(_: User = Depends(admin_user), db: Session = Depends(get_db)) ->
def set_user_role( def set_user_role(
user_id: int, user_id: int,
payload: dict, payload: dict,
background: BackgroundTasks,
admin: User = Depends(admin_user), admin: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Promote/demote a user. Guards: can't change your own role, can't touch the demo """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.""" account, and can't remove the last remaining admin. Emails the user when it actually changes."""
role = payload.get("role") role = payload.get("role")
if role not in ("user", "admin"): if role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'") raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
@ -169,11 +172,82 @@ def set_user_role(
admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin"))
if (admin_count or 0) <= 1: if (admin_count or 0) <= 1:
raise HTTPException(status_code=400, detail="Can't remove the last admin.") raise HTTPException(status_code=400, detail="Can't remove the last admin.")
changed = target.role != role
target.role = role target.role = role
db.commit() db.commit()
if changed:
background.add_task(email_mod.send_role_changed, target.email, role, operator_contact())
return _serialize_user(target) return _serialize_user(target)
@router.patch("/users/{user_id}/suspend")
def set_user_suspended(
user_id: int,
payload: dict,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Suspend or un-suspend an account. A suspended user can't sign in by any method and any
live session is dropped on its next request. Guards: can't suspend the demo account, can't
suspend yourself, and can't suspend the last active admin (that would lock admin out). On
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
sign-in)."""
suspended = bool(payload.get("suspended"))
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 can't be suspended.")
if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
if suspended and target.role == "admin" and not target.is_suspended:
active_admins = db.scalar(
select(func.count())
.select_from(User)
.where(User.role == "admin", User.is_suspended.is_(False))
)
if (active_admins or 0) <= 1:
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
was_suspended = target.is_suspended
target.is_suspended = suspended
db.commit()
if was_suspended and not suspended:
background.add_task(
email_mod.send_account_unsuspended, target.email, operator_contact()
)
return _serialize_user(target)
@router.delete("/users/{user_id}")
def admin_delete_user(
user_id: int,
background: BackgroundTasks,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Admin-triggered account deletion — the same full erasure a user can perform on themselves
(cascade delete of all personal data + access-request cleanup + Google-grant revoke). Guards:
not the demo account, not yourself (use Settings Account), and not the last 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 can't be deleted.")
if target.id == admin.id:
raise HTTPException(
status_code=400, detail="Delete your own account from Settings → Account."
)
if target.role == "admin":
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 delete the last admin.")
purge_user(db, target, background)
return {"deleted": user_id}
# --- Demo account: email whitelist + state reset ------------------------------------- # --- Demo account: email whitelist + state reset -------------------------------------
def _serialize_demo(row: DemoWhitelist) -> dict: def _serialize_demo(row: DemoWhitelist) -> dict:
@ -270,16 +344,35 @@ def _seed_demo_playlists(db: Session, demo: User) -> int:
return created return created
def _seed_demo_subscriptions(db: Session, demo: User, n: int = 14) -> int:
"""Seed the demo with a handful of catalog channels (the ones with the most videos) so its
Channel manager isn't empty — gives visitors something to explore and matches the landing-page
screenshot. Idempotent: clears the demo's subscriptions first, then re-adds the top N."""
db.execute(delete(Subscription).where(Subscription.user_id == demo.id))
top = db.execute(
select(Video.channel_id)
.where(Video.channel_id.is_not(None))
.group_by(Video.channel_id)
.order_by(func.count().desc())
.limit(n)
).scalars().all()
for channel_id in top:
db.add(Subscription(user_id=demo.id, channel_id=channel_id))
return len(top)
def reset_demo_state(db: Session, demo: User) -> int: def reset_demo_state(db: Session, demo: User) -> int:
"""Wipe the demo account's per-user state (watch/save/hide states, playlists, preferences) """Wipe the demo account's per-user state (watch/save/hide states, playlists, preferences)
back to a clean baseline and re-seed a few sample playlists. Returns the seeded count. back to a clean baseline and re-seed a few sample playlists + channel subscriptions. Returns
Shared by the admin's manual reset button and the scheduled demo reset (see scheduler).""" the seeded playlist count. Shared by the admin's manual reset button and the scheduled demo
reset (see scheduler)."""
db.execute(delete(VideoState).where(VideoState.user_id == demo.id)) db.execute(delete(VideoState).where(VideoState.user_id == demo.id))
# Playlist items cascade on playlist delete (ondelete="CASCADE"). # Playlist items cascade on playlist delete (ondelete="CASCADE").
db.execute(delete(Playlist).where(Playlist.user_id == demo.id)) db.execute(delete(Playlist).where(Playlist.user_id == demo.id))
demo.preferences = {"language": "en"} demo.preferences = {"language": "en"}
db.commit() db.commit()
seeded = _seed_demo_playlists(db, demo) seeded = _seed_demo_playlists(db, demo)
_seed_demo_subscriptions(db, demo)
db.commit() db.commit()
return seeded return seeded

View file

@ -1,8 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from sqlalchemy import delete, func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth import current_user, has_read_scope, has_write_scope, is_allowed from app.auth import current_user, has_read_scope, has_write_scope, is_allowed, purge_user
from app.db import get_db from app.db import get_db
from app.models import Invite, User from app.models import Invite, User
@ -78,6 +78,8 @@ def get_me(
"avatar_url": user.avatar_url, "avatar_url": user.avatar_url,
"role": user.role, "role": user.role,
"is_demo": user.is_demo, "is_demo": user.is_demo,
"has_google": user.google_sub is not None,
"has_password": user.password_hash is not None,
"can_read": has_read_scope(user), "can_read": has_read_scope(user),
"can_write": has_write_scope(user), "can_write": has_write_scope(user),
"pending_invites": pending_invites, "pending_invites": pending_invites,
@ -88,6 +90,7 @@ def get_me(
@router.delete("/account") @router.delete("/account")
def delete_my_account( def delete_my_account(
request: Request, request: Request,
background: BackgroundTasks,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
@ -105,13 +108,10 @@ def delete_my_account(
status_code=400, status_code=400,
detail="You're the only admin — promote another admin before deleting your account.", detail="You're the only admin — promote another admin before deleting your account.",
) )
email = user.email.lower()
user_id = user.id user_id = user.id
# Raw delete so Postgres applies the ON DELETE CASCADE / SET NULL on every dependent table. # Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
db.execute(delete(User).where(User.id == user_id)) # admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
# Erase the access-request/whitelist row for this email too (full erasure; they'd re-request). purge_user(db, user, background)
db.execute(delete(Invite).where(Invite.email == email))
db.commit()
# Drop this account from the browser session; switch to another signed-in account if any. # Drop this account from the browser session; switch to another signed-in account if any.
remaining = [a for a in (request.session.get("account_ids") or []) if a != user_id] remaining = [a for a in (request.session.get("account_ids") or []) if a != user_id]
if remaining: if remaining:

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, HttpError, type FeedFilters } from "./lib/api"; import { api, HttpError, setUnauthorizedHandler, type FeedFilters } from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n"; import { setLanguage, isSupported, type LangCode } from "./i18n";
import { import {
applyTheme, applyTheme,
@ -264,7 +264,47 @@ export default function App() {
return () => window.removeEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me }); const queryClient = useQueryClient();
// Poll the session so an account suspended/deleted by an admin is noticed even with no
// interaction (option 1): the refetch 401s → the 401 handler below drops to the login page.
// Only poll while signed in (data present) — polling on the public login page is pointless and
// its periodic refetch caused a visible flicker there.
const meQuery = useQuery({
queryKey: ["me"],
queryFn: api.me,
refetchInterval: (query) => (query.state.data ? 60_000 : false),
});
// Any request that 401s means the session ended server-side. If we were signed in (cached
// `me` exists), reload to the login page at once (option 2 — also covers any click that hits
// the API). Guarded on cached `me` so the public login page's own /api/me 401 can't loop.
useEffect(() => {
setUnauthorizedHandler(() => {
if (queryClient.getQueryData(["me"])) {
queryClient.clear();
window.location.reload();
}
});
return () => setUnauthorizedHandler(null);
}, [queryClient]);
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
// Settings → Account so the new state is visible, then strip the param for a clean URL.
useEffect(() => {
const result = new URLSearchParams(window.location.search).get("link");
if (!result) return;
if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") });
void meQuery.refetch();
localStorage.setItem("siftlode.settingsTab", "account");
setPage("settings");
} else {
const key = result === "conflict" || result === "mismatch" ? result : "error";
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
}
stripUrlParams();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after // First-login onboarding: prompt the user to connect YouTube (and resume the flow after
// each consent redirect). Derived from granted scopes + storage flags so it's stable // each consent redirect). Derived from granted scopes + storage flags so it's stable

View file

@ -1,21 +1,31 @@
import { useState } 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the // 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 // demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
// and demo data are unchanged (same tables) — only the UI relocated. // carries a badge with the count of pending requests so it's visible without switching to it.
export default function AdminUsers({ me }: { me: Me }) { export default function AdminUsers({ me }: { me: Me }) {
const { t } = useTranslation();
const [tab, setTab] = usePersistedTab("siftlode.adminUsersTab", "roles");
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
{ id: "access", label: t("users.tabs.access"), badge: me.pending_invites },
{ id: "demo", label: t("users.tabs.demo") },
];
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
return ( return (
<div className="p-4 max-w-3xl w-full mx-auto"> <div className="p-4 max-w-3xl w-full mx-auto">
<UsersRoles me={me} /> <Tabs tabs={tabs} active={active} onChange={setTab} />
<AdminInvites /> {active === "roles" && <UsersRoles me={me} />}
<AdminDemo /> {active === "access" && <AdminInvites />}
{active === "demo" && <AdminDemo />}
</div> </div>
); );
} }
@ -47,14 +57,37 @@ function UsersRoles({ me }: { me: Me }) {
const confirm = useConfirm(); const confirm = useConfirm();
const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers }); const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
const setRole = useMutation({ const setRole = useMutation({
mutationFn: ({ id, role }: { id: number; role: "user" | "admin" }) => api.setUserRole(id, role), mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) =>
onSuccess: () => { api.setUserRole(id, role),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] }); qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ level: "success", message: t("users.roles.updated") }); notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) });
}, },
// Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail. // Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail.
}); });
const suspend = useMutation({
mutationFn: ({ id, suspended }: { id: number; suspended: boolean; email: string }) =>
api.setUserSuspended(id, suspended),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({
level: "success",
message: t(vars.suspended ? "users.suspend.done" : "users.suspend.undone", {
email: vars.email,
}),
});
},
// Guards (demo / self / last admin) surface via the global error dialog.
});
const del = useMutation({
mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id),
onSuccess: (_d, vars) => {
qc.invalidateQueries({ queryKey: ["admin-users"] });
notify({ level: "success", message: t("users.delete.done", { email: vars.email }) });
},
});
const onToggle = async (u: AdminUserRow) => { const onToggle = async (u: AdminUserRow) => {
const makeAdmin = u.role !== "admin"; const makeAdmin = u.role !== "admin";
const ok = await confirm({ const ok = await confirm({
@ -65,7 +98,30 @@ function UsersRoles({ me }: { me: Me }) {
confirmLabel: makeAdmin ? t("users.roles.makeAdmin") : t("users.roles.makeUser"), confirmLabel: makeAdmin ? t("users.roles.makeAdmin") : t("users.roles.makeUser"),
danger: !makeAdmin, danger: !makeAdmin,
}); });
if (ok) setRole.mutate({ id: u.id, role: makeAdmin ? "admin" : "user" }); if (ok) setRole.mutate({ id: u.id, role: makeAdmin ? "admin" : "user", email: u.email });
};
const onSuspend = async (u: AdminUserRow) => {
const willSuspend = !u.is_suspended;
const ok = await confirm({
title: t(willSuspend ? "users.suspend.title" : "users.suspend.undoTitle"),
message: t(willSuspend ? "users.suspend.confirm" : "users.suspend.undoConfirm", {
email: u.email,
}),
confirmLabel: t(willSuspend ? "users.suspend.action" : "users.suspend.undoAction"),
danger: willSuspend,
});
if (ok) suspend.mutate({ id: u.id, suspended: willSuspend, email: u.email });
};
const onDelete = async (u: AdminUserRow) => {
const ok = await confirm({
title: t("users.delete.title"),
message: t("users.delete.confirm", { email: u.email }),
confirmLabel: t("users.delete.action"),
danger: true,
});
if (ok) del.mutate({ id: u.id, email: u.email });
}; };
const rows = q.data ?? []; const rows = q.data ?? [];
@ -90,8 +146,11 @@ function UsersRoles({ me }: { me: Me }) {
</div> </div>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>} {u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>} {u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
{!u.is_active && <Badge tone="warning">{t("users.roles.pending")}</Badge>} {!u.is_active && <Badge tone="warning">{t("users.roles.pending")}</Badge>}
{u.is_active && !u.email_verified && ( {/* A Google sign-in proves the email, so "unverified" only applies to a
password-only account that hasn't clicked its verification link. */}
{u.is_active && !u.email_verified && !u.has_google && (
<Badge tone="warning">{t("users.roles.unverified")}</Badge> <Badge tone="warning">{t("users.roles.unverified")}</Badge>
)} )}
<Tooltip <Tooltip
@ -114,6 +173,40 @@ function UsersRoles({ me }: { me: Me }) {
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")} {u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
</button> </button>
</Tooltip> </Tooltip>
<Tooltip
hint={
u.is_demo
? t("users.suspend.demoLocked")
: self
? t("users.suspend.selfLocked")
: u.is_suspended
? t("users.suspend.undoAction")
: t("users.suspend.action")
}
>
<button
onClick={() => onSuspend(u)}
disabled={u.is_demo || self || suspend.isPending}
aria-label={u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")}
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
u.is_suspended
? "border-amber-500/40 text-amber-500 hover:bg-amber-500/10"
: "border-border text-muted hover:text-amber-500"
}`}
>
{u.is_suspended ? <RotateCcw className="w-4 h-4" /> : <Ban className="w-4 h-4" />}
</button>
</Tooltip>
<Tooltip hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}>
<button
onClick={() => onDelete(u)}
disabled={u.is_demo || self || del.isPending}
aria-label={t("users.delete.action")}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-40 transition"
>
<Trash2 className="w-4 h-4" />
</button>
</Tooltip>
</div> </div>
); );
})} })}
@ -135,10 +228,10 @@ function AdminInvites() {
qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge
}; };
const approve = useMutation({ const approve = useMutation({
mutationFn: (id: number) => api.approveInvite(id), mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
onSuccess: () => { onSuccess: (_d, vars) => {
refresh(); refresh();
notify({ level: "success", message: t("settings.invites.approved") }); notify({ level: "success", message: t("settings.invites.approved", { email: vars.email }) });
}, },
onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }), onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }),
}); });
@ -171,7 +264,7 @@ function AdminInvites() {
<InviteRow <InviteRow
key={i.id} key={i.id}
inv={i} inv={i}
onApprove={() => approve.mutate(i.id)} onApprove={() => approve.mutate({ id: i.id, email: i.email })}
onDeny={() => deny.mutate(i.id)} onDeny={() => deny.mutate(i.id)}
busy={approve.isPending || deny.isPending} busy={approve.isPending || deny.isPending}
/> />

View file

@ -5,6 +5,7 @@ import { Check, RotateCcw, Save, Send } from "lucide-react";
import { api, type ConfigItem } from "../lib/api"; import { api, type ConfigItem } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the // Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and // backend — adding a key there makes it appear here automatically). Edits are drafted and
@ -35,6 +36,9 @@ export default function ConfigPanel() {
// clearing the field, since an empty secret field means "leave unchanged"). // clearing the field, since an empty secret field means "leave unchanged").
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({}); const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
const [saveState, setSaveState] = useState<SaveState>("idle"); const [saveState, setSaveState] = useState<SaveState>("idle");
// One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded.
const [tab, setTab] = usePersistedTab("siftlode.configTab");
// Re-seed the draft whenever the server data changes (initial load + after a save/reset). // Re-seed the draft whenever the server data changes (initial load + after a save/reset).
useEffect(() => { useEffect(() => {
@ -94,18 +98,26 @@ export default function ConfigPanel() {
const groupKeys = Object.keys(data.groups).sort( const groupKeys = Object.keys(data.groups).sort(
(a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99) (a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99)
); );
// Clamp the persisted tab to a real group (the registry can change between sessions). The
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
// never silently lost behind the global Save bar.
const activeGroup = groupKeys.includes(tab) ? tab : groupKeys[0];
const dirtyByGroup = new Set(dirtyKeys.map((k) => byKey[k]?.group).filter(Boolean));
const groupTabs = groupKeys.map((g) => ({
id: g,
label: `${t(`config.groups.${g}`, g)}${dirtyByGroup.has(g) ? " •" : ""}`,
}));
return ( return (
<div className="p-4 max-w-3xl w-full mx-auto pb-24"> <div className="p-4 max-w-3xl w-full mx-auto pb-24">
<p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p> <p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p>
{groupKeys.map((g) => ( <Tabs tabs={groupTabs} active={activeGroup} onChange={setTab} />
<div key={g} className="glass rounded-2xl p-4 mb-4">
<div className="text-xs uppercase tracking-wide text-muted mb-3"> {activeGroup && (
{t(`config.groups.${g}`, g)} <div className="glass rounded-2xl p-4 mb-4">
</div>
<div className="divide-y divide-border/60"> <div className="divide-y divide-border/60">
{data.groups[g].map((item) => ( {data.groups[activeGroup].map((item) => (
<Field <Field
key={item.key} key={item.key}
item={item} item={item}
@ -121,7 +133,7 @@ export default function ConfigPanel() {
))} ))}
</div> </div>
{g === "email" && ( {activeGroup === "email" && (
<div className="mt-3 pt-3 border-t border-border/60"> <div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.testEmailHint")}> <Tooltip hint={t("config.testEmailHint")}>
<button <button
@ -136,7 +148,7 @@ export default function ConfigPanel() {
</div> </div>
)} )}
</div> </div>
))} )}
{(dirty || saveState !== "idle") && ( {(dirty || saveState !== "idle") && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"> <div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]">

View file

@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react"; import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api"; import { api, type Me } from "../lib/api";
@ -353,6 +354,125 @@ function AccessRow({
); );
} }
// Sign-in methods: link a Google account to a password account (or vice-versa set a password),
// so either method can reach the same account. Google connect is a full-page OAuth round-trip
// (auth.py attaches the identity to the current session via /auth/link); the password form posts
// directly with inline errors. Demo accounts never see this (handled by the caller).
function SignInMethods({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setErr(null);
setBusy(true);
try {
await api.setPassword(next, me.has_password ? current : undefined);
setOpen(false);
setCurrent("");
setNext("");
// Refresh `me` so has_password flips and the section switches to "Change password".
await qc.invalidateQueries({ queryKey: ["me"] });
notify({ level: "success", message: t("settings.account.password.saved", { email: me.email }) });
} catch (e: any) {
setErr(e?.detail ?? t("settings.account.password.failed"));
} finally {
setBusy(false);
}
};
const inputCls =
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent";
return (
<Section title={t("settings.account.signInMethods")}>
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings.account.googleLink.title")}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{me.has_google
? t("settings.account.googleLink.connectedHint")
: t("settings.account.googleLink.connectHint")}
</p>
</div>
{me.has_google ? (
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
{t("settings.account.googleLink.connected")}
</span>
) : (
<button
onClick={() => {
window.location.href = "/auth/link";
}}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
{t("settings.account.googleLink.connect")}
</button>
)}
</div>
<div className="border-t border-border mt-1 pt-1">
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings.account.password.title")}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{me.has_password
? t("settings.account.password.setHint")
: t("settings.account.password.unsetHint")}
</p>
</div>
<button
onClick={() => {
setOpen((o) => !o);
setErr(null);
}}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
{me.has_password
? t("settings.account.password.change")
: t("settings.account.password.set")}
</button>
</div>
{open && (
<form onSubmit={submit} className="space-y-2 pt-1 pb-1">
{me.has_password && (
<input
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
placeholder={t("settings.account.password.current")}
autoComplete="current-password"
className={inputCls}
/>
)}
<input
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
placeholder={t("settings.account.password.new")}
autoComplete="new-password"
className={inputCls}
/>
{err && <p className="text-xs text-red-400">{err}</p>}
<button
type="submit"
disabled={busy || !next}
className="px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium disabled:opacity-40 transition"
>
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
</button>
</form>
)}
</div>
</Section>
);
}
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const confirm = useConfirm(); const confirm = useConfirm();
@ -366,7 +486,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
if (!ok) return; if (!ok) return;
try { try {
await api.deleteAccount(); await api.deleteAccount();
window.location.reload(); // session cleared server-side → lands on the welcome page // Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner.
window.location.href = "/?deleted=1";
} catch { } catch {
/* the global error dialog surfaces the reason (e.g. last admin) */ /* the global error dialog surfaces the reason (e.g. last admin) */
} }
@ -388,6 +509,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</div> </div>
</Section> </Section>
{!me.is_demo && <SignInMethods me={me} />}
{me.is_demo ? ( {me.is_demo ? (
<Section title={t("settings.account.youtubeAccess")}> <Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed"> <p className="text-xs text-muted leading-relaxed">

View file

@ -0,0 +1,62 @@
import { useState } from "react";
// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…).
// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were,
// per the app's "persist UI state across reload" convention.
export interface TabDef {
id: string;
label: string;
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
}
/** Persisted active-tab state. Validation is deferred to the caller (it clamps to a valid id at
* render) so this can be called before an async-loaded tab list is known, keeping hook order stable. */
export function usePersistedTab(storageKey: string, fallback = ""): [string, (id: string) => void] {
const [tab, setTabState] = useState<string>(() => localStorage.getItem(storageKey) ?? fallback);
const setTab = (id: string) => {
setTabState(id);
localStorage.setItem(storageKey, id);
};
return [tab, setTab];
}
export default function Tabs({
tabs,
active,
onChange,
}: {
tabs: TabDef[];
active: string;
onChange: (id: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1 mb-4">
{tabs.map((tb) => {
const on = tb.id === active;
return (
<button
key={tb.id}
onClick={() => onChange(tb.id)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition ${
on
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
: "text-muted hover:text-fg hover:bg-card/60"
}`}
>
{tb.label}
{tb.badge != null && tb.badge > 0 && (
<span
className={`text-[11px] leading-none px-1.5 py-0.5 rounded-full ${
on ? "bg-accent-fg/20" : "bg-accent/20 text-accent"
}`}
>
{tb.badge}
</span>
)}
</button>
);
})}
</div>
);
}

View file

@ -1,12 +1,14 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Expand,
Filter, Filter,
Inbox, Inbox,
ListVideo, ListVideo,
Lock, Lock,
PlayCircle, PlayCircle,
Tags, Tags,
X,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
@ -25,6 +27,9 @@ type Mode = "signin" | "register" | "forgot" | "reset";
// backend); errors are surfaced inline (the api calls use the quiet flag). // backend); errors are surfaced inline (the api calls use the quiet flag).
export default function Welcome() { export default function Welcome() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
// Which screenshot (if any) is open in the full-size lightbox.
const [zoomed, setZoomed] = useState<{ src: string; label: string } | null>(null);
const open = (src: string, label: string) => setZoomed({ src, label });
return ( return (
<div className="min-h-screen bg-bg text-fg overflow-y-auto"> <div className="min-h-screen bg-bg text-fg overflow-y-auto">
<header className="flex items-center justify-between px-5 sm:px-8 py-4"> <header className="flex items-center justify-between px-5 sm:px-8 py-4">
@ -48,7 +53,12 @@ export default function Welcome() {
</section> </section>
{/* App preview */} {/* App preview */}
<Preview src="/welcome/feed.png" label={t("welcome.preview.feed")} className="aspect-video" /> <Preview
src="/welcome/feed.png"
label={t("welcome.preview.feed")}
className="aspect-video"
onOpen={open}
/>
{/* Features */} {/* Features */}
<section className="mt-14"> <section className="mt-14">
@ -60,13 +70,27 @@ export default function Welcome() {
</div> </div>
</section> </section>
{/* Secondary showcase */} {/* Secondary showcase — smaller thumbnails; click to view full size. */}
<section className="grid sm:grid-cols-2 gap-4 mt-12"> <section className="mt-12 flex flex-wrap justify-center gap-4">
<Preview src="/welcome/channels.png" label={t("welcome.preview.channels")} className="aspect-[4/3]" /> <Preview
<Preview src="/welcome/playlists.png" label={t("welcome.preview.playlists")} className="aspect-[4/3]" /> src="/welcome/channels.png"
label={t("welcome.preview.channels")}
className="w-full sm:w-72 aspect-[4/3]"
onOpen={open}
/>
<Preview
src="/welcome/playlists.png"
label={t("welcome.preview.playlists")}
className="w-full sm:w-72 aspect-[4/3]"
onOpen={open}
/>
</section> </section>
</main> </main>
{zoomed && (
<Lightbox src={zoomed.src} label={zoomed.label} onClose={() => setZoomed(null)} />
)}
<footer className="border-t border-border py-6 text-center text-xs text-muted flex flex-col sm:flex-row gap-3 justify-center items-center"> <footer className="border-t border-border py-6 text-center text-xs text-muted flex flex-col sm:flex-row gap-3 justify-center items-center">
<span> <span>
Sift<span className="text-accent">lode</span> Sift<span className="text-accent">lode</span>
@ -107,15 +131,35 @@ function FeatureCard({ icon: Icon, k }: { icon: LucideIcon; k: string }) {
// Image with a graceful fallback: if the file isn't present yet, show a tasteful placeholder // Image with a graceful fallback: if the file isn't present yet, show a tasteful placeholder
// frame instead of a broken image (the demo screenshots drop into frontend/public/welcome/). // frame instead of a broken image (the demo screenshots drop into frontend/public/welcome/).
function Preview({ src, label, className }: { src: string; label: string; className?: string }) { // When loaded it's a button that opens the full-size lightbox (onOpen).
function Preview({
src,
label,
className,
onOpen,
}: {
src: string;
label: string;
className?: string;
onOpen?: (src: string, label: string) => void;
}) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
if (failed) {
return ( return (
<div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}> <div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}>
{failed ? (
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-accent/10 to-card text-muted text-sm p-6 text-center"> <div className="w-full h-full grid place-items-center bg-gradient-to-br from-accent/10 to-card text-muted text-sm p-6 text-center">
{label} {label}
</div> </div>
) : ( </div>
);
}
return (
<button
type="button"
onClick={() => onOpen?.(src, label)}
aria-label={label}
className={`group relative glass-card rounded-2xl overflow-hidden block cursor-zoom-in ${className ?? ""}`}
>
<img <img
src={src} src={src}
alt={label} alt={label}
@ -123,17 +167,76 @@ function Preview({ src, label, className }: { src: string; label: string; classN
onError={() => setFailed(true)} onError={() => setFailed(true)}
className="w-full h-full object-cover object-top" className="w-full h-full object-cover object-top"
/> />
)} <span className="absolute inset-0 grid place-items-center bg-black/0 opacity-0 transition group-hover:bg-black/30 group-hover:opacity-100">
<Expand className="w-6 h-6 text-white drop-shadow-lg" />
</span>
</button>
);
}
// Full-size image viewer: a dimmed, blurred backdrop with the screenshot fit to the viewport
// (aspect ratio preserved via object-contain). Closes on backdrop click, the ✕, or Escape.
function Lightbox({ src, label, onClose }: { src: string; label: string; onClose: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden"; // no background scroll while open
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
return (
<div
className="fixed inset-0 z-[60] grid place-items-center bg-black/80 backdrop-blur-sm p-4 sm:p-8"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={label}
>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="absolute top-4 right-4 p-2 rounded-full glass-card glass-hover text-fg"
>
<X className="w-5 h-5" />
</button>
<figure
className="flex max-h-full max-w-full flex-col items-center gap-3"
onClick={(e) => e.stopPropagation()}
>
<img
src={src}
alt={label}
className="max-h-[82vh] max-w-[92vw] w-auto h-auto rounded-xl border border-border object-contain shadow-2xl"
/>
<figcaption className="text-sm text-muted">{label}</figcaption>
</figure>
</div> </div>
); );
} }
function AuthCard() { function AuthCard() {
const { t } = useTranslation(); const { t } = useTranslation();
const params = new URLSearchParams(window.location.search); // Snapshot the redirect params ONCE so we can clean them from the address bar below without
// dismissing the banners/reset-mode they drive (those read this stable snapshot, not the URL).
const [params] = useState(() => new URLSearchParams(window.location.search));
const resetToken = params.get("reset"); const resetToken = params.get("reset");
const bounced = params.get("access"); // Google denied → "requested" | "denied" const bounced = params.get("access"); // Google denied → "requested" | "denied"
const verify = params.get("verify"); // "ok" | "invalid" const verify = params.get("verify"); // "ok" | "invalid"
const login = params.get("login"); // Google login blocked → "suspended"
const deleted = params.get("deleted"); // self-service account deletion just completed
// Strip the query string so the address bar stays clean (http://host/) after we've captured it.
useEffect(() => {
if (window.location.search) {
window.history.replaceState(null, "", window.location.pathname);
}
}, []);
const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin"); const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@ -230,6 +333,8 @@ function AuthCard() {
{verify === "invalid" && <Banner tone="warn">{t("welcome.auth.verifyInvalid")}</Banner>} {verify === "invalid" && <Banner tone="warn">{t("welcome.auth.verifyInvalid")}</Banner>}
{bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>} {bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>}
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>} {bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
{done ? ( {done ? (
<div className="mt-3"> <div className="mt-3">

View file

@ -61,7 +61,32 @@
"deleteTitle": "Konto löschen?", "deleteTitle": "Konto löschen?",
"deleteConfirm": "Dies löscht dein Konto und alle deine Daten (Abos, Tags, angesehen/gespeichert/ausgeblendet, Playlists, Einstellungen) dauerhaft. Es kann nicht rückgängig gemacht werden.", "deleteConfirm": "Dies löscht dein Konto und alle deine Daten (Abos, Tags, angesehen/gespeichert/ausgeblendet, Playlists, Einstellungen) dauerhaft. Es kann nicht rückgängig gemacht werden.",
"deleteConfirmButton": "Alles löschen", "deleteConfirmButton": "Alles löschen",
"demoNotice": "Dies ist das gemeinsame Demo-Konto. Es hat keine YouTube-Verbindung — du kannst die gesamte gemeinsame Bibliothek durchsuchen, filtern und sortieren, Playlists erstellen und Dinge ausprobieren, aber nichts berührt ein echtes YouTube-Konto." "demoNotice": "Dies ist das gemeinsame Demo-Konto. Es hat keine YouTube-Verbindung — du kannst die gesamte gemeinsame Bibliothek durchsuchen, filtern und sortieren, Playlists erstellen und Dinge ausprobieren, aber nichts berührt ein echtes YouTube-Konto.",
"signInMethods": "Anmeldemethoden",
"googleLink": {
"title": "Google-Konto",
"connected": "Verbunden",
"connectedHint": "Dein Google-Konto ist verknüpft. Du kannst dich mit Google anmelden und unten den YouTube-Zugriff erteilen.",
"connectHint": "Verknüpfe ein Google-Konto, um dich mit Google anzumelden und den YouTube-Zugriff für deinen Feed zu aktivieren.",
"connect": "Google verknüpfen",
"linked": "Google-Konto verknüpft.",
"conflict": "Dieses Google-Konto ist bereits mit einem anderen Siftlode-Konto verknüpft.",
"mismatch": "Das ist ein anderes Google-Konto als das bereits hier verknüpfte. Melde dich mit dem verknüpften an.",
"error": "Das Google-Konto konnte nicht verknüpft werden. Bitte versuche es erneut."
},
"password": {
"title": "Passwort",
"setHint": "Ein Passwort ist gesetzt. Du kannst dich mit E-Mail und Passwort anmelden.",
"unsetHint": "Noch kein Passwort — lege eines fest, um dich mit deiner E-Mail anzumelden (zusätzlich zu oder anstelle von Google).",
"set": "Passwort festlegen",
"change": "Passwort ändern",
"current": "Aktuelles Passwort",
"new": "Neues Passwort (min. 10 Zeichen)",
"save": "Passwort speichern",
"saving": "Speichern…",
"saved": "Passwort für {{email}} aktualisiert.",
"failed": "Das Passwort konnte nicht aktualisiert werden."
}
}, },
"demo": { "demo": {
"title": "Demo-Zugang", "title": "Demo-Zugang",
@ -87,7 +112,7 @@
"addPlaceholder": "E-Mail direkt hinzufügen…", "addPlaceholder": "E-Mail direkt hinzufügen…",
"add": "Hinzufügen", "add": "Hinzufügen",
"decided": "{{count}} entschieden", "decided": "{{count}} entschieden",
"approved": "Genehmigt — sie können sich jetzt anmelden", "approved": "{{email}} genehmigt — sie können sich jetzt anmelden",
"approveFailed": "Genehmigung fehlgeschlagen", "approveFailed": "Genehmigung fehlgeschlagen",
"denyFailed": "Ablehnung fehlgeschlagen", "denyFailed": "Ablehnung fehlgeschlagen",
"addedToWhitelist": "Zur Whitelist hinzugefügt", "addedToWhitelist": "Zur Whitelist hinzugefügt",

View file

@ -1,4 +1,9 @@
{ {
"tabs": {
"roles": "Benutzer & Rollen",
"access": "Zugriffsanfragen",
"demo": "Demo"
},
"roles": { "roles": {
"title": "Benutzer & Rollen", "title": "Benutzer & Rollen",
"intro": "Alle, die sich anmelden können. Mache einen Benutzer zum Admin oder wieder zum normalen Benutzer.", "intro": "Alle, die sich anmelden können. Mache einen Benutzer zum Admin oder wieder zum normalen Benutzer.",
@ -6,16 +11,36 @@
"you": "du", "you": "du",
"admin": "Admin", "admin": "Admin",
"demo": "Demo", "demo": "Demo",
"suspended": "Gesperrt",
"pending": "Wartet auf Freigabe", "pending": "Wartet auf Freigabe",
"unverified": "E-Mail nicht bestätigt", "unverified": "E-Mail nicht bestätigt",
"demoLocked": "Die Rolle des Demo-Kontos kann nicht geändert werden.", "demoLocked": "Die Rolle des Demo-Kontos kann nicht geändert werden.",
"selfLocked": "Du kannst deine eigene Rolle nicht ändern.", "selfLocked": "Du kannst deine eigene Rolle nicht ändern.",
"makeAdmin": "Zum Admin machen", "makeAdmin": "Zum Admin machen",
"makeUser": "Zum Benutzer machen", "makeUser": "Zum Benutzer machen",
"updated": "Rolle aktualisiert", "updated": "Rolle aktualisiert für {{email}}",
"promoteTitle": "Zum Admin machen?", "promoteTitle": "Zum Admin machen?",
"demoteTitle": "Admin entfernen?", "demoteTitle": "Admin entfernen?",
"promoteConfirm": "{{email}} vollen Admin-Zugriff geben (Einstellungen, Planer, Benutzerverwaltung)?", "promoteConfirm": "{{email}} vollen Admin-Zugriff geben (Einstellungen, Planer, Benutzerverwaltung)?",
"demoteConfirm": "{{email}} den Admin-Zugriff entziehen? Wird zum normalen Benutzer." "demoteConfirm": "{{email}} den Admin-Zugriff entziehen? Wird zum normalen Benutzer."
},
"suspend": {
"action": "Sperren",
"undoAction": "Entsperren",
"title": "Konto sperren?",
"undoTitle": "Konto entsperren?",
"confirm": "{{email}} sperren? Eine Anmeldung (per Passwort oder Google) ist erst nach dem Entsperren wieder möglich, und jede aktive Sitzung endet.",
"undoConfirm": "{{email}} entsperren? Eine Anmeldung ist dann wieder möglich.",
"done": "{{email}} gesperrt",
"undone": "{{email}} entsperrt",
"demoLocked": "Das Demo-Konto kann nicht gesperrt werden.",
"selfLocked": "Du kannst dein eigenes Konto nicht sperren."
},
"delete": {
"action": "Löschen",
"locked": "Dieses Konto kann hier nicht gelöscht werden.",
"title": "Konto löschen?",
"confirm": "{{email}} und alle zugehörigen Daten endgültig löschen — Abos, Tags, Wiedergabeverlauf, Playlists und Einstellungen? Das kann nicht rückgängig gemacht werden.",
"done": "{{email}} gelöscht"
} }
} }

View file

@ -68,6 +68,8 @@
"verifyOk": "E-Mail bestätigt. Sobald ein Admin dein Konto freigibt, kannst du dich anmelden.", "verifyOk": "E-Mail bestätigt. Sobald ein Admin dein Konto freigibt, kannst du dich anmelden.",
"verifyInvalid": "Dieser Bestätigungslink ist ungültig oder abgelaufen.", "verifyInvalid": "Dieser Bestätigungslink ist ungültig oder abgelaufen.",
"accessRequested": "Danke — wir haben deine Anfrage erfasst. Ein Admin wird sie prüfen.", "accessRequested": "Danke — wir haben deine Anfrage erfasst. Ein Admin wird sie prüfen.",
"accessDenied": "Dieses Konto ist für diese Instanz noch nicht freigegeben — registriere dich unten oder bitte den Admin um Zugang." "accessDenied": "Dieses Konto ist für diese Instanz noch nicht freigegeben — registriere dich unten oder bitte den Admin um Zugang.",
"suspended": "Dieses Konto wurde gesperrt und kann sich nicht anmelden. Falls das ein Irrtum ist, wende dich an den Betreiber (Details in deiner E-Mail).",
"deleted": "Dein Konto und alle Daten wurden endgültig gelöscht. Eine Bestätigung haben wir dir per E-Mail geschickt."
} }
} }

View file

@ -61,7 +61,32 @@
"deleteTitle": "Delete your account?", "deleteTitle": "Delete your account?",
"deleteConfirm": "This permanently erases your account and all your data (subscriptions, tags, watch/saved/hidden state, playlists, settings). It can't be undone.", "deleteConfirm": "This permanently erases your account and all your data (subscriptions, tags, watch/saved/hidden state, playlists, settings). It can't be undone.",
"deleteConfirmButton": "Delete everything", "deleteConfirmButton": "Delete everything",
"demoNotice": "This is the shared demo account. It has no YouTube connection — you can browse the whole shared library, filter and sort it, build playlists and try things out, but nothing touches a real YouTube account." "demoNotice": "This is the shared demo account. It has no YouTube connection — you can browse the whole shared library, filter and sort it, build playlists and try things out, but nothing touches a real YouTube account.",
"signInMethods": "Sign-in methods",
"googleLink": {
"title": "Google account",
"connected": "Connected",
"connectedHint": "Your Google account is linked. You can sign in with Google and grant YouTube access below.",
"connectHint": "Link a Google account to sign in with Google and to enable YouTube access for your feed.",
"connect": "Connect Google",
"linked": "Google account linked.",
"conflict": "That Google account is already linked to a different Siftlode account.",
"mismatch": "That's a different Google account than the one already linked here. Sign in with the linked one.",
"error": "Couldn't link the Google account. Please try again."
},
"password": {
"title": "Password",
"setHint": "A password is set. You can sign in with your email and password.",
"unsetHint": "No password yet — set one to sign in with your email instead of (or as well as) Google.",
"set": "Set password",
"change": "Change password",
"current": "Current password",
"new": "New password (min. 10 characters)",
"save": "Save password",
"saving": "Saving…",
"saved": "Password updated for {{email}}.",
"failed": "Couldn't update the password."
}
}, },
"demo": { "demo": {
"title": "Demo access", "title": "Demo access",
@ -87,7 +112,7 @@
"addPlaceholder": "Add an email directly…", "addPlaceholder": "Add an email directly…",
"add": "Add", "add": "Add",
"decided": "{{count}} decided", "decided": "{{count}} decided",
"approved": "Approved — they can sign in now", "approved": "Approved {{email}} — they can sign in now",
"approveFailed": "Approve failed", "approveFailed": "Approve failed",
"denyFailed": "Deny failed", "denyFailed": "Deny failed",
"addedToWhitelist": "Added to the whitelist", "addedToWhitelist": "Added to the whitelist",

View file

@ -1,4 +1,9 @@
{ {
"tabs": {
"roles": "Users & roles",
"access": "Access requests",
"demo": "Demo"
},
"roles": { "roles": {
"title": "Users & roles", "title": "Users & roles",
"intro": "Everyone who can sign in. Promote a user to admin, or back to a regular user.", "intro": "Everyone who can sign in. Promote a user to admin, or back to a regular user.",
@ -6,16 +11,36 @@
"you": "you", "you": "you",
"admin": "Admin", "admin": "Admin",
"demo": "Demo", "demo": "Demo",
"suspended": "Suspended",
"pending": "Pending approval", "pending": "Pending approval",
"unverified": "Unverified email", "unverified": "Unverified email",
"demoLocked": "The demo account's role can't be changed.", "demoLocked": "The demo account's role can't be changed.",
"selfLocked": "You can't change your own role.", "selfLocked": "You can't change your own role.",
"makeAdmin": "Make admin", "makeAdmin": "Make admin",
"makeUser": "Make user", "makeUser": "Make user",
"updated": "Role updated", "updated": "Role updated for {{email}}",
"promoteTitle": "Make admin?", "promoteTitle": "Make admin?",
"demoteTitle": "Remove admin?", "demoteTitle": "Remove admin?",
"promoteConfirm": "Give {{email}} full admin access (settings, scheduler, user management)?", "promoteConfirm": "Give {{email}} full admin access (settings, scheduler, user management)?",
"demoteConfirm": "Remove admin access from {{email}}? They'll become a regular user." "demoteConfirm": "Remove admin access from {{email}}? They'll become a regular user."
},
"suspend": {
"action": "Suspend",
"undoAction": "Unsuspend",
"title": "Suspend account?",
"undoTitle": "Unsuspend account?",
"confirm": "Suspend {{email}}? They won't be able to sign in (by password or Google) until you unsuspend them, and any active session ends.",
"undoConfirm": "Unsuspend {{email}}? They'll be able to sign in again.",
"done": "{{email}} suspended",
"undone": "{{email}} unsuspended",
"demoLocked": "The demo account can't be suspended.",
"selfLocked": "You can't suspend your own account."
},
"delete": {
"action": "Delete",
"locked": "This account can't be deleted here.",
"title": "Delete account?",
"confirm": "Permanently delete {{email}} and all their data — subscriptions, tags, watch history, playlists and settings? This can't be undone.",
"done": "{{email}} deleted"
} }
} }

View file

@ -68,6 +68,8 @@
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.", "verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
"verifyInvalid": "That verification link is invalid or has expired.", "verifyInvalid": "That verification link is invalid or has expired.",
"accessRequested": "Thanks — we recorded your request. An admin will review it.", "accessRequested": "Thanks — we recorded your request. An admin will review it.",
"accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access." "accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access.",
"suspended": "This account has been suspended and can't sign in. If you think this is a mistake, contact the operator (check your email for details).",
"deleted": "Your account and all its data were permanently deleted. We've emailed you a confirmation."
} }
} }

View file

@ -61,7 +61,32 @@
"deleteTitle": "Törlöd a fiókodat?", "deleteTitle": "Törlöd a fiókodat?",
"deleteConfirm": "Ez véglegesen törli a fiókodat és minden adatodat (feliratkozások, címkék, megnézett/mentett/elrejtett állapot, lejátszási listák, beállítások). Nem vonható vissza.", "deleteConfirm": "Ez véglegesen törli a fiókodat és minden adatodat (feliratkozások, címkék, megnézett/mentett/elrejtett állapot, lejátszási listák, beállítások). Nem vonható vissza.",
"deleteConfirmButton": "Minden törlése", "deleteConfirmButton": "Minden törlése",
"demoNotice": "Ez a közös demo fiók. Nincs YouTube-kapcsolata — böngészheted a teljes közös könyvtárat, szűrheted és rendezheted, listákat építhetsz és kipróbálhatsz dolgokat, de semmi nem nyúl valódi YouTube-fiókhoz." "demoNotice": "Ez a közös demo fiók. Nincs YouTube-kapcsolata — böngészheted a teljes közös könyvtárat, szűrheted és rendezheted, listákat építhetsz és kipróbálhatsz dolgokat, de semmi nem nyúl valódi YouTube-fiókhoz.",
"signInMethods": "Bejelentkezési módok",
"googleLink": {
"title": "Google-fiók",
"connected": "Csatlakoztatva",
"connectedHint": "A Google-fiókod össze van kapcsolva. Bejelentkezhetsz Google-lel, és alább engedélyezheted a YouTube-hozzáférést.",
"connectHint": "Kapcsolj össze egy Google-fiókot, hogy Google-lel jelentkezhess be, és engedélyezhesd a YouTube-hozzáférést a feededhez.",
"connect": "Google összekapcsolása",
"linked": "Google-fiók összekapcsolva.",
"conflict": "Ez a Google-fiók már egy másik Siftlode-fiókhoz van kapcsolva.",
"mismatch": "Ez nem az a Google-fiók, amely ide már össze van kapcsolva. Az összekapcsolttal jelentkezz be.",
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra."
},
"password": {
"title": "Jelszó",
"setHint": "Van beállított jelszó. Bejelentkezhetsz e-mail-címmel és jelszóval.",
"unsetHint": "Még nincs jelszó — állíts be egyet, hogy e-mail-címmel is bejelentkezhess (a Google mellett vagy helyett).",
"set": "Jelszó beállítása",
"change": "Jelszó módosítása",
"current": "Jelenlegi jelszó",
"new": "Új jelszó (min. 10 karakter)",
"save": "Jelszó mentése",
"saving": "Mentés…",
"saved": "Jelszó frissítve ehhez: {{email}}.",
"failed": "Nem sikerült frissíteni a jelszót."
}
}, },
"demo": { "demo": {
"title": "Demo hozzáférés", "title": "Demo hozzáférés",
@ -87,7 +112,7 @@
"addPlaceholder": "E-mail közvetlen hozzáadása…", "addPlaceholder": "E-mail közvetlen hozzáadása…",
"add": "Hozzáadás", "add": "Hozzáadás",
"decided": "{{count}} eldöntve", "decided": "{{count}} eldöntve",
"approved": "Jóváhagyva — most már be tud jelentkezni", "approved": "Jóváhagyva: {{email}} — most már be tud jelentkezni",
"approveFailed": "A jóváhagyás sikertelen", "approveFailed": "A jóváhagyás sikertelen",
"denyFailed": "Az elutasítás sikertelen", "denyFailed": "Az elutasítás sikertelen",
"addedToWhitelist": "Hozzáadva a fehérlistához", "addedToWhitelist": "Hozzáadva a fehérlistához",

View file

@ -1,4 +1,9 @@
{ {
"tabs": {
"roles": "Felhasználók és szerepek",
"access": "Hozzáférési kérések",
"demo": "Demo"
},
"roles": { "roles": {
"title": "Felhasználók és szerepek", "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á.", "intro": "Mindenki, aki be tud lépni. Léptess egy felhasználót adminná, vagy vissza sima felhasználóvá.",
@ -6,16 +11,36 @@
"you": "te", "you": "te",
"admin": "Admin", "admin": "Admin",
"demo": "Demo", "demo": "Demo",
"suspended": "Felfüggesztve",
"pending": "Jóváhagyásra vár", "pending": "Jóváhagyásra vár",
"unverified": "Nem igazolt e-mail", "unverified": "Nem igazolt e-mail",
"demoLocked": "A demo fiók szerepe nem módosítható.", "demoLocked": "A demo fiók szerepe nem módosítható.",
"selfLocked": "A saját szerepedet nem módosíthatod.", "selfLocked": "A saját szerepedet nem módosíthatod.",
"makeAdmin": "Adminná tesz", "makeAdmin": "Adminná tesz",
"makeUser": "Felhasználóvá tesz", "makeUser": "Felhasználóvá tesz",
"updated": "Szerep frissítve", "updated": "Szerep frissítve: {{email}}",
"promoteTitle": "Adminná teszed?", "promoteTitle": "Adminná teszed?",
"demoteTitle": "Elveszed az admin jogot?", "demoteTitle": "Elveszed az admin jogot?",
"promoteConfirm": "Teljes admin hozzáférést adsz neki: {{email}} (beállítások, ütemező, felhasználókezelés)?", "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." "demoteConfirm": "Elveszed az admin jogot tőle: {{email}}? Sima felhasználó lesz."
},
"suspend": {
"action": "Felfüggeszt",
"undoAction": "Feloldás",
"title": "Felfüggeszted a fiókot?",
"undoTitle": "Feloldod a felfüggesztést?",
"confirm": "Felfüggeszted: {{email}}? Nem fog tudni belépni (sem jelszóval, sem Google-lel), amíg fel nem oldod, és az aktív munkamenete megszűnik.",
"undoConfirm": "Feloldod a felfüggesztést: {{email}}? Újra be tud majd lépni.",
"done": "{{email}} felfüggesztve",
"undone": "{{email}} felfüggesztése feloldva",
"demoLocked": "A demo fiók nem függeszthető fel.",
"selfLocked": "A saját fiókodat nem függesztheted fel."
},
"delete": {
"action": "Törlés",
"locked": "Ez a fiók itt nem törölhető.",
"title": "Törlöd a fiókot?",
"confirm": "Véglegesen törlöd: {{email}} és minden adatát — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások? Ez nem visszavonható.",
"done": "{{email}} törölve"
} }
} }

View file

@ -68,6 +68,8 @@
"verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.", "verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.",
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.", "verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.",
"accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.", "accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.",
"accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól." "accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól.",
"suspended": "Ez a fiók fel van függesztve, nem tud belépni. Ha tévedésnek gondolod, keresd az üzemeltetőt (a részletek az e-mailedben).",
"deleted": "A fiókod és minden adata véglegesen törölve lett. A megerősítést e-mailben is elküldtük."
} }
} }

View file

@ -9,6 +9,8 @@ export interface Me {
avatar_url: string | null; avatar_url: string | null;
role: string; role: string;
is_demo: boolean; is_demo: boolean;
has_google: boolean;
has_password: boolean;
can_read: boolean; can_read: boolean;
can_write: boolean; can_write: boolean;
pending_invites: number; pending_invites: number;
@ -199,6 +201,15 @@ interface ReqConfig {
quiet?: boolean; quiet?: boolean;
} }
// Set by the app shell. Invoked whenever any request returns 401 so a session that ended
// server-side (account suspended/deleted while the tab was open) can drop the user to the login
// page. The handler itself guards against firing when we were never signed in (public pages
// legitimately 401 on /api/me), so it's safe to call for every 401.
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null): void {
onUnauthorized = fn;
}
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> { async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
const method = opts.method ?? "GET"; const method = opts.method ?? "GET";
const canRetry = cfg.idempotent ?? method === "GET"; const canRetry = cfg.idempotent ?? method === "GET";
@ -251,6 +262,10 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
// 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow. // 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
if (RETRIABLE_GATEWAY.has(r.status)) { if (RETRIABLE_GATEWAY.has(r.status)) {
markConnectivityLost(); markConnectivityLost();
} else if (r.status === 401) {
// Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the
// app shell decide what to do (drop to the login page if we were signed in). Not a modal.
onUnauthorized?.();
} else if (cfg.quiet) { } else if (cfg.quiet) {
/* caller handles the error inline — no global modal */ /* caller handles the error inline — no global modal */
} else if (r.status >= 500) { } else if (r.status >= 500) {
@ -470,6 +485,7 @@ export interface AdminUserRow {
display_name: string | null; display_name: string | null;
role: string; // "user" | "admin" role: string; // "user" | "admin"
is_active: boolean; is_active: boolean;
is_suspended: boolean;
email_verified: boolean; email_verified: boolean;
is_demo: boolean; is_demo: boolean;
has_password: boolean; has_password: boolean;
@ -599,6 +615,10 @@ export const api = {
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"), adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> => setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }), req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
setUserSuspended: (id: number, suspended: boolean): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/suspend`, { method: "PATCH", body: JSON.stringify({ suspended }) }),
adminDeleteUser: (id: number): Promise<{ deleted: number }> =>
req(`/api/admin/users/${id}`, { method: "DELETE" }),
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}`, {
@ -624,6 +644,13 @@ export const api = {
req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }), req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }),
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> => confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }), req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }),
// Set or change the signed-in account's password (errors shown inline via quiet).
setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
req(
"/auth/set-password",
{ method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
{ quiet: true }
),
// --- onboarding / admin --- // --- onboarding / admin ---
requestAccess: (email: string): Promise<{ status: string }> => requestAccess: (email: string): Promise<{ status: string }> =>
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }), req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),