"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add. Also the demo-account admin surface: the demo email whitelist + a manual state reset.""" from collections.abc import Iterable from datetime import datetime, timezone from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import or_, select from sqlalchemy.orm import Session from app import audit from app import email as email_mod from app import mailtext from app.audit import AuditAction from app.auth import ( admin_user, can_activate, count_admins, env_allowed_canonicals, find_user_by_email, get_or_create_demo_user, is_allowed, operator_contact, purge_user, ) from app.db import get_db from app.demo import reset_demo_state from app.models import ( DemoWhitelist, Invite, User, ) from app.routes._common import get_or_404 from app.routes.auth import ( APPROVAL_VERIFY_TTL, issue_set_password_link, issue_verification_link, send_verification_link, ) from app.utils import canonical_email, iso, valid_email from app.refusal import refused router = APIRouter(prefix="/api/admin", tags=["admin"]) def _accounts_for(db: Session, emails: Iterable[str]) -> set[str]: """Which of these addresses already have an account, as a set of CANONICAL forms. Batched, so listing the invites costs one extra query. An invite whose address has an account must not be deletable: `is_allowed` reads this row, so removing it would silently revoke a live user's access. Canonical on both sides, because `is_allowed` matches that way: an invite for `x.y@gmail.com` whose account registered as `xy@gmail.com` would otherwise look account-less, offer the admin a delete button, and take away a live user's access — precisely the accident this guard exists to prevent.""" wanted = {canonical_email(e) for e in emails if e} if not wanted: return set() rows = db.execute( select(User.email_canonical).where(User.email_canonical.in_(wanted)) ).scalars() return {e for e in rows if e} def _env_allowed(email: str) -> bool: """Whether the env bootstrap lists still let this address in. `is_allowed` consults them only when no Invite row exists, so this is exactly what deleting the row would fall back to — and the admin has to know that BEFORE they confirm, not from a toast afterwards. Canonical, because `is_allowed` is: a warning that disagrees with the gate it is warning about is worse than no warning.""" return canonical_email(email or "") in env_allowed_canonicals() def _serialize(inv: Invite, *, has_account: bool = False) -> dict: return { "id": inv.id, "email": inv.email, "status": inv.status, "note": inv.note, "requested_at": iso(inv.requested_at), "decided_at": iso(inv.decided_at), "decided_by": inv.decided_by, # When (if ever) the admin sent this person an invitation. Adding an address is silent, so # without this the admin has no way to answer "did I already write to them?" — and the only # failure mode is writing to a real person twice. "invited_at": iso(inv.invited_at), # Drives the delete affordance: an entry backed by an account is not removable here. "has_account": has_account, # …and whether removing it would actually take access away, or leave the env allow-list # granting it anyway (for an ADMIN_EMAILS address, as an admin). "still_allowed_by_env": _env_allowed(inv.email), # The language this person asked us in, or null when nobody ever told us (a hand-added # whitelist row). The invitation dialog preselects it, and falls back to the admin's own UI # language — a guess, but a visible one the admin can correct before sending. "lang": inv.lang, } @router.get("/invites") def list_invites( status: str | None = None, _: User = Depends(admin_user), db: Session = Depends(get_db), ) -> list[dict]: stmt = select(Invite) if status: stmt = stmt.where(Invite.status == status) # Pending first, then most-recently requested. rows = db.execute(stmt.order_by(Invite.requested_at.desc())).scalars().all() order = {"pending": 0, "approved": 1, "denied": 2} rows.sort(key=lambda i: order.get(i.status, 9)) accounts = _accounts_for(db, (i.email for i in rows)) return [_serialize(i, has_account=canonical_email(i.email) in accounts) for i in rows] def _welcome_mail_parts(db: Session, email: str) -> tuple[str | None, bool, int]: """What a welcome or invitation mail should tell this address to do next: a confirmation link (if there is an unconfirmed account behind it), whether an account exists at all, and how long the link lasts. This is the C option's whole mechanic. In the common ordering — the address is approved and the person signs up either side of that — they would otherwise have to find and open TWO messages: one saying they are in, another asking them to confirm. Carrying the link inside the mail that is already going out makes it one mail and one click. Resolved through `find_user_by_email`, so an account registered under another spelling of the same Gmail mailbox is still recognised — and, deliberately, so several candidates resolve to "no single answer" rather than a guess: an invitation that carried the wrong account's link would be worse than one that simply pointed at the sign-up page. """ hours = int(APPROVAL_VERIFY_TTL.total_seconds() // 3600) user = find_user_by_email(db, email) if user is None or user.is_demo: return None, False, hours if user.email_verified or not email_mod.email_enabled(db): return None, True, hours return issue_verification_link(db, user, APPROVAL_VERIFY_TTL), True, hours def _activate_user_by_email(db: Session, email: str) -> None: """Approving access activates a matching (e.g. pending password-registration) account so it can sign in. No-op if no such user exists yet (a Google user is created at first login). E9.5 S1: this path deliberately does NOT revoke a preset password. Revoking here destroyed the password a legitimate registrant had just chosen and made "your password was cleared for security" the DEFAULT experience, because approve-before-verify is the normal ordering. S1 justified that by saying `password_login` refuses an unverified account BEFORE it looks at `is_active`, so a pre-emptor could not sign in "approved or not". A review of the finished epic showed that argument only holds while the address stays unverified — and S6's approval mail hands the real owner a confirmation link precisely so they will end that state. Nothing replaced the revoke, and that is deliberate: round 2 of the same review tried "the newest registration owns an unverified account" and reverted it, because the rule is symmetric — an attacker can always register last — and it handed anonymous callers a write onto a real account's credential. The owner's recourse is password-reset, which proves the mailbox, rewrites the hash and bumps the session epoch. The residual is logged as SEC-16. The revoke also stays on the Google-adoption path (auth.revoke_preset_password_if_unverified), where ownership is actually attested.""" # Canonical, with the literal spelling preferred when both exist: approving # `npeter83.temu@gmail.com` must activate the account that registered as `npeter83temu@gmail.com`, # since Gmail delivers both to one mailbox and the admin decided about the mailbox. email = email.lower() candidates = ( db.execute( select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id) ) .scalars() .all() ) user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None) if user is not None and not user.is_active: user.is_active = True db.flush() # the handler commits (CLAUDE.md: helpers flush) def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite: inv = get_or_404(db, Invite, invite_id, what="invite") # Idempotency guard: only a still-pending invite can be decided. Without this a repeat/stale # approve or deny re-runs its side effects — re-activating a user, or (now that deny emails) # sending a duplicate decision mail, even flipping an already-approved invite to "denied" and # mailing a rejection to an already-active account. if inv.status != "pending": raise refused(409, "invite_decided") inv.status = "approved" if approved else "denied" inv.decided_at = datetime.now(timezone.utc) inv.decided_by = admin.email audit.record( db, admin.id, AuditAction.INVITE_APPROVE if approved else AuditAction.INVITE_DENY, target_type="invite", target_id=inv.id, summary=inv.email, ) db.flush() # the handler commits, with the activation that goes with it return inv @router.post("/invites/{invite_id}/approve") def approve_invite( invite_id: int, background: BackgroundTasks, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: inv = _decide(db, invite_id, admin, approved=True) _activate_user_by_email(db, inv.email) # Computed here, not in the background task: issuing the token needs this request's session, and # the task must not outlive it. Approval answers a request the person MADE, so unlike the # invitation below it is sent automatically — a reply is owed, not a nudge. link, has_account, hours = _welcome_mail_parts(db, inv.email) lang = mailtext.lang_for_email(db, inv.email) background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours) # Before the response, so the mail (a background task, after it) never describes a decision # the database does not hold yet. db.commit() return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email]))) @router.post("/invites/{invite_id}/deny") def deny_invite( invite_id: int, background: BackgroundTasks, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: # Symmetry with approve: the requester hears back either way, instead of a silent limbo. No # account is activated, so this is an email only (the requester has no in-app inbox yet). inv = _decide(db, invite_id, admin, approved=False) background.add_task( email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email) ) db.commit() return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email]))) class AddInviteIn(BaseModel): email: str = "" @router.post("/invites") def add_invite( body: AddInviteIn, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Manually whitelist an email (approved straight away). Convenience for the admin.""" email = body.email.strip().lower() if not valid_email(email): raise refused(400, "invalid_email") # Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries # exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@), # which is how an admin ends up approving the same human twice and still seeing them bounce. canon = canonical_email(email) rows = ( db.execute(select(Invite).where(Invite.email_canonical == canon).order_by(Invite.id)) .scalars() .all() ) inv = next((r for r in rows if r.email == email), None) or next(iter(rows), None) if inv is None: inv = Invite(email=email) db.add(inv) inv.status = "approved" inv.decided_at = datetime.now(timezone.utc) inv.decided_by = admin.email db.flush() # a new invite's id, for the audit row — before it, the row named no target audit.record( db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email ) # One transaction: the whitelisting and the activation it implies land together or not at all. _activate_user_by_email(db, email) db.commit() return _serialize(inv, has_account=bool(_accounts_for(db, [email]))) class InviteSendIn(BaseModel): # Which language to write this person in. Optional so an existing caller (and a test) can post # an empty body, but the admin UI always sends it: this is the one recipient the app has no # signal about, because the admin typed the address in themselves. lang: str | None = None @router.post("/invites/{invite_id}/invite") def send_invitation( invite_id: int, body: InviteSendIn | None = None, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Tell someone their address is whitelisted — only when the admin asks for it. Whitelisting stays SILENT on purpose. The operator opens a door as a possibility, usually having already agreed it with the person, and an address can sit approved for weeks with nobody registering; a mail fired automatically would be nudging someone who never asked to be nudged. So the send is its own act, on its own button, and `invited_at` records it so the admin can see they have already written rather than writing twice. Only an APPROVED row can be invited: there is nothing to invite someone to while their request is undecided, and inviting a denied address would contradict the decision.""" inv = get_or_404(db, Invite, invite_id, what="invite") if inv.status != "approved": raise HTTPException( status_code=409, detail={"code": "invite_send", "reason": "not_approved", "email": inv.email}, ) if not email_mod.email_enabled(db): # Same reasoning as the admin-side verification resend: mail is fail-soft everywhere else, # but a button the admin pressed must not answer with a success toast for a mail that was # never sent. raise HTTPException(status_code=409, detail={"code": "invite_send", "reason": "email_off"}) link, has_account, hours = _welcome_mail_parts(db, inv.email) # An explicit pick from the dialog is recorded, so a later mail to this address matches the one # the admin already sent. Only the explicit pick — writing the fallback back would turn a column # that means "what this person told us" into "what we last guessed". picked = body.lang if body else None if picked in mailtext.SUPPORTED: lang = inv.lang = picked else: lang = mailtext.lang_for_email(db, inv.email) # Sent INLINE, so `invited_at` records the send rather than the intent. Every other mail here is # fail-soft in the background, which is right when nobody is waiting on it — but this column # exists to answer "did I already write to them?", and the UI dims the button and warns about a # second mail on the strength of it. Stamping it before a send that then failed makes the one # question it answers a lie, and talks the admin out of the retry that would fix it. # # One thing a failed send does leave behind: the confirmation token `_welcome_mail_parts` minted # to render the link, which commits inside `_issue_token`. Accepted rather than chased — the # token is single-use, expires on its own, and was mailed nowhere; deferring it would mean # rewriting a helper three callers share so the handler owns its commit. if not email_mod.send_access_invitation(inv.email, lang, link, has_account, hours): raise HTTPException( status_code=422, detail={"code": "invite_send", "reason": "send_failed"} ) # Only after the mail is actually out. Same as the other two paths that decide for an address # (`approve_invite`, `add_invite`): an approved row means this mailbox may come in, so finish it, # or the mail can promise "your account is ready — just sign in" to someone `password_login` will # refuse for `is_active`. Doing it BEFORE the send would mean a request the UI reports as failed # had quietly activated an account behind the admin's back. _activate_user_by_email(db, inv.email) inv.invited_at = datetime.now(timezone.utc) audit.record( db, admin.id, AuditAction.INVITE_SEND, target_type="invite", target_id=inv.id, summary=inv.email, ) db.commit() return _serialize(inv, has_account=has_account) @router.delete("/invites/{invite_id}") def delete_invite( invite_id: int, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Remove a whitelist/request entry outright — the counterpart to add_invite, for an address that never became an account (an approval added by mistake, or one that is no longer wanted). Deny records a decision and keeps the row for the audit trail; delete forgets the address. Neither stops the person asking again: `upsert_pending_invite` reopens a denied row, and a deleted one simply comes back as a fresh pending request. Refused once an account exists for the address, whatever its state: this row is the account's approval gate, and orphaning the account from it would leave a person who can neither sign in nor be approved. Delete the USER instead — purge_user removes the invite along with it. Reports `still_allowed_by_env`, because deleting the row is NOT always a revoke: `is_allowed` falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still promoted to admin on its next Google sign-in). The admin has to hear that, not guess it.""" inv = get_or_404(db, Invite, invite_id, what="invite") if _accounts_for(db, [inv.email]): raise refused(409, "address_has_account") still_allowed_by_env = _env_allowed(inv.email) audit.record( db, admin.id, AuditAction.INVITE_DELETE, target_type="invite", target_id=inv.id, summary=inv.email, ) db.delete(inv) db.commit() return {"deleted": invite_id, "still_allowed_by_env": still_allowed_by_env} # --- Users & roles -------------------------------------------------------------------- def _serialize_user(u: User) -> dict: return { "id": u.id, "email": u.email, "display_name": u.display_name, "role": u.role, "is_active": u.is_active, "is_suspended": u.is_suspended, "email_verified": u.email_verified, "is_demo": u.is_demo, "has_password": u.password_hash is not None, # Distinguishes "this account never had a password" (Google-only) from "SEC-10 cleared it" — # the same distinction _send_reset_if_eligible needs, and the one an admin has to see before # deciding what kind of help someone needs. "password_revoked_at": iso(u.password_revoked_at), "has_google": u.google_sub is not None, # Only when it DIFFERS from the account address (E9.5 S3): "connected" is not the same # statement as "connected as someone else", and the second one is the one that explains a # confusing sign-in. "google_email": u.google_email if u.google_email != u.email else None, "created_at": iso(u.created_at), # NULL = no sign-in since the column existed (0085), or the shared demo account. "last_login_at": iso(u.last_login_at), } @router.get("/users") def list_users(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]: rows = db.execute(select(User).order_by(User.created_at.desc())).scalars().all() return [_serialize_user(u) for u in rows] class SetRoleIn(BaseModel): role: str @router.patch("/users/{user_id}/role") def set_user_role( user_id: int, body: SetRoleIn, 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. Emails the user when it actually changes.""" role = body.role if role not in ("user", "admin"): raise refused(400, "invalid_role") target = get_or_404(db, User, user_id, what="user") if target.is_demo: raise refused(400, "demo_role") if target.id == admin.id: raise refused(400, "own_role") if ( target.role == "admin" and role == "user" and not target.is_suspended and count_admins(db, active_only=True) <= 1 ): raise refused(400, "last_admin") changed = target.role != role if changed: audit.record( db, admin.id, AuditAction.USER_ROLE_CHANGE, target_type="user", target_id=target.id, summary=target.email, before={"role": target.role}, after={"role": role}, ) target.role = role db.commit() if changed: background.add_task( email_mod.send_role_changed, target.email, mailtext.lang_for_user(target), role, operator_contact(), ) return _serialize_user(target) class SetSuspendedIn(BaseModel): suspended: bool = False @router.patch("/users/{user_id}/suspend") def set_user_suspended( user_id: int, body: SetSuspendedIn, 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 = body.suspended target = get_or_404(db, User, user_id, what="user") if target.is_demo: raise refused(400, "demo_suspend") if target.id == admin.id: raise refused(400, "own_suspend") if ( suspended and target.role == "admin" and not target.is_suspended and count_admins(db, active_only=True) <= 1 ): raise refused(400, "last_active_admin") was_suspended = target.is_suspended if was_suspended != suspended: audit.record( db, admin.id, AuditAction.USER_SUSPEND if suspended else AuditAction.USER_UNSUSPEND, target_type="user", target_id=target.id, summary=target.email, ) target.is_suspended = suspended db.commit() if was_suspended and not suspended: background.add_task( email_mod.send_account_unsuspended, target.email, mailtext.lang_for_user(target), 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 = get_or_404(db, User, user_id, what="user") if target.is_demo: raise refused(400, "demo_delete") if target.id == admin.id: raise refused(400, "own_delete") if ( target.role == "admin" and not target.is_suspended and count_admins(db, active_only=True) <= 1 ): raise refused(400, "last_admin") # Record BEFORE the purge — the target row (and its email) is gone afterwards. audit.record( db, admin.id, AuditAction.USER_DELETE, target_type="user", target_id=target.id, summary=target.email, ) purge_user(db, target, background) return {"deleted": user_id} # --- Demo account: email whitelist + state reset ------------------------------------- # --- Stuck sign-ups: the admin lifeline (E9.5 S5, roadmap UX-25) --------------------------------- def _pending_access_rows(db: Session) -> list[User]: """Accounts that have signed up but still cannot sign in, newest first. Two different dead ends share this list, because from the admin's chair they look the same — a person who registered and is still outside: · `is_active=False` — approved BEFORE they signed up, so nothing ever landed in the Access-requests queue and its Approve button doesn't apply (`_decide` only takes a pending row). This is roadmap UX-25, and it is why the list exists. · `email_verified=False` — the confirmation mail hasn't been acted on. Not the admin's to decide, but theirs to help with: on 2026-09-03 the first mail to a fresh Gmail landed in Spam, so "send it again" is a real move. A suspended account is deliberately excluded: it cannot sign in either, but that is a decision already made, not a person stuck waiting on one. """ return list( db.execute( select(User) .where( User.is_demo.is_(False), User.is_suspended.is_(False), or_(User.is_active.is_(False), User.email_verified.is_(False)), ) .order_by(User.created_at.desc()) ) .scalars() .all() ) @router.get("/pending-access") def list_pending_access(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]: """The sign-ups that are still outside, with which gate is shut for each. `can_activate` cannot be derived on the client — it needs the invite/allow-list lookup — so it is answered here. One `is_allowed` query per row rather than a batched re-implementation of it: the row count is the admin's own backlog of stuck sign-ups (single digits on any real instance), and a second, subtly different copy of the "may this address sign in" rule is exactly the drift this codebase keeps getting bitten by.""" out = [] for u in _pending_access_rows(db): # The whole rule in one line: this address MAY sign in, and this account still can't. An # undecided request fails it (nothing is allowed yet) and so does a denied one — both belong # to the queue above, which owns that decision. Without this, every fresh registration # appeared in both lists at once, which UAT caught on 2026-09-05. if not is_allowed(db, u.email): continue out.append(_serialize_user(u) | {"can_activate": can_activate(db, u)}) return out @router.get("/users/{user_id}/access") def user_access( user_id: int, _: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Everything the account panel shows about why this person can or cannot get in (E12 S5). Its own endpoint rather than extra columns on /users: `is_allowed` and `can_activate` each run a query, and the panel opens for ONE user at a time — folding them into the list would pay that cost per row on every load to answer a question nobody asked yet.""" target = get_or_404(db, User, user_id, what="user") return _serialize_user(target) | { "is_allowed": is_allowed(db, target.email), "can_activate": can_activate(db, target), "email_configured": email_mod.email_enabled(db), } @router.post("/users/{user_id}/set-password-link") def create_set_password_link( user_id: int, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Mint a one-time link that lets this account set a password, with no email involved (E12 S5). The answer to SEC-15, SEC-16 and "no SMTP and someone forgot their password" at once. The admin hands the link over by whatever channel they trust; opening it sets a password and signs out whoever held the old one. Deliberately NOT gated on the account's state. It is useful for a pre-empted address, for a revoked password, for a Google-only account that wants one, and for a plain forgotten password on an instance with no mail — narrowing it to one of those would only make the admin's real case the one it refuses. The demo account is the single exception: it is shared, so handing anyone a way to change its password is handing them everyone else's session.""" target = get_or_404(db, User, user_id, what="user") if target.is_demo: raise HTTPException( status_code=400, detail={"code": "setpw", "reason": "demo", "email": target.email} ) url, expires_at = issue_set_password_link(db, target) # The URL itself is NOT recorded: the audit log is read by humans and kept long, and a live # credential-setting link is the last thing that belongs in it. That the link was issued, by whom, # for whom, is the auditable fact. audit.record( db, admin.id, AuditAction.USER_SETPW_LINK, target_type="user", target_id=target.id, summary=target.email, ) db.commit() return {"ok": True, "url": url, "expires_at": iso(expires_at), "email": target.email} @router.post("/users/{user_id}/activate") def activate_user( user_id: int, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Let in a registrant whose address was approved before they signed up (roadmap UX-25). Admin-side ONLY, and that is the whole design. Two earlier attempts at this were reverted after review, and both moved authority rather than visibility: auto-activating on registration turns a standing whitelist entry into a bearer grant for whoever registers the address first, and reopening the approved invite to `pending` lets an anonymous sign-up REVOKE a standing approval. This endpoint changes nothing about what an unauthenticated caller can cause — the admin could already do exactly this by re-entering the address in the whitelist box (`add_invite` → `_activate_user_by_email`). What was missing was any way to know they had to.""" target = get_or_404(db, User, user_id, what="user") if target.is_demo: raise refused(400, "demo_activate") if target.is_active: return _serialize_user(target) # idempotent: a double click is not an error if not is_allowed(db, target.email): raise HTTPException( status_code=409, detail={"code": "activate", "reason": "not_allowed", "email": target.email}, ) audit.record( db, admin.id, AuditAction.USER_ACTIVATE, target_type="user", target_id=target.id, summary=target.email, before={"is_active": False}, after={"is_active": True}, ) target.is_active = True db.commit() return _serialize_user(target) @router.post("/users/{user_id}/resend-verification") def resend_user_verification( user_id: int, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Re-send an account's confirmation link from the admin side. The public /auth/verify/resend answers uniformly and reveals nothing, which is right for an anonymous caller but leaves the admin unable to tell a delivered mail from a dropped one. Here the caller is already authenticated as an admin, so this one says plainly what happened. Not rate-limited: an admin is not a threat model to enumerate against, and the deliberate silence of the public endpoint is exactly what this exists to escape.""" target = get_or_404(db, User, user_id, what="user") if target.is_demo or target.email_verified: raise HTTPException( status_code=409, detail={"code": "resend", "reason": "not_applicable", "email": target.email}, ) if not email_mod.email_enabled(db): # Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to # fall back on. An admin pressing a button deserves the failure, not a success toast. raise HTTPException(status_code=409, detail={"code": "resend", "reason": "email_off"}) if not send_verification_link(db, target): # Same rule as the invitation above: a button an admin pressed must not answer with a # success for a mail that was dropped. Mail is fail-soft everywhere else because nobody is # waiting on the answer; here somebody is. raise HTTPException(status_code=422, detail={"code": "resend", "reason": "send_failed"}) audit.record( db, admin.id, AuditAction.USER_VERIFY_RESEND, target_type="user", target_id=target.id, summary=target.email, ) db.commit() return {"ok": True, "email": target.email} def _serialize_demo(row: DemoWhitelist) -> dict: return { "id": row.id, "email": row.email, "note": row.note, "added_by": row.added_by, "created_at": iso(row.created_at), } @router.get("/demo/whitelist") def list_demo_whitelist(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]: rows = ( db.execute(select(DemoWhitelist).order_by(DemoWhitelist.created_at.desc())).scalars().all() ) return [_serialize_demo(r) for r in rows] class AddDemoWhitelistIn(BaseModel): email: str = "" note: str | None = None @router.post("/demo/whitelist") def add_demo_whitelist( body: AddDemoWhitelistIn, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Add an email that may enter the shared demo account from the login page (no Google sign-in). Idempotent — re-adding an existing email just returns it.""" email = body.email.strip().lower() if not valid_email(email): raise refused(400, "invalid_email") row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none() if row is None: row = DemoWhitelist( email=email, note=(body.note or "").strip() or None, added_by=admin.email, ) db.add(row) audit.record( db, admin.id, AuditAction.DEMO_ADD, target_type="demo", target_id=email, summary=email ) db.commit() return _serialize_demo(row) @router.delete("/demo/whitelist/{entry_id}") def remove_demo_whitelist( entry_id: int, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: row = db.get(DemoWhitelist, entry_id) if row is not None: audit.record( db, admin.id, AuditAction.DEMO_REMOVE, target_type="demo", target_id=row.email, summary=row.email, ) db.delete(row) db.commit() return {"deleted": entry_id} @router.post("/demo/reset") def reset_demo(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: """Manually clean up the shared demo sandbox back to a baseline. A scheduled job does the same automatically (admin-tunable interval); this is the admin's on-demand button.""" demo = get_or_create_demo_user(db) seeded = reset_demo_state(db, demo) audit.record( db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded", after={"playlists_seeded": seeded}, # the count as data: named in the reader's language ) db.commit() return {"ok": True, "reset": True, "playlists_seeded": seeded} @router.post("/purge-discovery") def purge_discovery(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: """Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result videos nobody watched/saved/subscribed to, plus explored-but-unsubscribed channels. The scheduled discovery-cleanup job does the same on its interval; this is the on-demand button.""" from app.sync.explore import purge_unkept_explored, purge_unkept_search result = {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)} audit.record( db, admin.id, AuditAction.DISCOVERY_PURGE, summary=", ".join(f"{k}: {v}" for k, v in result.items()) or None, after=result, ) db.commit() return {"ok": True, **result}