Design option C, chosen by the user 2026-09-05. Nothing in the admin UI ever showed an account's GATE STATE: an admin could see a "Pending approval" chip but not whether the address was confirmed, whether SEC-10 had cleared the password, or which Google account was attached — so every support conversation started by guessing. Clicking a row now opens a drawer that says which gate the person is actually stuck behind, with the recovery actions under that readout rather than in the row. The drawer is `Modal placement="drawer"`, not a second dialog: the layer stack, ESC, the focus trap, Back-to-close and the scroll lock are the hard parts, and duplicating them is how two shells drift apart. It slides in, and honours prefers-reduced-motion like the rest. The link itself (SEC-15, SEC-16, and the everyday "no SMTP and someone forgot theirs") reuses the existing token machinery and the SAME `#reset=` entry the mailed link uses, so the signed-out card consumes it with no new flow. It is a distinct token KIND for one reason: a mailed reset arrives in the mailbox, so completing it proves ownership and confirms the address; an admin-issued link proves nothing about the mailbox — the admin hands it over by whatever means they choose — so it must not satisfy the email gate. Claiming otherwise would be the same false attestation SEC-15 is about. Issuing does not revoke the current password: a misclick must not lock anyone out. USING it rewrites the hash and bumps the session epoch, which is what evicts a squatter — SEC-16's actual remedy. Spending either kind burns the siblings of both, so a completed reset cannot leave a live admin link behind. The non-consuming probe learned the new kind too, or every admin link would have opened onto "this link is spent". The audit log records that a link was issued, by whom, for whom — never the link, which is a live credential-setting secret and the last thing that belongs in a long-lived log a human reads. Not gated on account state on purpose: it is useful for a pre-empted address, a revoked password, a Google-only account that wants one, and a plain forgotten password. Narrowing it would only make it refuse exactly when the admin needs it. The shared demo account is the one exception. Verified on localdev, both languages, end to end: issue from the panel -> the signed-in tab shows the honest "reset links open in a signed-out window" banner with the token still in the address bar -> signing out draws the "Set a new password" form, which is only possible because the probe recognises the new kind. Gate: pytest 925 -> 942, vitest 676.
862 lines
37 KiB
Python
862 lines
37 KiB
Python
"""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
|
|
|
|
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.commit()
|
|
|
|
|
|
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
|
inv = get_or_404(db, Invite, invite_id, detail="No such 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 HTTPException(status_code=409, detail="Invite already 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.commit()
|
|
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)
|
|
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)
|
|
)
|
|
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 HTTPException(status_code=400, detail="Enter a valid email address.")
|
|
# 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
|
|
audit.record(
|
|
db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email
|
|
)
|
|
db.commit()
|
|
_activate_user_by_email(db, email)
|
|
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, detail="No such 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, detail="No such invite")
|
|
if _accounts_for(db, [inv.email]):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="That address already has an account — delete the account instead.",
|
|
)
|
|
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),
|
|
}
|
|
|
|
|
|
@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 HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
|
|
target = db.get(User, user_id)
|
|
if target is None:
|
|
raise HTTPException(status_code=404, detail="No such user")
|
|
if target.is_demo:
|
|
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
|
|
if target.id == admin.id:
|
|
raise HTTPException(status_code=400, detail="You can't change your own role.")
|
|
if (
|
|
target.role == "admin"
|
|
and role == "user"
|
|
and not target.is_suspended
|
|
and count_admins(db, active_only=True) <= 1
|
|
):
|
|
raise HTTPException(status_code=400, detail="Can't remove the 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 = 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
|
|
and count_admins(db, active_only=True) <= 1
|
|
):
|
|
raise HTTPException(status_code=400, detail="Can't suspend the 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 = 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"
|
|
and not target.is_suspended
|
|
and count_admins(db, active_only=True) <= 1
|
|
):
|
|
raise HTTPException(status_code=400, detail="Can't delete the 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, detail="No such 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, detail="No such 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, detail="No such user")
|
|
if target.is_demo:
|
|
raise HTTPException(status_code=400, detail="The demo account can't be activated.")
|
|
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, detail="No such 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 HTTPException(status_code=400, detail="Enter a valid email address.")
|
|
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"
|
|
)
|
|
db.commit()
|
|
return {"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 result
|