Merge feature/auth-5e-google-link — complete epic 5 (auth/account) + admin user management
This commit is contained in:
commit
5d3ef4f337
26 changed files with 1148 additions and 97 deletions
30
backend/alembic/versions/0023_user_suspension.py
Normal file
30
backend/alembic/versions/0023_user_suspension.py
Normal 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")
|
||||||
31
backend/alembic/versions/0024_google_email_verified.py
Normal file
31
backend/alembic/versions/0024_google_email_verified.py
Normal 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
|
||||||
|
|
@ -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 —
|
||||||
db.add(user)
|
# 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)
|
||||||
|
# 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
|
||||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
if email_ok:
|
||||||
background.add_task(email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}")
|
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
||||||
|
background.add_task(
|
||||||
|
email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}"
|
||||||
|
)
|
||||||
if settings.admin_email_set:
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
BIN
frontend/public/welcome/channels.png
Normal file
BIN
frontend/public/welcome/channels.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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))]">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
62
frontend/src/components/Tabs.tsx
Normal file
62
frontend/src/components/Tabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,33 +131,112 @@ 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);
|
||||||
return (
|
if (failed) {
|
||||||
<div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}>
|
return (
|
||||||
{failed ? (
|
<div className={`glass-card rounded-2xl overflow-hidden ${className ?? ""}`}>
|
||||||
<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
|
||||||
|
src={src}
|
||||||
|
alt={label}
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
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
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={label}
|
alt={label}
|
||||||
loading="lazy"
|
className="max-h-[82vh] max-w-[92vw] w-auto h-auto rounded-xl border border-border object-contain shadow-2xl"
|
||||||
onError={() => setFailed(true)}
|
|
||||||
className="w-full h-full object-cover object-top"
|
|
||||||
/>
|
/>
|
||||||
)}
|
<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">
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }) }),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue