diff --git a/backend/alembic/versions/0023_user_suspension.py b/backend/alembic/versions/0023_user_suspension.py new file mode 100644 index 0000000..51f20fa --- /dev/null +++ b/backend/alembic/versions/0023_user_suspension.py @@ -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") diff --git a/backend/alembic/versions/0024_google_email_verified.py b/backend/alembic/versions/0024_google_email_verified.py new file mode 100644 index 0000000..7b7af53 --- /dev/null +++ b/backend/alembic/versions/0024_google_email_verified.py @@ -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 diff --git a/backend/app/auth.py b/backend/app/auth.py index 4139b4f..e3fc79a 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -4,10 +4,11 @@ import re import secrets from datetime import datetime, timedelta, timezone +import httpx from authlib.integrations.starlette_client import OAuth, OAuthError from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.orm import Session from app import email as email_mod @@ -16,7 +17,7 @@ from app.config import settings from app.db import get_db from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User 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. PASSWORD_MIN_LEN = 10 @@ -25,6 +26,22 @@ RESET_TTL = timedelta(hours=1) _register_limiter = RateLimiter(max_events=5, window_seconds=300) _login_limiter = RateLimiter(max_events=10, window_seconds=300) _reset_limiter = RateLimiter(max_events=5, window_seconds=300) +# 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]+$") @@ -190,7 +207,16 @@ async def callback( if not userinfo or not userinfo.get("sub"): raise HTTPException(status_code=400, detail="No user info returned by Google") + sub = userinfo["sub"] 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): log.warning("Login denied (not approved): %s", email or "") # 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=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: - user = User(google_sub=userinfo["sub"], email=email) - db.add(user) + # No account carries this Google identity yet. If one already exists for this email — + # e.g. an email+password registration — adopt the Google identity onto it instead of + # inserting a duplicate (which would collide on the unique email and 500). Google has + # verified the address, so this safely unifies password + Google sign-in on one account. + existing = db.query(User).filter(User.email == email).one_or_none() + if existing is not None: + if existing.google_sub is not None and existing.google_sub != sub: + # A different Google identity is already bound to this email — refuse rather than + # hijack it. (Near-impossible with real Google accounts: email↔sub is stable.) + log.warning("Google login email collision (different sub): %s", email) + return RedirectResponse(url="/?access=denied") + user = existing + user.google_sub = sub + # Google verified the email and is_allowed granted access, so finish activating a + # previously pending password registration (otherwise current_user would bounce it). + user.email_verified = True + user.is_active = True + else: + user = User(google_sub=sub, email=email) + db.add(user) + # 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.display_name = userinfo.get("name") 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 # picked one yet); unsupported locales fall back to English. prefs = dict(user.preferences or {}) @@ -223,19 +284,7 @@ async def callback( user.preferences = prefs db.flush() - tok = user.token or OAuthToken(user=user) - # 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) + _store_token(db, user, token) db.commit() # Multi-session: remember every account that has authenticated in this browser so the @@ -249,6 +298,94 @@ async def callback( 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") async def logout(request: Request): """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: """Public origin of the deployed app (OAUTH_REDIRECT_URL is .../auth/callback).""" - u = settings.oauth_redirect_url - return u.split("/auth/")[0] if "/auth/" in u else u.rstrip("/") + return settings.app_base 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() 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( email=email, password_hash=hash_password(password), is_active=False, - email_verified=False, + email_verified=not email_ok, ) db.add(user) db.flush() upsert_pending_invite(db, email) # admin-approval gate - raw = _issue_token(db, user, "verify", VERIFY_TTL) - background.add_task(email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}") + if email_ok: + raw = _issue_token(db, user, "verify", VERIFY_TTL) + background.add_task( + email_mod.send_verify_email, email, f"{_app_base()}/auth/verify?token={raw}" + ) if settings.admin_email_set: background.add_task( email_mod.send_admin_new_request, sorted(settings.admin_email_set), email @@ -413,11 +556,14 @@ def verify_email(token: str, db: Session = Depends(get_db)): @router.post("/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: """Email+password sign-in. Wrong email/password give a single uniform 401 (no enumeration). Once the password is proven correct, the owner gets a specific reason if their account is - still pending — that's not an enumeration leak (they already hold the password).""" + still pending or suspended — that's not an enumeration leak (they already hold the password).""" if not _login_limiter.allow(_client_ip(request)): raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.") email = (payload.get("email") or "").strip().lower() @@ -425,6 +571,13 @@ def password_login( user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() if user is None or user.is_demo or not verify_password(password, user.password_hash): raise HTTPException(status_code=401, detail="Invalid email or password.") + if 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: raise HTTPException(status_code=403, detail="Please verify your email first (check your inbox).") if not user.is_active: @@ -484,7 +637,8 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User: if not user_id: raise HTTPException(status_code=401, detail="Not authenticated") 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() raise HTTPException(status_code=401, detail="Not authenticated") # 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 +@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") async def upgrade( 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. if user.is_demo: 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 return await oauth.google.authorize_redirect( request, diff --git a/backend/app/config.py b/backend/app/config.py index da51958..7d454a6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -119,6 +119,14 @@ class Settings(BaseSettings): def admin_email_set(self) -> set[str]: 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 def session_https_only(self) -> bool: """Mark the session cookie Secure when we're served over HTTPS. We treat an diff --git a/backend/app/email.py b/backend/app/email.py index d1af768..8e1e653 100644 --- a/backend/app/email.py +++ b/backend/app/email.py @@ -74,8 +74,8 @@ def send_access_approved(email: str) -> bool: body = ( "Hi,\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" - "subscriptions feed will be ready.\n\n" + "Open Siftlode and sign in, and your YouTube 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" "— Siftlode" + (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) +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: body = ( f"{requester} just requested access to Siftlode.\n\n" diff --git a/backend/app/main.py b/backend/app/main.py index 77c2a5e..220b1a5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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. if full_path.startswith(("api/", "auth/", "healthz", "assets/")): 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") diff --git a/backend/app/models.py b/backend/app/models.py index 4f12e9f..388ba36 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -37,6 +37,12 @@ class User(Base): # Admin-approved & enabled. A pending password registration starts inactive; admin # approval activates it. A deactivated account can't log in. is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") + # 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)) avatar_url: Mapped[str | None] = mapped_column(String(1024)) role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 176c1b9..1f7d3d8 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -8,13 +8,14 @@ from sqlalchemy import delete, func, select from sqlalchemy.orm import Session 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.models import ( DemoWhitelist, Invite, Playlist, PlaylistItem, + Subscription, User, Video, VideoState, @@ -132,6 +133,7 @@ def _serialize_user(u: User) -> dict: "display_name": u.display_name, "role": u.role, "is_active": u.is_active, + "is_suspended": u.is_suspended, "email_verified": u.email_verified, "is_demo": u.is_demo, "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( user_id: int, payload: dict, + background: BackgroundTasks, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Promote/demote a user. Guards: can't change your own role, can't touch the demo - account, and can't remove the last remaining admin.""" + account, and can't remove the last remaining admin. Emails the user when it actually changes.""" role = payload.get("role") if role not in ("user", "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")) if (admin_count or 0) <= 1: raise HTTPException(status_code=400, detail="Can't remove the last admin.") + changed = target.role != role target.role = role db.commit() + if changed: + background.add_task(email_mod.send_role_changed, target.email, role, operator_contact()) 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 ------------------------------------- def _serialize_demo(row: DemoWhitelist) -> dict: @@ -270,16 +344,35 @@ def _seed_demo_playlists(db: Session, demo: User) -> int: 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: """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. - Shared by the admin's manual reset button and the scheduled demo reset (see scheduler).""" + back to a clean baseline and re-seed a few sample playlists + channel subscriptions. Returns + 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)) # Playlist items cascade on playlist delete (ondelete="CASCADE"). db.execute(delete(Playlist).where(Playlist.user_id == demo.id)) demo.preferences = {"language": "en"} db.commit() seeded = _seed_demo_playlists(db, demo) + _seed_demo_subscriptions(db, demo) db.commit() return seeded diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 9631344..6d05883 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -1,8 +1,8 @@ -from fastapi import APIRouter, Depends, HTTPException, Request -from sqlalchemy import delete, func, select +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from sqlalchemy import func, select 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.models import Invite, User @@ -78,6 +78,8 @@ def get_me( "avatar_url": user.avatar_url, "role": user.role, "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_write": has_write_scope(user), "pending_invites": pending_invites, @@ -88,6 +90,7 @@ def get_me( @router.delete("/account") def delete_my_account( request: Request, + background: BackgroundTasks, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: @@ -105,13 +108,10 @@ def delete_my_account( status_code=400, detail="You're the only admin — promote another admin before deleting your account.", ) - email = user.email.lower() user_id = user.id - # Raw delete so Postgres applies the ON DELETE CASCADE / SET NULL on every dependent table. - db.execute(delete(User).where(User.id == user_id)) - # Erase the access-request/whitelist row for this email too (full erasure; they'd re-request). - db.execute(delete(Invite).where(Invite.email == email)) - db.commit() + # Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the + # admin delete path. Capture the id first — `user` is detached once purge_user deletes the row. + purge_user(db, user, background) # 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] if remaining: diff --git a/frontend/public/welcome/channels.png b/frontend/public/welcome/channels.png new file mode 100644 index 0000000..31cf1e9 Binary files /dev/null and b/frontend/public/welcome/channels.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 009f5a3..b1a03a6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; -import { api, HttpError, type FeedFilters } from "./lib/api"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, HttpError, setUnauthorizedHandler, type FeedFilters } from "./lib/api"; import { setLanguage, isSupported, type LangCode } from "./i18n"; import { applyTheme, @@ -264,7 +264,47 @@ export default function App() { return () => window.removeEventListener("popstate", onPop); }, []); // 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 // each consent redirect). Derived from granted scopes + storage flags so it's stable diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index d3172b1..20165b6 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -1,21 +1,31 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; +import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import { useConfirm } from "./ConfirmProvider"; +import Tabs, { usePersistedTab } from "./Tabs"; // Admin-only user & access management: roles, access requests (the Invite whitelist), and the -// demo whitelist + reset. Moved out of Settings → Account into its own admin page; the Invite -// and demo data are unchanged (same tables) — only the UI relocated. +// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab +// carries a badge with the count of pending requests so it's visible without switching to it. 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 (
- - - + + {active === "roles" && } + {active === "access" && } + {active === "demo" && }
); } @@ -47,14 +57,37 @@ function UsersRoles({ me }: { me: Me }) { const confirm = useConfirm(); const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers }); const setRole = useMutation({ - mutationFn: ({ id, role }: { id: number; role: "user" | "admin" }) => api.setUserRole(id, role), - onSuccess: () => { + mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) => + api.setUserRole(id, role), + onSuccess: (_d, vars) => { 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. }); + 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 makeAdmin = u.role !== "admin"; const ok = await confirm({ @@ -65,7 +98,30 @@ function UsersRoles({ me }: { me: Me }) { confirmLabel: makeAdmin ? t("users.roles.makeAdmin") : t("users.roles.makeUser"), 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 ?? []; @@ -90,8 +146,11 @@ function UsersRoles({ me }: { me: Me }) { {u.role === "admin" && {t("users.roles.admin")}} {u.is_demo && {t("users.roles.demo")}} + {u.is_suspended && {t("users.roles.suspended")}} {!u.is_active && {t("users.roles.pending")}} - {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 && ( {t("users.roles.unverified")} )} + + + + + + ); })} @@ -135,10 +228,10 @@ function AdminInvites() { qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge }; const approve = useMutation({ - mutationFn: (id: number) => api.approveInvite(id), - onSuccess: () => { + mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id), + onSuccess: (_d, vars) => { 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") }), }); @@ -171,7 +264,7 @@ function AdminInvites() { approve.mutate(i.id)} + onApprove={() => approve.mutate({ id: i.id, email: i.email })} onDeny={() => deny.mutate(i.id)} busy={approve.isPending || deny.isPending} /> diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 9141265..b614eec 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -5,6 +5,7 @@ import { Check, RotateCcw, Save, Send } from "lucide-react"; import { api, type ConfigItem } from "../lib/api"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; +import Tabs, { usePersistedTab } from "./Tabs"; // 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 @@ -35,6 +36,9 @@ export default function ConfigPanel() { // clearing the field, since an empty secret field means "leave unchanged"). const [secretReset, setSecretReset] = useState>({}); const [saveState, setSaveState] = useState("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). useEffect(() => { @@ -94,18 +98,26 @@ export default function ConfigPanel() { const groupKeys = Object.keys(data.groups).sort( (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 (

{t("config.intro")}

- {groupKeys.map((g) => ( -
-
- {t(`config.groups.${g}`, g)} -
+ + + {activeGroup && ( +
- {data.groups[g].map((item) => ( + {data.groups[activeGroup].map((item) => ( - {g === "email" && ( + {activeGroup === "email" && (
- ))} + )} {(dirty || saveState !== "idle") && (
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 954f675..465e988 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; 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(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 ( +
+
+
+
{t("settings.account.googleLink.title")}
+

+ {me.has_google + ? t("settings.account.googleLink.connectedHint") + : t("settings.account.googleLink.connectHint")} +

+
+ {me.has_google ? ( + + {t("settings.account.googleLink.connected")} + + ) : ( + + )} +
+ +
+
+
+
{t("settings.account.password.title")}
+

+ {me.has_password + ? t("settings.account.password.setHint") + : t("settings.account.password.unsetHint")} +

+
+ +
+ {open && ( +
+ {me.has_password && ( + setCurrent(e.target.value)} + placeholder={t("settings.account.password.current")} + autoComplete="current-password" + className={inputCls} + /> + )} + setNext(e.target.value)} + placeholder={t("settings.account.password.new")} + autoComplete="new-password" + className={inputCls} + /> + {err &&

{err}

} + +
+ )} +
+
+ ); +} + function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { const { t } = useTranslation(); const confirm = useConfirm(); @@ -366,7 +486,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { if (!ok) return; try { 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 { /* the global error dialog surfaces the reason (e.g. last admin) */ } @@ -388,6 +509,8 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
+ {!me.is_demo && } + {me.is_demo ? (

diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx new file mode 100644 index 0000000..7871c3d --- /dev/null +++ b/frontend/src/components/Tabs.tsx @@ -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(() => 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 ( +

+ {tabs.map((tb) => { + const on = tb.id === active; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index 3bddb6b..df0daa9 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -1,12 +1,14 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { + Expand, Filter, Inbox, ListVideo, Lock, PlayCircle, Tags, + X, type LucideIcon, } from "lucide-react"; 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). export default function Welcome() { 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 (
@@ -48,7 +53,12 @@ export default function Welcome() {
{/* App preview */} - + {/* Features */}
@@ -60,13 +70,27 @@ export default function Welcome() {
- {/* Secondary showcase */} -
- - + {/* Secondary showcase — smaller thumbnails; click to view full size. */} +
+ +
+ {zoomed && ( + setZoomed(null)} /> + )} +