~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.
- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
reason, …params}; localizeDetail translates errors.refused.<reason> with the
params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
answer a reason; the search scraper's failure is logged and answered as
youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
(only the framework's own can remain) is left out, so reading .detail is
safe everywhere — including destructured, the hole the raw-detail source
scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
for a detail; the contract test requires every refused reason and every
not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
lockfile lines.
854 lines
36 KiB
Python
854 lines
36 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
|
|
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.commit()
|
|
|
|
|
|
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.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 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
|
|
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, 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"
|
|
)
|
|
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
|