feat(auth): one mail, one click — the approval mail carries the confirmation link (E9.5 S6)

The funnel's common ordering made a person open TWO messages: one saying their access was approved,
another asking them to confirm their address. The approval mail now branches on what they actually
still have to do — carry the confirmation link, point at sign-in, or say a confirmation mail follows
once they register — so the usual case is one mail and one click. A mail that guesses wrong is worse
than a plain one: telling someone to sign in while their address is unconfirmed contradicts the
screen that will refuse them.

That embedded link lives 7 days rather than the usual 24 hours. Nobody sits waiting for an approval —
it can easily be read on Monday after a weekend, and an expired link spends the one-mail promise and
puts them back on two. The risk is small: the token only ever confirms an address; it signs nobody in
and sets no password.

The most likely root cause of the 2026-09-03 report that a confirmation mail "never arrived" is here
too, and it was never a delivery failure: re-registering an address that already had an unconfirmed
account returned in SILENCE — same cheerful "check your inbox", no mail sent at all. It now re-sends
the link. Nothing new leaks: the response and its timing are unchanged, and the public verify/resend
already does exactly this for the same input.

Whitelisting stays silent, and that is a decision, not an omission. The operator opens a door as a
possibility — usually already agreed with the person — and a row can sit approved for weeks with
nobody registering; a mail fired automatically there would be nudging someone who never asked.
Telling them is its own act on its own button, `invited_at` records it so a second click is a
decision rather than an accident, and the button only appears where nobody is behind the address yet:
"the door is open, come in" is meaningless to someone already inside.

Two things UAT caught that no test would have. Every fresh registration appeared in BOTH admin lists
at once, because the stuck-sign-ups filter keyed on "unconfirmed" — the rule is now "the address may
sign in, and this account still can't", which leaves an undecided request with the queue that owns
it. And the invitation button was offered on every approved row, half of which are existing users.

Also: the confirmation mail finally says what its link's rules are (once, 24 hours) — the reset mail
always has, while this one left the reader to discover both by meeting a dead link. The card's
"didn't get the email?" moves up under the form, and the three waiting messages name the spam folder,
which is where a first message to a fresh recipient sometimes lands.

Registration now records the language the sign-up page was in. Nothing reads it yet — the bilingual
templates are S7 — but it is the only moment that choice exists anywhere the server can see, and a
preference nobody recorded cannot be backfilled later.

UAT on localdev: re-registering re-sent the link (one token became two, one account); approval issued
a 7-day token and moved the account into the stuck list with only a re-send; whitelisting mailed
nobody; the invitation recorded itself and the row then warned that sending again would write twice.
Test data removed.
This commit is contained in:
2026-09-04 04:05:28 +02:00
parent 7acdf5d577
commit a76bab9b34
20 changed files with 687 additions and 47 deletions
+88 -9
View File
@@ -16,6 +16,7 @@ from app.auth import (
admin_user,
can_activate,
count_admins,
find_user_by_email,
get_or_create_demo_user,
is_allowed,
operator_contact,
@@ -30,7 +31,7 @@ from app.models import (
User,
)
from app.routes._common import get_or_404
from app.routes.auth import send_verification_link
from app.routes.auth import APPROVAL_VERIFY_TTL, issue_verification_link, send_verification_link
from app.utils import canonical_email, iso, valid_email
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -71,6 +72,10 @@ def _serialize(inv: Invite, *, has_account: bool = False) -> dict:
"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
@@ -96,6 +101,30 @@ def list_invites(
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).
@@ -159,7 +188,11 @@ def approve_invite(
) -> dict:
inv = _decide(db, invite_id, admin, approved=True)
_activate_user_by_email(db, inv.email)
background.add_task(email_mod.send_access_approved, 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)
background.add_task(email_mod.send_access_approved, inv.email, link, has_account, hours)
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
@@ -211,6 +244,49 @@ def add_invite(
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
@router.post("/invites/{invite_id}/invite")
def send_invitation(
invite_id: int,
background: BackgroundTasks,
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)
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()
background.add_task(email_mod.send_access_invitation, inv.email, link, has_account, hours)
return _serialize(inv, has_account=has_account)
@router.delete("/invites/{invite_id}")
def delete_invite(
invite_id: int,
@@ -459,13 +535,16 @@ def list_pending_access(_: User = Depends(admin_user), db: Session = Depends(get
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."""
rows = [(u, can_activate(db, u)) for u in _pending_access_rows(db)]
# An account that is inactive, confirmed and merely waiting on a still-pending invite is ALREADY
# an actionable row in the Access-requests queue above, with Approve on it. Listing it here too
# would make the admin choose between two buttons for one person.
return [
_serialize_user(u) | {"can_activate": ca} for u, ca in rows if ca or not u.email_verified
]
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.post("/users/{user_id}/activate")