release: 0.80.0 (improvement/e9.5-onboarding-access)
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"""email_canonical on users + invites, for matching one mailbox across Gmail spellings
|
||||
|
||||
Revision ID: 0079_email_canonical
|
||||
Revises: 0078_password_revoked_at
|
||||
Create Date: 2026-09-04
|
||||
|
||||
Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE mailbox; the invite gate
|
||||
only lowercased. On 2026-09-03 that turned an approved user's Google sign-in into a silent second
|
||||
access request (OAuth returns the account's stored primary, which need not be the spelling they typed)
|
||||
and left prod with two user rows for one mailbox.
|
||||
|
||||
The backfill below reproduces `app.utils.canonical_email` in SQL. That duplication is deliberate — a
|
||||
migration must not import application code that will keep changing — but it means the two have to
|
||||
agree; `test_canonical_email_backfill_matches_the_helper` pins them together.
|
||||
|
||||
Deliberately NOT unique. Prod already carries canonical duplicates: two approved invites for one
|
||||
mailbox dating back to 2026-06-16, and two active user rows from the 2026-09-03 walkthrough. Merging
|
||||
accounts is a judgement call about people's data, not something a migration gets to do on its own, so
|
||||
this migration only records the canonical form and leaves the duplicates in place for a human.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0079_email_canonical"
|
||||
down_revision: str | None = "0078_password_revoked_at"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
# Mirrors app.utils.canonical_email: Gmail (and its googlemail alias) loses dots and any `+tag` from
|
||||
# the local part and lands on gmail.com; every other domain is only lowercased, because the dot rule
|
||||
# is not guaranteed elsewhere — and does not hold for Workspace custom domains unless their admin
|
||||
# enabled it.
|
||||
_CANON_SQL = """
|
||||
CASE
|
||||
WHEN split_part(lower({col}), '@', 2) IN ('gmail.com', 'googlemail.com')
|
||||
-- an empty local part after stripping falls through to plain lowercase, as the helper does
|
||||
AND replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') <> ''
|
||||
THEN replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') || '@gmail.com'
|
||||
ELSE lower({col})
|
||||
END
|
||||
"""
|
||||
|
||||
|
||||
def _backfill(table: str) -> None:
|
||||
op.execute(
|
||||
f"UPDATE {table} SET email_canonical = {_CANON_SQL.format(col='email')} "
|
||||
f"WHERE email IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in ("users", "invites"):
|
||||
op.add_column(
|
||||
table,
|
||||
sa.Column("email_canonical", sa.String(length=320), nullable=False, server_default=""),
|
||||
)
|
||||
_backfill(table)
|
||||
op.create_index(f"ix_{table}_email_canonical", table, ["email_canonical"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ("users", "invites"):
|
||||
op.drop_index(f"ix_{table}_email_canonical", table_name=table)
|
||||
op.drop_column(table, "email_canonical")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""users.google_email — the address Google asserts, kept beside the one the user registered with
|
||||
|
||||
Revision ID: 0080_google_email
|
||||
Revises: 0079_email_canonical
|
||||
Create Date: 2026-09-04
|
||||
|
||||
Since 0079's follow-up a Google sign-in no longer overwrites `users.email`, so a deliberate
|
||||
`me+siftlode@gmail.com` survives being linked to a Google account whose primary is `me@gmail.com`.
|
||||
That leaves two true addresses for one account and only one of them stored. This column holds the
|
||||
other, so the account menu and the Settings sign-in row can show which address Google actually
|
||||
answers with.
|
||||
|
||||
No backfill is possible: Google's answer is only known at sign-in. Accounts linked before this
|
||||
column existed carry NULL until their owner next signs in with Google — and for the majority the two
|
||||
addresses are identical anyway, where the UI shows nothing either way.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0080_google_email"
|
||||
down_revision: str | None = "0079_email_canonical"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("google_email", sa.String(length=320), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "google_email")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""invites.invited_at — when the admin actually told this person the door was open
|
||||
|
||||
Revision ID: 0081_invite_invited_at
|
||||
Revises: 0080_google_email
|
||||
Create Date: 2026-09-05
|
||||
|
||||
Whitelisting an address stays SILENT by design: the operator is opening a door as a possibility,
|
||||
usually after agreeing it with the person, and a row can sit approved for weeks without anyone
|
||||
registering. Telling them is therefore a separate, deliberate act — a send button on the row.
|
||||
|
||||
Which raises the one question the admin cannot otherwise answer: "did I already write to them?"
|
||||
Without a record the only failure mode is writing to a real person twice, which is exactly the
|
||||
nudging the silent-by-default design exists to avoid. The audit log holds the event, but not where
|
||||
the decision is made.
|
||||
|
||||
NULL means "never invited from here", which is true of every existing row: no backfill is possible
|
||||
or wanted, since the historic approval mails answered a REQUEST rather than inviting anyone.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0081_invite_invited_at"
|
||||
down_revision: str | None = "0080_google_email"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("invites", sa.Column("invited_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("invites", "invited_at")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""invites.lang — the language this person asked us in
|
||||
|
||||
Revision ID: 0082_invite_lang
|
||||
Revises: 0081_invite_invited_at
|
||||
Create Date: 2026-09-05
|
||||
|
||||
The mails an access request produces — approved, denied, invited — go to an address that often has
|
||||
no account yet, so there is no `preferences.language` to read. The signal does exist, though, and
|
||||
only at the moment of the request: the language of the page the form was submitted from, or the
|
||||
locale Google reported for a sign-in we refused. Recording it there is what lets the decision mail
|
||||
answer in the requester's own language instead of an admin's guess about a stranger.
|
||||
|
||||
Captured rather than derived, for the third time in this epic: `preferences.language` (S6) and
|
||||
`google_email` (S3) both taught that a signal which exists only in one moment cannot be backfilled
|
||||
later. So no backfill here either — NULL means "we were never told", which is the honest state for
|
||||
every historic row, and for every address an admin whitelists by hand. Those fall back to English
|
||||
unless the admin picks otherwise on the invitation send.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0082_invite_lang"
|
||||
down_revision: str | None = "0081_invite_invited_at"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("invites", sa.Column("lang", sa.String(length=8), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("invites", "lang")
|
||||
@@ -27,10 +27,17 @@ class AuditAction:
|
||||
USER_SUSPEND = "user_suspend"
|
||||
USER_UNSUSPEND = "user_unsuspend"
|
||||
USER_DELETE = "user_delete"
|
||||
# E9.5 S5: an admin let a stuck registrant in, or re-sent their confirmation link. Both are
|
||||
# recorded because both are the admin acting ON someone's access, not merely reading a list.
|
||||
USER_ACTIVATE = "user_activate"
|
||||
USER_VERIFY_RESEND = "user_verify_resend"
|
||||
INVITE_APPROVE = "invite_approve"
|
||||
INVITE_DENY = "invite_deny"
|
||||
INVITE_ADD = "invite_add"
|
||||
INVITE_DELETE = "invite_delete"
|
||||
# E9.5 S6: the admin chose to tell someone their address is whitelisted. Adding the address is
|
||||
# silent, so this is the act that actually reaches a person — and the one worth a record.
|
||||
INVITE_SEND = "invite_send"
|
||||
DEMO_ADD = "demo_add"
|
||||
DEMO_REMOVE = "demo_remove"
|
||||
DEMO_RESET = "demo_reset"
|
||||
|
||||
+135
-8
@@ -7,11 +7,13 @@ from sqlalchemy.orm import Session
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import Invite, User
|
||||
from app.security import decrypt_optional
|
||||
from app.utils import canonical_email
|
||||
|
||||
PASSWORD_MIN_LEN = 10
|
||||
|
||||
@@ -63,16 +65,126 @@ def has_write_scope(user: User) -> bool:
|
||||
return WRITE_SCOPE in tok.scopes.split()
|
||||
|
||||
|
||||
def env_allowed_canonicals() -> set[str]:
|
||||
"""The env bootstrap allow-list, in CANONICAL form — the only shape it may be compared in.
|
||||
|
||||
S3 canonicalised the Invite branch of `is_allowed` and left this one matching the literal string,
|
||||
which is the 2026-09-03 lockout with a different source of truth: an operator who writes
|
||||
`first.last@gmail.com` into ALLOWED_EMAILS is refused when Google answers with the undotted
|
||||
primary, and a `+tag`ged entry can never match at all, because a Google account cannot contain a
|
||||
`+`. The addresses themselves stay literal wherever we MAIL them (`admin_notify_groups`); only
|
||||
the comparison is canonical.
|
||||
"""
|
||||
return env_admin_canonicals() | {canonical_email(e) for e in settings.allowed_email_set}
|
||||
|
||||
|
||||
def env_admin_canonicals() -> set[str]:
|
||||
"""The env ADMIN_EMAILS bootstrap list, canonical — for the role check only. Kept separate from
|
||||
`env_allowed_canonicals` because being listed as an admin also grants access, but being allowed
|
||||
does not make anyone an admin."""
|
||||
return {canonical_email(e) for e in settings.admin_email_set}
|
||||
|
||||
|
||||
def is_allowed(db: Session, email: str) -> bool:
|
||||
"""May this email sign in? An approved Invite is the source of truth; the env
|
||||
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB)."""
|
||||
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB).
|
||||
|
||||
Matched on the CANONICAL form (utils.canonical_email), not the literal string. Gmail delivers
|
||||
every dotted / `+tagged` spelling to one mailbox, so approving one spelling always meant approving
|
||||
that mailbox — matching literally only produced the 2026-09-03 lockout, where an approved user's
|
||||
Google sign-in became a silent second access request because OAuth returned the undotted primary.
|
||||
|
||||
One canonical form can match SEVERAL invite rows (prod carries approved pairs for one mailbox,
|
||||
the oldest from 2026-06-16). An approved row among them grants access: the API cannot flip an
|
||||
approved invite to denied (`_decide` only accepts a pending one), so approved-beside-denied means
|
||||
the admin approved that mailbox under a different spelling, not that they revoked it. Revoking is
|
||||
deleting the invite row or suspending the account — both still work, since a deleted row is no
|
||||
longer here to match and suspension is enforced at sign-in.
|
||||
"""
|
||||
if not email:
|
||||
return False
|
||||
email = email.lower()
|
||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||
if inv is not None:
|
||||
return inv.status == "approved"
|
||||
return email in settings.allowed_email_set or email in settings.admin_email_set
|
||||
canon = canonical_email(email)
|
||||
statuses = (
|
||||
db.execute(select(Invite.status).where(Invite.email_canonical == canon)).scalars().all()
|
||||
)
|
||||
if statuses:
|
||||
return "approved" in statuses
|
||||
return canon in env_allowed_canonicals()
|
||||
|
||||
|
||||
def can_activate(db: Session, user: User) -> bool:
|
||||
"""Is this account merely waiting for an admin to finish letting it in?
|
||||
|
||||
True when it is inactive but its address is ALREADY allowed — the shape roadmap UX-25 describes:
|
||||
the admin approved the address, the person signed up afterwards, and nothing connects the two
|
||||
(the Access-requests queue only offers Approve on a still-pending invite).
|
||||
|
||||
Gated on `is_allowed`, the same function the sign-in paths consult, because activating an address
|
||||
that is NOT allowed manufactures an inconsistent account: password sign-in only checks
|
||||
`is_active` and would let them in, while their Google sign-in would be turned away and silently
|
||||
reopened as a fresh access request. Letting in someone with no approved invite is the whitelist
|
||||
box's job — that action says so out loud.
|
||||
|
||||
Lives in core so the admin surface and the /api/me badge count answer from ONE definition; a
|
||||
button that appears and a number that counts must never disagree about who is stuck.
|
||||
"""
|
||||
if user.is_demo or user.is_suspended or user.is_active:
|
||||
return False
|
||||
return is_allowed(db, user.email)
|
||||
|
||||
|
||||
def count_pending_activations(db: Session) -> int:
|
||||
"""How many sign-ups are waiting on an admin click (see can_activate). Drives the admin badge.
|
||||
|
||||
Deliberately NOT "everyone who cannot sign in": an account that has simply not confirmed its
|
||||
address yet is waiting on its owner, not on the admin, and badging that would nag about something
|
||||
the admin did not cause. It is still listed on the admin screen — with a re-send — because the
|
||||
mail may never have arrived.
|
||||
"""
|
||||
# Narrowed in SQL, then decided by can_activate itself — so the number the badge shows and the
|
||||
# button the admin sees cannot drift apart by one of them re-deriving the rule.
|
||||
rows = (
|
||||
db.execute(
|
||||
select(User).where(
|
||||
User.is_demo.is_(False),
|
||||
User.is_suspended.is_(False),
|
||||
User.is_active.is_(False),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return sum(1 for u in rows if can_activate(db, u))
|
||||
|
||||
|
||||
def find_user_by_email(db: Session, email: str) -> User | None:
|
||||
"""The account someone means when they type an address at sign-in, password-reset or
|
||||
verify-resend. None when there isn't exactly one answer.
|
||||
|
||||
Exact spelling wins. Failing that, the address is resolved to its mailbox
|
||||
(utils.canonical_email) — so a password chosen at registration still works when the person types
|
||||
another spelling of their own Gmail, and so the three flows that look an account up by typed
|
||||
address stop being dead ends. Before this, requesting a reset for `xy@gmail.com` on an account
|
||||
registered as `x.y@gmail.com` issued nothing and said nothing: the endpoint answers uniformly by
|
||||
design, so the user got silence and no way in.
|
||||
|
||||
SEVERAL canonical candidates return None rather than a guess. That case is real — prod carries two
|
||||
accounts for one mailbox — and picking one of them for a password sign-in would hand someone the
|
||||
wrong account. The callers all treat None as "no such account", which is the uniform,
|
||||
non-enumerating answer they already gave.
|
||||
"""
|
||||
if not email:
|
||||
return None
|
||||
email = email.strip().lower()
|
||||
exact = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if exact is not None:
|
||||
return exact
|
||||
candidates = (
|
||||
db.execute(select(User).where(User.email_canonical == canonical_email(email)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return candidates[0] if len(candidates) == 1 else None
|
||||
|
||||
|
||||
def get_or_create_demo_user(db: Session) -> User:
|
||||
@@ -119,6 +231,9 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
||||
touch any browser session — each caller handles its own."""
|
||||
email = user.email.lower()
|
||||
user_id = user.id
|
||||
# Read the language off the row while it still exists — the confirmation below is queued as a
|
||||
# background task that runs after this session (and this user) are gone.
|
||||
lang = mailtext.lang_for_user(user)
|
||||
tok = user.token
|
||||
# Both are stored encrypted — decrypt for the revoke call (the access-token fallback was passing
|
||||
# ciphertext, so a token row with no refresh token silently failed to revoke). decrypt_optional so a
|
||||
@@ -130,13 +245,25 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
||||
)
|
||||
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
|
||||
db.execute(delete(User).where(User.id == user_id))
|
||||
db.execute(delete(Invite).where(Invite.email == email))
|
||||
# Every canonically-equal invite goes, not just the literal spelling: is_allowed matches on the
|
||||
# canonical form, so leaving a sibling row behind would keep granting access to a mailbox whose
|
||||
# account was just erased — and prod really does carry approved pairs for one mailbox.
|
||||
#
|
||||
# Unless ANOTHER account still lives on that mailbox — prod carries that too (two user rows for
|
||||
# one Gmail mailbox). Then NO invite goes, not even the literal one: is_allowed matches
|
||||
# canonically, so every row here is what keeps the survivor allowed, and removing any of them
|
||||
# would refuse their next Google sign-in and reopen it as a fresh access request — this epic's
|
||||
# founding bug, caused by its own cleanup. `delete_invite` refuses for exactly this reason; the
|
||||
# user row is already gone by now, so this query sees only the others.
|
||||
canon = canonical_email(email)
|
||||
if db.execute(select(User.id).where(User.email_canonical == canon)).first() is None:
|
||||
db.execute(delete(Invite).where(Invite.email_canonical == canon))
|
||||
db.commit()
|
||||
if google_token:
|
||||
background.add_task(revoke_google_token, google_token)
|
||||
# Confirm the erasure to the (now former) user — GDPR good practice, and covers both the
|
||||
# self-service and admin deletion paths since both funnel through here.
|
||||
background.add_task(email_mod.send_account_deleted, email, operator_contact())
|
||||
background.add_task(email_mod.send_account_deleted, email, lang, operator_contact())
|
||||
|
||||
|
||||
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
|
||||
|
||||
+124
-100
@@ -21,7 +21,7 @@ from email.utils import formatdate
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app import mailtext, sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import User
|
||||
@@ -184,125 +184,149 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
|
||||
return False
|
||||
|
||||
|
||||
def send_access_approved(email: str) -> bool:
|
||||
def _next_step(verify_link: str | None, has_account: bool, hours: int, lang: str) -> str:
|
||||
"""What this person actually has to do next, in the one paragraph that carries the whole flow.
|
||||
|
||||
Three states, because a mail that guesses wrong sends someone looking for a link that isn't
|
||||
coming — or worse, tells them to sign in when their address is still unconfirmed and sign-in will
|
||||
refuse them (E9.5 S4 named that state on screen; the mail must not contradict it).
|
||||
|
||||
The link branch is the point of the C option: in the common ordering — approve first, the person
|
||||
signs up later, or signs up and waits — they would otherwise have to find and open TWO separate
|
||||
messages. One mail, one click.
|
||||
"""
|
||||
if verify_link:
|
||||
return mailtext.text(
|
||||
"next_step.verify", lang, link=verify_link, expiry=mailtext.link_expiry(hours, lang)
|
||||
)
|
||||
if has_account:
|
||||
return mailtext.text("next_step.sign_in", lang, app=settings.app_base)
|
||||
return mailtext.text("next_step.register", lang, app=settings.app_base)
|
||||
|
||||
|
||||
def _reply_contact(lang: str) -> tuple[str | None, str]:
|
||||
"""The admin address to answer to, and the closing line that names it — empty when no admin
|
||||
contact is configured, so the mail never promises a reply nobody will read."""
|
||||
admin = _admin_contact()
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
|
||||
"Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n"
|
||||
f"{settings.app_base}\n\n"
|
||||
"If you weren't expecting this, you can ignore the message.\n\n"
|
||||
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
||||
return admin, (mailtext.text("contact.reply", lang, admin=admin) if admin else "")
|
||||
|
||||
|
||||
def send_access_approved(
|
||||
email: str,
|
||||
lang: str,
|
||||
verify_link: str | None = None,
|
||||
has_account: bool = False,
|
||||
hours: int = 24,
|
||||
) -> bool:
|
||||
"""Answer someone who ASKED for access. Sent automatically on approval, because a person who
|
||||
made a request is owed a reply — unlike the admin-initiated invitation below."""
|
||||
admin, contact = _reply_contact(lang)
|
||||
body = mailtext.text(
|
||||
"access_approved.body",
|
||||
lang,
|
||||
next_step=_next_step(verify_link, has_account, hours, lang),
|
||||
contact=contact,
|
||||
)
|
||||
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
|
||||
return _send([email], mailtext.text("access_approved.subject", lang), body, reply_to=admin)
|
||||
|
||||
|
||||
def send_access_denied(email: str) -> bool:
|
||||
admin = _admin_contact()
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Thanks for your interest in Siftlode. After review, your access request wasn't approved "
|
||||
"this time.\n\n"
|
||||
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
|
||||
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
|
||||
def send_access_invitation(
|
||||
email: str,
|
||||
lang: str,
|
||||
verify_link: str | None = None,
|
||||
has_account: bool = False,
|
||||
hours: int = 24,
|
||||
) -> bool:
|
||||
"""Invite someone the admin let in WITHOUT them asking — sent only when the admin presses send.
|
||||
|
||||
Whitelisting an address is opening a door, not knocking on one: the operator has usually already
|
||||
agreed it with the person, and an address can sit approved for weeks without anyone registering.
|
||||
So adding to the whitelist stays silent and this is a separate, deliberate act. That is also why
|
||||
the closing line offers no deadline — nudging is the one thing this mail must not do.
|
||||
|
||||
Its language is the one place the app has no signal to read: the admin typed this address in, so
|
||||
nobody has ever told us how to address its owner. That is why the send button asks (E9.5 S7).
|
||||
"""
|
||||
admin, contact = _reply_contact(lang)
|
||||
body = mailtext.text(
|
||||
"access_invitation.body",
|
||||
lang,
|
||||
next_step=_next_step(verify_link, has_account, hours, lang),
|
||||
contact=contact,
|
||||
)
|
||||
return _send([email], "About your Siftlode access request", body, reply_to=admin)
|
||||
return _send([email], mailtext.text("access_invitation.subject", lang), body, reply_to=admin)
|
||||
|
||||
|
||||
def send_role_changed(to: str, role: str, operator: str | None) -> bool:
|
||||
line = (
|
||||
"You've been granted admin access on Siftlode — you can now manage settings, the "
|
||||
"scheduler and users."
|
||||
if role == "admin"
|
||||
else "Your admin access on Siftlode has been removed — you're now a regular user."
|
||||
def send_access_denied(email: str, lang: str) -> bool:
|
||||
admin, contact = _reply_contact(lang)
|
||||
body = mailtext.text("access_denied.body", lang, contact=contact)
|
||||
return _send([email], mailtext.text("access_denied.subject", lang), body, reply_to=admin)
|
||||
|
||||
|
||||
def send_role_changed(to: str, lang: str, role: str, operator: str | None) -> bool:
|
||||
line = mailtext.text(
|
||||
"role_changed.granted" if role == "admin" else "role_changed.revoked", lang
|
||||
)
|
||||
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
|
||||
body = "Hi,\n\n" + line + contact + "\n\n— Siftlode"
|
||||
return _send([to], "Your Siftlode role has changed", body, reply_to=operator)
|
||||
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
|
||||
body = mailtext.text("role_changed.body", lang, line=line, contact=contact)
|
||||
return _send([to], mailtext.text("role_changed.subject", lang), body, reply_to=operator)
|
||||
|
||||
|
||||
def send_account_unsuspended(to: str, operator: str | None) -> bool:
|
||||
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Your Siftlode account has been reinstated — the suspension was lifted and you can sign "
|
||||
"in again." + contact + "\n\n— Siftlode"
|
||||
)
|
||||
return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator)
|
||||
def send_account_unsuspended(to: str, lang: str, operator: str | None) -> bool:
|
||||
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
|
||||
body = mailtext.text("account_unsuspended.body", lang, contact=contact)
|
||||
return _send([to], mailtext.text("account_unsuspended.subject", lang), body, reply_to=operator)
|
||||
|
||||
|
||||
def send_account_deleted(to: str, operator: str | None) -> bool:
|
||||
def send_account_deleted(to: str, lang: str, operator: str | None) -> bool:
|
||||
contact = mailtext.text("contact.deleted", lang, operator=operator) if operator else ""
|
||||
body = mailtext.text("account_deleted.body", lang, contact=contact)
|
||||
return _send([to], mailtext.text("account_deleted.subject", lang), body, reply_to=operator)
|
||||
|
||||
|
||||
def send_account_suspended(to: str, lang: str, operator: str | None) -> bool:
|
||||
contact = (
|
||||
f"\n\nIf you didn't request this, contact the Siftlode operator at {operator}."
|
||||
mailtext.text("contact.suspended", lang, operator=operator)
|
||||
if operator
|
||||
else ""
|
||||
else mailtext.text("contact.suspended_unknown", lang)
|
||||
)
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
|
||||
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
|
||||
"Thanks for having used Siftlode." + contact + "\n\n— Siftlode"
|
||||
body = mailtext.text("account_suspended.body", lang, contact=contact)
|
||||
return _send([to], mailtext.text("account_suspended.subject", lang), body, reply_to=operator)
|
||||
|
||||
|
||||
def send_admin_new_request(admins: list[str], lang: str, requester: str) -> bool:
|
||||
"""Tell the admins someone is waiting. One call per language group (see
|
||||
`routes.auth.notify_admins_of_request`) — admins are people with their own UI language, and a
|
||||
single blast to all of them would have to pick one and be wrong for the rest.
|
||||
|
||||
The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The
|
||||
requester address is spelled out (mail clients auto-link both the URL and the address), and the
|
||||
reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the
|
||||
sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.)
|
||||
"""
|
||||
body = mailtext.text(
|
||||
"admin_new_request.body",
|
||||
lang,
|
||||
requester=requester,
|
||||
link=f"{settings.app_base}/?admin=access-requests",
|
||||
)
|
||||
return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator)
|
||||
subject = mailtext.text("admin_new_request.subject", lang, requester=requester)
|
||||
return _send(admins, subject, body, reply_to=requester)
|
||||
|
||||
|
||||
def send_account_suspended(to: str, operator: str | None) -> bool:
|
||||
contact = (
|
||||
f"If you think this is a mistake or have questions, contact the Siftlode operator "
|
||||
f"at {operator}.\n\n"
|
||||
if operator
|
||||
else "If you think this is a mistake, please contact the Siftlode operator.\n\n"
|
||||
def send_verify_email(to: str, lang: str, link: str, hours: int = 24) -> bool:
|
||||
# The body says what the link's rules are. The reset mail always has; this one left the reader to
|
||||
# discover both by meeting a dead link, which is precisely the silence this epic removes.
|
||||
body = mailtext.text(
|
||||
"verify_email.body", lang, link=link, expiry=mailtext.link_expiry(hours, lang)
|
||||
)
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"A sign-in to your Siftlode account was just attempted, but the account is currently "
|
||||
"suspended, so access was denied.\n\n" + contact + "— Siftlode"
|
||||
)
|
||||
return _send([to], "Your Siftlode account is suspended", body, reply_to=operator)
|
||||
return _send([to], mailtext.text("verify_email.subject", lang), body)
|
||||
|
||||
|
||||
def send_admin_new_request(admins: list[str], requester: str) -> bool:
|
||||
# The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The
|
||||
# requester address is spelled out (mail clients auto-link both the URL and the address), and the
|
||||
# reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the
|
||||
# sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.)
|
||||
approve_url = f"{settings.app_base}/?admin=access-requests"
|
||||
body = (
|
||||
f"{requester} just requested access to Siftlode.\n\n"
|
||||
f"Approve or deny it here:\n{approve_url}\n"
|
||||
f"(or in Siftlode: open Users → Access requests)\n\n"
|
||||
f"Requester's email: {requester}\n"
|
||||
f"Replying to this message goes straight to the requester (it's the Reply-To address).\n"
|
||||
)
|
||||
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
|
||||
def send_password_reset(to: str, lang: str, link: str) -> bool:
|
||||
body = mailtext.text("password_reset.body", lang, link=link)
|
||||
return _send([to], mailtext.text("password_reset.subject", lang), body)
|
||||
|
||||
|
||||
def send_verify_email(to: str, link: str) -> bool:
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Confirm your email to finish creating your Siftlode account:\n\n"
|
||||
f"{link}\n\n"
|
||||
"If you didn't sign up, you can ignore this message.\n\n"
|
||||
"— Siftlode"
|
||||
)
|
||||
return _send([to], "Confirm your Siftlode email", body)
|
||||
|
||||
|
||||
def send_password_reset(to: str, link: str) -> bool:
|
||||
body = (
|
||||
"Hi,\n\n"
|
||||
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
|
||||
f"{link}\n\n"
|
||||
"If you didn't request this, you can ignore this message — your password is unchanged.\n\n"
|
||||
"— Siftlode"
|
||||
)
|
||||
return _send([to], "Reset your Siftlode password", body)
|
||||
|
||||
|
||||
def send_test(to: str) -> bool:
|
||||
body = (
|
||||
"This is a test email from Siftlode.\n\n"
|
||||
"If you're reading this, your SMTP settings work.\n\n"
|
||||
"— Siftlode"
|
||||
)
|
||||
return _send([to], "Siftlode SMTP test", body)
|
||||
def send_test(to: str, lang: str) -> bool:
|
||||
return _send([to], mailtext.text("test.subject", lang), mailtext.text("test.body", lang))
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""Bilingual text for every outbound mail, and the one resolver that decides which language a
|
||||
recipient gets (E9.5 S7).
|
||||
|
||||
Why a catalog instead of literals in `email.py`: HU/EN parity is a house rule for every user-facing
|
||||
string, and a mail is the one user-facing surface the reader CANNOT re-render by switching the UI
|
||||
language — it arrives once, in whatever language we picked, and stays that way. `test_mail_language.py`
|
||||
enforces that every key carries both languages *and* the same `{placeholders}`, so a half-translated
|
||||
mail fails the gate instead of reaching a person.
|
||||
|
||||
The language is resolved by the REQUEST handler and handed to `send_*` as a plain code. It is never
|
||||
resolved inside the mail: every send runs as a `BackgroundTasks` job, after the request's session is
|
||||
gone — the same reason `_welcome_mail_parts` computes its link in the handler and not in the task.
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Invite, User
|
||||
from app.utils import canonical_email
|
||||
|
||||
SUPPORTED: tuple[str, ...] = ("en", "hu")
|
||||
DEFAULT = "en"
|
||||
|
||||
|
||||
def normalize(lang: str | None) -> str:
|
||||
"""Coerce anything to a language we can actually render. Storage is not a guarantee: a
|
||||
`preferences.language` written by an older build (or a locale we no longer ship) must degrade to
|
||||
English rather than raise inside a background task nobody is watching."""
|
||||
return lang if lang in SUPPORTED else DEFAULT
|
||||
|
||||
|
||||
def text(key: str, lang: str | None, **values: object) -> str:
|
||||
"""Render one catalog entry. Unknown languages fall back to English (see `normalize`); an
|
||||
unknown KEY raises, because that is a programming error the tests must surface, not something to
|
||||
paper over with an empty line in someone's inbox."""
|
||||
return MAIL_TEXT[key][normalize(lang)].format(**values)
|
||||
|
||||
|
||||
def link_expiry(hours: int, lang: str) -> str:
|
||||
"""How long a link lasts, in a unit a reader converts on sight. A whole number of days is
|
||||
said in days — the approval mail's token lives seven of them, and "168 hours" is not an answer
|
||||
to "how long have I got". A single day stays "24 hours", which is how everyone writes it."""
|
||||
if hours >= 48 and hours % 24 == 0:
|
||||
return text("duration.days", lang, n=hours // 24)
|
||||
return text("duration.hours", lang, n=hours)
|
||||
|
||||
|
||||
def lang_for_user(user: User | None) -> str:
|
||||
"""The language a signed-up recipient gets: the one they picked in the app."""
|
||||
prefs = (user.preferences or {}) if user is not None else {}
|
||||
return normalize(prefs.get("language"))
|
||||
|
||||
|
||||
def lang_for_email(db: Session, email: str) -> str:
|
||||
"""The language for an address that may not have an account yet: their own account preference,
|
||||
else the language they filled our access-request form in, else English.
|
||||
|
||||
Matched CANONICALLY like every other identity lookup in this epic, and it walks past rows that
|
||||
carry no usable code instead of stopping at the first one — prod really does hold two accounts
|
||||
for a single mailbox, and only one of them may have ever picked a language.
|
||||
"""
|
||||
canon = canonical_email(email)
|
||||
for prefs in (
|
||||
db.execute(select(User.preferences).where(User.email_canonical == canon).order_by(User.id))
|
||||
.scalars()
|
||||
.all()
|
||||
):
|
||||
code = (prefs or {}).get("language")
|
||||
if code in SUPPORTED:
|
||||
return code
|
||||
for code in (
|
||||
db.execute(select(Invite.lang).where(Invite.email_canonical == canon).order_by(Invite.id))
|
||||
.scalars()
|
||||
.all()
|
||||
):
|
||||
if code in SUPPORTED:
|
||||
return code
|
||||
return DEFAULT
|
||||
|
||||
|
||||
# The brand line ("— Siftlode") and the product name are deliberately NOT catalog entries: they are
|
||||
# a name, not a translated string, and giving them keys would invite someone to "translate" them.
|
||||
MAIL_TEXT: dict[str, dict[str, str]] = {
|
||||
# ── How long a link lasts ───────────────────────────────────────────────────────────────
|
||||
# Rendered, not interpolated as a raw hour count: the approval mail's token lives seven days
|
||||
# (E9.5 S6), and "expires in 168 hours" is a unit no reader converts.
|
||||
"duration.hours": {"en": "{n} hours", "hu": "{n} óra"},
|
||||
"duration.days": {"en": "{n} days", "hu": "{n} nap"},
|
||||
# ── Shared closing fragments ────────────────────────────────────────────────────────────────
|
||||
"contact.reply": {
|
||||
"en": "\n\nQuestions? Just reply to this email ({admin}).",
|
||||
"hu": "\n\nKérdésed van? Csak válaszolj erre a levélre ({admin}).",
|
||||
},
|
||||
"contact.operator": {
|
||||
"en": "\n\nQuestions? Contact the operator at {operator}.",
|
||||
"hu": "\n\nKérdésed van? Írj az üzemeltetőnek: {operator}.",
|
||||
},
|
||||
"contact.deleted": {
|
||||
"en": "\n\nIf you didn't request this, contact the Siftlode operator at {operator}.",
|
||||
"hu": "\n\nHa nem te kérted, írj a Siftlode üzemeltetőjének: {operator}.",
|
||||
},
|
||||
"contact.suspended": {
|
||||
"en": (
|
||||
"If you think this is a mistake or have questions, contact the Siftlode operator "
|
||||
"at {operator}.\n\n"
|
||||
),
|
||||
"hu": (
|
||||
"Ha szerinted ez tévedés, vagy kérdésed van, írj a Siftlode üzemeltetőjének: "
|
||||
"{operator}.\n\n"
|
||||
),
|
||||
},
|
||||
"contact.suspended_unknown": {
|
||||
"en": "If you think this is a mistake, please contact the Siftlode operator.\n\n",
|
||||
"hu": "Ha szerinted ez tévedés, keresd a Siftlode üzemeltetőjét.\n\n",
|
||||
},
|
||||
# ── The one paragraph that carries the whole onboarding flow (see email._next_step) ──────────
|
||||
"next_step.verify": {
|
||||
"en": (
|
||||
"You already started an account, so there is just one step left. Confirm your email "
|
||||
"address and you're in:\n\n"
|
||||
"{link}\n\n"
|
||||
"The link works once and expires in {expiry}. If it does, you can ask for a fresh "
|
||||
"one from the sign-in page."
|
||||
),
|
||||
"hu": (
|
||||
"A fiókodat már elkezdted, úgyhogy egyetlen lépés maradt. Erősítsd meg az "
|
||||
"e-mail-címed, és bent is vagy:\n\n"
|
||||
"{link}\n\n"
|
||||
"A link egyszer használható, és {expiry} múlva lejár. Ha lejárt, a belépési oldalon "
|
||||
"kérhetsz újat."
|
||||
),
|
||||
},
|
||||
"next_step.sign_in": {
|
||||
"en": "Your account is ready — just sign in:\n\n{app}",
|
||||
"hu": "A fiókod készen áll — csak lépj be:\n\n{app}",
|
||||
},
|
||||
"next_step.register": {
|
||||
"en": (
|
||||
"Create your account whenever you feel like it:\n\n"
|
||||
"{app}\n\n"
|
||||
"We'll send you a confirmation link to finish, and then you're in."
|
||||
),
|
||||
"hu": (
|
||||
"Hozd létre a fiókod, amikor kedved tartja:\n\n"
|
||||
"{app}\n\n"
|
||||
"Küldünk egy megerősítő linket a befejezéshez, és már bent is vagy."
|
||||
),
|
||||
},
|
||||
# ── Access decision ─────────────────────────────────────────────────────────────────────────
|
||||
"access_approved.subject": {
|
||||
"en": "You're in — your Siftlode access is approved",
|
||||
"hu": "Bent vagy — jóváhagytuk a Siftlode-hozzáférésed",
|
||||
},
|
||||
"access_approved.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
|
||||
"{next_step}\n\n"
|
||||
"If you weren't expecting this, you can ignore the message.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Jóváhagytuk a Siftlode-hozzáférési kérelmed — üdv a fedélzeten.\n\n"
|
||||
"{next_step}\n\n"
|
||||
"Ha nem számítottál erre, nyugodtan hagyd figyelmen kívül ezt a levelet.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
},
|
||||
"access_invitation.subject": {
|
||||
"en": "You're invited to Siftlode",
|
||||
"hu": "Meghívtak a Siftlode-ra",
|
||||
},
|
||||
"access_invitation.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Someone has opened up Siftlode for you — a quiet, self-hosted reader for your YouTube "
|
||||
"subscriptions. Every upload from the channels you follow lands in one feed you can "
|
||||
"actually filter and sort, with no algorithm deciding what you see, and no Shorts or "
|
||||
"livestreams unless you want them.\n\n"
|
||||
"{next_step}\n\n"
|
||||
"No rush — the door stays open.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Valaki megnyitotta előtted a Siftlode-ot — ez egy csendes, saját üzemeltetésű olvasó "
|
||||
"a YouTube-feliratkozásaidhoz. A követett csatornák minden feltöltése egyetlen "
|
||||
"hírfolyamba érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
|
||||
"eldönti, mit látsz, és nincs Shorts vagy élő adás, hacsak nem kéred.\n\n"
|
||||
"{next_step}\n\n"
|
||||
"Nem sürget semmi — az ajtó nyitva marad.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
},
|
||||
"access_denied.subject": {
|
||||
"en": "About your Siftlode access request",
|
||||
"hu": "A Siftlode-hozzáférési kérelmedről",
|
||||
},
|
||||
"access_denied.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Thanks for your interest in Siftlode. After review, your access request wasn't "
|
||||
"approved this time.\n\n"
|
||||
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Köszönjük az érdeklődésed a Siftlode iránt. Átnéztük a kérelmed, de most nem hagytuk "
|
||||
"jóvá.\n\n"
|
||||
"Ha szerinted ez tévedés, válaszolj nyugodtan erre a levélre.\n\n"
|
||||
"— Siftlode{contact}"
|
||||
),
|
||||
},
|
||||
# ── Account lifecycle ───────────────────────────────────────────────────────────────────────
|
||||
"role_changed.subject": {
|
||||
"en": "Your Siftlode role has changed",
|
||||
"hu": "Megváltozott a szereped a Siftlode-on",
|
||||
},
|
||||
"role_changed.granted": {
|
||||
"en": (
|
||||
"You've been granted admin access on Siftlode — you can now manage settings, the "
|
||||
"scheduler and users."
|
||||
),
|
||||
"hu": (
|
||||
"Admin jogot kaptál a Siftlode-on — mostantól kezelheted a beállításokat, az ütemezőt "
|
||||
"és a felhasználókat."
|
||||
),
|
||||
},
|
||||
"role_changed.revoked": {
|
||||
"en": "Your admin access on Siftlode has been removed — you're now a regular user.",
|
||||
"hu": "Elvettük az admin jogod a Siftlode-on — mostantól sima felhasználó vagy.",
|
||||
},
|
||||
"role_changed.body": {
|
||||
"en": "Hi,\n\n{line}{contact}\n\n— Siftlode",
|
||||
"hu": "Szia,\n\n{line}{contact}\n\n— Siftlode",
|
||||
},
|
||||
"account_unsuspended.subject": {
|
||||
"en": "Your Siftlode account has been reinstated",
|
||||
"hu": "Visszaállítottuk a Siftlode-fiókod",
|
||||
},
|
||||
"account_unsuspended.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Your Siftlode account has been reinstated — the suspension was lifted and you can "
|
||||
"sign in again.{contact}\n\n— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Visszaállítottuk a Siftlode-fiókod — a felfüggesztést feloldottuk, újra be tudsz "
|
||||
"lépni.{contact}\n\n— Siftlode"
|
||||
),
|
||||
},
|
||||
"account_deleted.subject": {
|
||||
"en": "Your Siftlode account has been deleted",
|
||||
"hu": "Töröltük a Siftlode-fiókod",
|
||||
},
|
||||
"account_deleted.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
|
||||
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
|
||||
"Thanks for having used Siftlode.{contact}\n\n— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"A Siftlode-fiókod és minden hozzá tartozó adat — feliratkozások, címkék, megtekintési "
|
||||
"előzmények, lejátszási listák és beállítások — véglegesen törölve. Semmit nem őrzünk "
|
||||
"meg.\n\n"
|
||||
"Köszönjük, hogy használtad a Siftlode-ot.{contact}\n\n— Siftlode"
|
||||
),
|
||||
},
|
||||
"account_suspended.subject": {
|
||||
"en": "Your Siftlode account is suspended",
|
||||
"hu": "A Siftlode-fiókod fel van függesztve",
|
||||
},
|
||||
"account_suspended.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"A sign-in to your Siftlode account was just attempted, but the account is currently "
|
||||
"suspended, so access was denied.\n\n{contact}— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Valaki most próbált belépni a Siftlode-fiókodba, de a fiók jelenleg fel van "
|
||||
"függesztve, ezért a belépést elutasítottuk.\n\n{contact}— Siftlode"
|
||||
),
|
||||
},
|
||||
# ── To the admins ───────────────────────────────────────────────────────────────────────────
|
||||
"admin_new_request.subject": {
|
||||
"en": "Siftlode access request from {requester}",
|
||||
"hu": "Siftlode hozzáférési kérelem: {requester}",
|
||||
},
|
||||
"admin_new_request.body": {
|
||||
"en": (
|
||||
"{requester} just requested access to Siftlode.\n\n"
|
||||
"Approve or deny it here:\n{link}\n"
|
||||
"(or in Siftlode: open Users → Access requests)\n\n"
|
||||
"Requester's email: {requester}\n"
|
||||
"Replying to this message goes straight to the requester (it's the Reply-To address).\n"
|
||||
),
|
||||
"hu": (
|
||||
"{requester} most kért hozzáférést a Siftlode-hoz.\n\n"
|
||||
"Itt hagyhatod jóvá vagy utasíthatod el:\n{link}\n"
|
||||
"(vagy a Siftlode-ban: Felhasználók → Hozzáférési kérések)\n\n"
|
||||
"A kérelmező e-mail-címe: {requester}\n"
|
||||
"Ha erre a levélre válaszolsz, az egyenesen a kérelmezőhöz megy (ő a Reply-To cím).\n"
|
||||
),
|
||||
},
|
||||
# ── Link mails ──────────────────────────────────────────────────────────────────────────────
|
||||
"verify_email.subject": {
|
||||
"en": "Confirm your Siftlode email",
|
||||
"hu": "Erősítsd meg a Siftlode e-mail-címed",
|
||||
},
|
||||
"verify_email.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Confirm your email to finish creating your Siftlode account:\n\n"
|
||||
"{link}\n\n"
|
||||
"The link works once and expires in {expiry}. If it does, you can ask for a fresh "
|
||||
"one from the sign-in page.\n\n"
|
||||
"If you didn't sign up, you can ignore this message.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Erősítsd meg az e-mail-címed, hogy elkészüljön a Siftlode-fiókod:\n\n"
|
||||
"{link}\n\n"
|
||||
"A link egyszer használható, és {expiry} múlva lejár. Ha lejárt, a belépési oldalon "
|
||||
"kérhetsz újat.\n\n"
|
||||
"Ha nem te regisztráltál, nyugodtan hagyd figyelmen kívül ezt a levelet.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
},
|
||||
"password_reset.subject": {
|
||||
"en": "Reset your Siftlode password",
|
||||
"hu": "Állíts be új Siftlode-jelszót",
|
||||
},
|
||||
"password_reset.body": {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
|
||||
"{link}\n\n"
|
||||
"If you didn't request this, you can ignore this message — your password is "
|
||||
"unchanged.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Szia,\n\n"
|
||||
"Ezzel a linkkel állíthatsz be új Siftlode-jelszót (egy óra múlva lejár):\n\n"
|
||||
"{link}\n\n"
|
||||
"Ha nem te kérted, hagyd figyelmen kívül ezt a levelet — a jelszavad változatlan.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
},
|
||||
"test.subject": {
|
||||
"en": "Siftlode SMTP test",
|
||||
"hu": "Siftlode SMTP-teszt",
|
||||
},
|
||||
"test.body": {
|
||||
"en": (
|
||||
"This is a test email from Siftlode.\n\n"
|
||||
"If you're reading this, your SMTP settings work.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
"hu": (
|
||||
"Ez egy teszt e-mail a Siftlode-tól.\n\n"
|
||||
"Ha ezt olvasod, az SMTP-beállításaid működnek.\n\n"
|
||||
"— Siftlode"
|
||||
),
|
||||
},
|
||||
}
|
||||
+46
-4
@@ -17,9 +17,10 @@ from sqlalchemy import (
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
|
||||
from app.db import Base
|
||||
from app.utils import canonical_email
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
@@ -29,6 +30,21 @@ class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class CanonicalEmailMixin:
|
||||
"""Keeps `email_canonical` derived from `email` on every write.
|
||||
|
||||
The invariant — "identity is matched on the canonical form, mail is sent to the typed one" —
|
||||
lives HERE, in code, instead of in a comment every write site has to remember. That distinction
|
||||
is what the 2026-09-03 lockout was made of: one gate lowercased, nothing else did, and an
|
||||
approved user's Google sign-in silently became a second access request.
|
||||
"""
|
||||
|
||||
@validates("email")
|
||||
def _derive_email_canonical(self, _key: str, value: str | None) -> str | None:
|
||||
self.email_canonical = canonical_email(value)
|
||||
return value
|
||||
|
||||
|
||||
class UpdatedAtMixin:
|
||||
"""Adds an `updated_at` that server-defaults on insert and bumps on every UPDATE."""
|
||||
|
||||
@@ -37,7 +53,7 @@ class UpdatedAtMixin:
|
||||
)
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
class User(Base, TimestampMixin, CanonicalEmailMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
@@ -45,9 +61,21 @@ class User(Base, TimestampMixin):
|
||||
# multiple NULLs under a unique index). Set on Google sign-in / in-app linking.
|
||||
google_sub: Mapped[str | None] = mapped_column(String(64), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
# The same mailbox, in the form used to MATCH identities (utils.canonical_email) — Gmail dots and
|
||||
# `+tags` folded away. Kept by the validator below so no write site can forget it. Deliberately
|
||||
# NOT unique: prod already carries two rows for one mailbox (a password registration and the
|
||||
# Google sign-in that failed to find it), and a migration must not merge accounts on its own.
|
||||
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
|
||||
# The address Google asserted for this account, recorded at sign-in / link time. NOT the same
|
||||
# thing as `email`: since E9.5 S3 a Google sign-in no longer overwrites the address the user
|
||||
# registered with, so a deliberate `me+siftlode@gmail.com` survives — and this is where the other
|
||||
# half of the truth lives, for the UI to show when the two differ. NULL until the next Google
|
||||
# sign-in for accounts linked before this column existed.
|
||||
google_email: Mapped[str | None] = mapped_column(String(320))
|
||||
# argon2id hash for email+password login; NULL for Google-only / demo accounts.
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||
# When SEC-10 dropped a preset password (see auth.revoke_preset_password_if_unverified). Without
|
||||
# When SEC-10 dropped a preset password on Google adoption (see the single caller of
|
||||
# auth.revoke_preset_password_if_unverified; admin approval no longer revokes — E9.5 S1). Without
|
||||
# this trace a revoked account was indistinguishable from a Google-only one, so it failed the
|
||||
# password-reset eligibility check and could never be recovered — it could neither sign in nor
|
||||
# reset. Set = "had a password, lost it to the guard" -> reset-eligible. Cleared whenever a
|
||||
@@ -120,7 +148,7 @@ class AuthToken(Base, TimestampMixin):
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Invite(Base):
|
||||
class Invite(Base, CanonicalEmailMixin):
|
||||
"""Access-request / whitelist row. Source of truth for who may sign in; the env
|
||||
ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed)."""
|
||||
|
||||
@@ -128,6 +156,9 @@ class Invite(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
# See User.email_canonical. Not unique here either: prod has approved invites for both spellings
|
||||
# of the same mailbox, dating back to 2026-06-16.
|
||||
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
|
||||
# pending | approved | denied
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending", server_default="pending")
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -136,6 +167,17 @@ class Invite(Base):
|
||||
)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
||||
# When the admin last SENT this person an invitation from the whitelist row. Adding an address
|
||||
# stays silent — it opens a door, it does not knock — so telling them is a separate, deliberate
|
||||
# act, and this is what stops it happening twice to a real person.
|
||||
invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# The language this person asked us in ("en"/"hu"), captured when the request was made: the page
|
||||
# their form was on, or the locale Google reported for a refused sign-in. NULL for a row the
|
||||
# admin whitelisted by hand — nobody has told us anything about that mailbox, which is exactly
|
||||
# why the invitation send asks (E9.5 S7). Captured rather than derived for the same reason
|
||||
# `preferences.language` and `google_email` are: the signal exists only in that one moment and
|
||||
# cannot be backfilled afterwards.
|
||||
lang: Mapped[str | None] = mapped_column(String(8))
|
||||
|
||||
|
||||
class DemoWhitelist(Base, TimestampMixin):
|
||||
|
||||
+331
-25
@@ -6,20 +6,24 @@ from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
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.config import settings
|
||||
from app.db import get_db
|
||||
from app.demo import reset_demo_state
|
||||
from app.models import (
|
||||
@@ -27,29 +31,39 @@ from app.models import (
|
||||
Invite,
|
||||
User,
|
||||
)
|
||||
from app.routes.auth import revoke_preset_password_if_unverified
|
||||
from app.utils import iso, valid_email
|
||||
from app.routes._common import get_or_404
|
||||
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"])
|
||||
|
||||
|
||||
def _accounts_for(db: Session, emails: Iterable[str]) -> set[str]:
|
||||
"""Which of these addresses already have an account. 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."""
|
||||
wanted = {e.lower() for e in emails if e}
|
||||
"""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).where(User.email.in_(wanted))).scalars()
|
||||
return {e.lower() for e in rows if e}
|
||||
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."""
|
||||
email = (email or "").lower()
|
||||
return email in settings.allowed_email_set or email in settings.admin_email_set
|
||||
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:
|
||||
@@ -61,11 +75,19 @@ 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
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -83,18 +105,64 @@ def list_invites(
|
||||
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=(i.email or "").lower() in accounts) 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)."""
|
||||
user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
|
||||
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:
|
||||
# SEC-10: same guard as the Google-adoption path — approving an account whose email was never
|
||||
# verified must not hand a working login to a password set on it by whoever registered the
|
||||
# address (a pre-emption). Shared single-source helper so the two paths can't drift.
|
||||
revoke_preset_password_if_unverified(user)
|
||||
user.is_active = True
|
||||
db.commit()
|
||||
|
||||
@@ -133,7 +201,12 @@ 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)
|
||||
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])))
|
||||
|
||||
|
||||
@@ -147,7 +220,9 @@ def deny_invite(
|
||||
# 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)
|
||||
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])))
|
||||
|
||||
|
||||
@@ -165,7 +240,16 @@ def add_invite(
|
||||
email = body.email.strip().lower()
|
||||
if not valid_email(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||
# 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)
|
||||
@@ -180,6 +264,83 @@ def add_invite(
|
||||
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,
|
||||
@@ -293,7 +454,13 @@ def set_user_role(
|
||||
target.role = role
|
||||
db.commit()
|
||||
if changed:
|
||||
background.add_task(email_mod.send_role_changed, target.email, role, operator_contact())
|
||||
background.add_task(
|
||||
email_mod.send_role_changed,
|
||||
target.email,
|
||||
mailtext.lang_for_user(target),
|
||||
role,
|
||||
operator_contact(),
|
||||
)
|
||||
return _serialize_user(target)
|
||||
|
||||
|
||||
@@ -342,7 +509,12 @@ def set_user_suspended(
|
||||
target.is_suspended = suspended
|
||||
db.commit()
|
||||
if was_suspended and not suspended:
|
||||
background.add_task(email_mod.send_account_unsuspended, target.email, operator_contact())
|
||||
background.add_task(
|
||||
email_mod.send_account_unsuspended,
|
||||
target.email,
|
||||
mailtext.lang_for_user(target),
|
||||
operator_contact(),
|
||||
)
|
||||
return _serialize_user(target)
|
||||
|
||||
|
||||
@@ -387,6 +559,140 @@ def admin_delete_user(
|
||||
# --- 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.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,
|
||||
|
||||
+426
-108
@@ -6,6 +6,8 @@ helpers) stays in app/auth.py, imported below."""
|
||||
import ipaddress
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import NamedTuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||
@@ -16,6 +18,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import sysconfig
|
||||
from app.auth import (
|
||||
_YOUTUBE_SCOPES,
|
||||
@@ -25,6 +28,8 @@ from app.auth import (
|
||||
READ_SCOPES,
|
||||
WRITE_SCOPES,
|
||||
current_user,
|
||||
env_admin_canonicals,
|
||||
find_user_by_email,
|
||||
get_or_create_demo_user,
|
||||
google_enabled,
|
||||
is_allowed,
|
||||
@@ -37,9 +42,15 @@ from app.db import SessionLocal, get_db
|
||||
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.security import encrypt, hash_password, hash_token, verify_password
|
||||
from app.utils import valid_email
|
||||
from app.utils import canonical_email, valid_email
|
||||
|
||||
VERIFY_TTL = timedelta(hours=24)
|
||||
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
|
||||
# waiting for. Nobody sits refreshing their inbox for an approval — it can easily be read on Monday
|
||||
# after a weekend, and if it has expired by then the one-mail-one-click promise is spent and they are
|
||||
# back to two messages. The risk is small: this token only ever confirms an address. It signs nobody
|
||||
# in and sets no password.
|
||||
APPROVAL_VERIFY_TTL = timedelta(days=7)
|
||||
RESET_TTL = timedelta(hours=1)
|
||||
|
||||
_DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
||||
@@ -47,17 +58,27 @@ _DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
||||
_register_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
|
||||
_reset_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
# Separate from _reset_limiter on purpose: opening a link must not eat the budget for REQUESTING one.
|
||||
_reset_check_limiter = RateLimiter(max_events=10, window_seconds=300)
|
||||
_verify_resend_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_request_access_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
|
||||
|
||||
|
||||
def _notify_suspended(background: BackgroundTasks, email: str) -> None:
|
||||
def _notify_suspended(background: BackgroundTasks, user: User) -> None:
|
||||
"""Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid)
|
||||
sign-in was just blocked. Gated by verified credentials at the call site, so this can't be
|
||||
triggered by someone who doesn't control the account."""
|
||||
if _suspend_email_limiter.allow(email):
|
||||
background.add_task(email_mod.send_account_suspended, email, operator_contact())
|
||||
triggered by someone who doesn't control the account.
|
||||
|
||||
Takes the user rather than the address because the mail needs their language, and the send runs
|
||||
as a background task — after this request's session, and the row, are out of reach."""
|
||||
if _suspend_email_limiter.allow(user.email):
|
||||
background.add_task(
|
||||
email_mod.send_account_suspended,
|
||||
user.email,
|
||||
mailtext.lang_for_user(user),
|
||||
operator_contact(),
|
||||
)
|
||||
|
||||
|
||||
_demo_limiter = RateLimiter(max_events=5, window_seconds=60)
|
||||
@@ -90,18 +111,43 @@ def google_oauth(db: Session):
|
||||
return _oauth.google
|
||||
|
||||
|
||||
def admin_notify_emails(db: Session) -> list[str]:
|
||||
"""Who to email about admin events (e.g. a new access request). The ACTUAL admins — every active,
|
||||
non-suspended `role=admin` user — UNIONED with the env ADMIN_EMAILS bootstrap list. Using the DB
|
||||
roles means an admin promoted through the UI is notified too (env alone would miss them, yet they
|
||||
CAN approve requests — the approve UI is role-gated, so the notify set must match); the env union
|
||||
keeps a freshly-seeded instance working before any DB admin exists."""
|
||||
db_admins = db.execute(
|
||||
select(User.email).where(
|
||||
def admin_notify_groups(db: Session) -> dict[str, list[str]]:
|
||||
"""Who to email about admin events (e.g. a new access request), grouped by the language each of
|
||||
them reads. The ACTUAL admins — every active, non-suspended `role=admin` user — UNIONED with the
|
||||
env ADMIN_EMAILS bootstrap list. Using the DB roles means an admin promoted through the UI is
|
||||
notified too (env alone would miss them, yet they CAN approve requests — the approve UI is
|
||||
role-gated, so the notify set must match); the env union keeps a freshly-seeded instance working
|
||||
before any DB admin exists.
|
||||
|
||||
Grouped rather than one blast to everyone, because these recipients are people with their own UI
|
||||
language: a single message would have to pick one of them and be wrong for the rest. An env
|
||||
address with no admin account still gets looked up, so an operator listed in ADMIN_EMAILS whose
|
||||
account is (say) suspended is still written to in the language they chose."""
|
||||
rows = db.execute(
|
||||
select(User.email, User.preferences).where(
|
||||
User.role == "admin", User.is_active.is_(True), User.is_suspended.is_(False)
|
||||
)
|
||||
).scalars()
|
||||
return sorted({e.lower() for e in db_admins if e} | settings.admin_email_set)
|
||||
).all()
|
||||
by_email = {e.lower(): mailtext.normalize((p or {}).get("language")) for e, p in rows if e}
|
||||
for email in settings.admin_email_set:
|
||||
if email not in by_email:
|
||||
by_email[email] = mailtext.lang_for_email(db, email)
|
||||
groups: dict[str, list[str]] = {}
|
||||
for email in sorted(by_email):
|
||||
groups.setdefault(by_email[email], []).append(email)
|
||||
return groups
|
||||
|
||||
|
||||
def notify_admins_of_request(
|
||||
db: Session, background: BackgroundTasks | None, requester: str
|
||||
) -> None:
|
||||
"""Tell every admin that someone is waiting — one mail per language group. Sends inline when
|
||||
there is no BackgroundTasks to hand (the registration path already runs outside a response)."""
|
||||
for lang, recipients in admin_notify_groups(db).items():
|
||||
if background is None:
|
||||
email_mod.send_admin_new_request(recipients, lang, requester)
|
||||
else:
|
||||
background.add_task(email_mod.send_admin_new_request, recipients, lang, requester)
|
||||
|
||||
|
||||
def is_demo_allowed(db: Session, email: str) -> bool:
|
||||
@@ -147,14 +193,64 @@ def _client_ip(request: Request) -> str:
|
||||
return peer
|
||||
|
||||
|
||||
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||
"""Idempotently record an access request. Returns the Invite if a *new* pending row
|
||||
was created (so the caller can notify admins), else None for already-known emails."""
|
||||
class InviteRequest(NamedTuple):
|
||||
"""What an access request settled on.
|
||||
|
||||
`invite` is the row the request now lives under — which is NOT always the address that was just
|
||||
typed: one Gmail mailbox spelled two ways settles on whichever row already exists, and that row's
|
||||
address is where mail will go. Callers that tell the user what happened need it (E9.5 S4).
|
||||
`is_new` is the old boolean return in a name: true only when a row was created or a denied one
|
||||
reopened, i.e. exactly when admins should hear about it. A repeat request must not re-spam them.
|
||||
"""
|
||||
|
||||
invite: Invite | None
|
||||
is_new: bool
|
||||
|
||||
|
||||
def upsert_pending_invite(db: Session, email: str, lang: str | None = None) -> InviteRequest:
|
||||
"""Idempotently record an access request, and the language it was made in.
|
||||
|
||||
"Already known" is decided on the CANONICAL form: `npeter83.temu@` and `npeter83temu@` are one
|
||||
Gmail mailbox, and treating them as two is what put a second, silent access request in the admin
|
||||
queue on 2026-09-03 for an already-approved user. An approved sibling wins — re-requesting must
|
||||
not reopen a decision that has already been made for that mailbox. The row is still STORED under
|
||||
the address as typed, because that is where mail has to go.
|
||||
|
||||
`lang` is the language of the surface the request came from — the sign-in page, or the locale
|
||||
Google reported for a refused sign-in. The latest one wins: someone who asks again from a
|
||||
Hungarian page is telling us something newer than last time. Anything unsupported is ignored
|
||||
rather than stored, so a forged body cannot put junk in the column.
|
||||
|
||||
The commit contract is unchanged: this still commits ONLY when it writes something. Callers rely
|
||||
on that — `_register_account` persists its own flushed user precisely because a no-op here
|
||||
commits nothing (E9.5 S6).
|
||||
"""
|
||||
email = (email or "").lower()
|
||||
if not valid_email(email):
|
||||
return None
|
||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||
return InviteRequest(None, False)
|
||||
lang = lang if lang in mailtext.SUPPORTED else None
|
||||
canon = canonical_email(email)
|
||||
rows = (
|
||||
db.execute(select(Invite).where(Invite.email_canonical == canon).order_by(Invite.id))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
# An approved row wins, so a denied duplicate can't mask an approval. Then a row that is not
|
||||
# DENIED, preferring the exact spelling — a denied sibling must never be picked while a pending
|
||||
# row exists, or the reopen branch below would fire and put one person in the admin's queue
|
||||
# twice. Only when every row is denied does the spelling decide which one reopens.
|
||||
undecided = [r for r in rows if r.status != "denied"]
|
||||
inv = (
|
||||
next((r for r in rows if r.status == "approved"), None)
|
||||
or next((r for r in undecided if r.email == email), None)
|
||||
or next(iter(undecided), None)
|
||||
or next((r for r in rows if r.email == email), None)
|
||||
or next(iter(rows), None)
|
||||
)
|
||||
if inv is not None:
|
||||
relearned = lang is not None and inv.lang != lang
|
||||
if relearned:
|
||||
inv.lang = lang
|
||||
# Let a previously-denied person ask again; don't disturb approved/pending rows.
|
||||
if inv.status == "denied":
|
||||
inv.status = "pending"
|
||||
@@ -162,12 +258,39 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||
inv.decided_at = None
|
||||
inv.decided_by = None
|
||||
db.commit()
|
||||
return inv
|
||||
return None
|
||||
inv = Invite(email=email, status="pending")
|
||||
return InviteRequest(inv, True)
|
||||
if relearned:
|
||||
db.commit()
|
||||
return InviteRequest(inv, False)
|
||||
inv = Invite(email=email, status="pending", lang=lang)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
return inv
|
||||
return InviteRequest(inv, True)
|
||||
|
||||
|
||||
def _access_notice_url(kind: str, email: str = "", inv: Invite | None = None) -> str:
|
||||
"""The landing URL for a Google sign-in that did not reach an account: `requested` (we recorded
|
||||
the request) or `denied` (nothing to record).
|
||||
|
||||
It rides the FRAGMENT, not the query string, for the same reason the verify/reset tokens do
|
||||
(SB3): a fragment never reaches the server, a proxy access log or a Referer header — and this
|
||||
notice now carries the user's address. Naming it is the point of E9.5 S4: on 2026-09-03 a
|
||||
registration as `npeter83.temu@` met an OAuth claim of `npeter83temu@`, and a screen that said
|
||||
"request recorded" without saying FOR WHAT hid the mismatch for a day.
|
||||
|
||||
`linked` names the address the request actually lives under, and ONLY when that differs from the
|
||||
one Google just returned — the honest version of "we didn't open a second request". Same
|
||||
spelling twice is just a repeat, and saying anything there would be noise.
|
||||
|
||||
urlencode (not an f-string): a `+tag` address must arrive as %2B, or the SPA's URLSearchParams
|
||||
would read it back as a space and mangle exactly the address this epic exists to protect.
|
||||
"""
|
||||
params = {"access": kind}
|
||||
if email:
|
||||
params["email"] = email
|
||||
if inv is not None and inv.email != email:
|
||||
params["linked"] = inv.email
|
||||
return "/#" + urlencode(params)
|
||||
|
||||
|
||||
_DEV_OAUTH_HOSTS = frozenset(
|
||||
@@ -201,6 +324,12 @@ class EmailIn(BaseModel):
|
||||
class EmailPasswordIn(BaseModel):
|
||||
email: str | None = None
|
||||
password: str | None = None
|
||||
# The language the sign-up page was in. Captured at registration because it is the ONLY moment
|
||||
# the choice exists anywhere the server can see: it lives in the browser until an account does,
|
||||
# and a preference nobody recorded cannot be backfilled later (the lesson `google_email` taught
|
||||
# in E9.5 S3 — those rows still read NULL until each user signs in again). Nothing reads it yet;
|
||||
# the bilingual mail templates are E9.5 S7.
|
||||
lang: str | None = None
|
||||
|
||||
|
||||
class TokenIn(BaseModel):
|
||||
@@ -283,26 +412,37 @@ async def callback(
|
||||
# A denied Google login doubles as an access request: record it for the admin and
|
||||
# land the user on a friendly "request received" screen instead of a raw 403.
|
||||
if email:
|
||||
inv = upsert_pending_invite(db, email)
|
||||
admins = admin_notify_emails(db)
|
||||
if inv is not None and admins:
|
||||
background.add_task(email_mod.send_admin_new_request, admins, email)
|
||||
return RedirectResponse(url="/?access=requested")
|
||||
return RedirectResponse(url="/?access=denied")
|
||||
# Google just told us this person's locale — the only language signal that will ever
|
||||
# exist for an address with no account, and the decision mail is written from it.
|
||||
req = upsert_pending_invite(db, email, (userinfo.get("locale") or "").split("-")[0])
|
||||
if req.is_new:
|
||||
notify_admins_of_request(db, background, email)
|
||||
return RedirectResponse(url=_access_notice_url("requested", email, req.invite))
|
||||
return RedirectResponse(url=_access_notice_url("denied"))
|
||||
|
||||
user = db.query(User).filter(User.google_sub == sub).one_or_none()
|
||||
if user is None:
|
||||
# No account carries this Google identity yet. If one already exists for this email —
|
||||
# No account carries this Google identity yet. If one already exists for this MAILBOX —
|
||||
# e.g. an email+password registration — adopt the Google identity onto it instead of
|
||||
# inserting a duplicate (which would collide on the unique email and 500). Google has
|
||||
# verified the address, so this safely unifies password + Google sign-in on one account.
|
||||
existing = db.query(User).filter(User.email == email).one_or_none()
|
||||
# inserting a duplicate. Google has verified the address, so this safely unifies password +
|
||||
# Google sign-in on one account.
|
||||
#
|
||||
# Matched canonically, and that is not cosmetic: on 2026-09-03 a registration as
|
||||
# `npeter83.temu@gmail.com` met an OAuth claim of `npeter83temu@gmail.com`, the literal
|
||||
# lookup found nothing, and prod ended up with TWO active user rows for one Gmail mailbox
|
||||
# (ids 13 and 14). Exact spelling still wins when both exist, so an account that already
|
||||
# matches literally is never passed over for a canonical sibling.
|
||||
canon = canonical_email(email)
|
||||
candidates = db.query(User).filter(User.email_canonical == canon).order_by(User.id).all()
|
||||
existing = next((u for u in candidates if u.email == email), None) or next(
|
||||
iter(candidates), None
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.google_sub is not None and existing.google_sub != sub:
|
||||
# A different Google identity is already bound to this email — refuse rather than
|
||||
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
|
||||
log.warning("Google login email collision (different sub): %s", email)
|
||||
return RedirectResponse(url="/?access=denied")
|
||||
return RedirectResponse(url=_access_notice_url("denied"))
|
||||
if not userinfo.get("email_verified", False):
|
||||
# Adopting (and activating) a pre-existing password account requires Google to
|
||||
# actually attest the address — otherwise an unverified Google email that merely
|
||||
@@ -311,7 +451,7 @@ async def callback(
|
||||
"Google login: unverified email, refusing to adopt account: %s",
|
||||
email,
|
||||
)
|
||||
return RedirectResponse(url="/?access=denied")
|
||||
return RedirectResponse(url=_access_notice_url("denied"))
|
||||
user = existing
|
||||
user.google_sub = sub
|
||||
# SEC-10: drop any password pre-set on this account while its email was still unverified,
|
||||
@@ -328,22 +468,23 @@ async def callback(
|
||||
# tell the (verified) owner why — a successful Google auth proves they control this account.
|
||||
if user.is_suspended:
|
||||
log.info("Suspended account blocked at Google login: %s", email)
|
||||
_notify_suspended(background, user.email)
|
||||
_notify_suspended(background, user)
|
||||
return RedirectResponse(url="/?login=suspended")
|
||||
# Keep the email in sync with Google, but never overwrite it with one another account already
|
||||
# owns — that would hit the unique constraint and 500, wedging this user's sign-in (rare: a
|
||||
# Google-side email change to a value that collides with a different local account).
|
||||
if user.email != email:
|
||||
clash = db.query(User).filter(User.email == email, User.id != user.id).one_or_none()
|
||||
if clash is None:
|
||||
user.email = email
|
||||
# Record what Google actually answers with, beside the address the user registered with. The two
|
||||
# are the same string for most people; where they differ (a deliberate `+tag`, a different dot
|
||||
# pattern) the UI shows both, so nobody has to guess which one signs them in.
|
||||
if email:
|
||||
user.google_email = email
|
||||
_sync_google_email(db, user, email)
|
||||
user.display_name = userinfo.get("name")
|
||||
user.avatar_url = userinfo.get("picture")
|
||||
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
|
||||
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
|
||||
# routine login must NOT reset it (that would clobber a UI promotion/demotion). Only seed a
|
||||
# default for a brand-new account (its role attribute is still unset before flush).
|
||||
if email in settings.admin_email_set:
|
||||
# Canonical, like every other read of the env lists: a dotted or `+tag`ged ADMIN_EMAILS entry
|
||||
# would otherwise never bootstrap its admin, because Google answers with the plain primary.
|
||||
if canonical_email(email) in env_admin_canonicals():
|
||||
user.role = "admin"
|
||||
elif not user.role:
|
||||
user.role = "user"
|
||||
@@ -355,8 +496,7 @@ async def callback(
|
||||
# picked one yet); unsupported locales fall back to English.
|
||||
prefs = dict(user.preferences or {})
|
||||
if not prefs.get("language"):
|
||||
loc = (userinfo.get("locale") or "").split("-")[0].lower()
|
||||
prefs["language"] = loc if loc in {"en", "hu"} else "en"
|
||||
prefs["language"] = mailtext.normalize((userinfo.get("locale") or "").split("-")[0].lower())
|
||||
user.preferences = prefs
|
||||
db.flush()
|
||||
|
||||
@@ -375,6 +515,30 @@ async def callback(
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
|
||||
def _sync_google_email(db: Session, user: User, email: str) -> None:
|
||||
"""Follow a genuine Google-side email change — and ONLY that.
|
||||
|
||||
A different SPELLING of the same mailbox is the user's own choice and must survive. People
|
||||
register as `me+siftlode@gmail.com` precisely so a later leak from this site is identifiable, and
|
||||
silently rewriting that to `me@gmail.com` destroys the thing it was for. It also locked them out
|
||||
of the spelling they chose, because sign-in matches the stored address first. Google returns the
|
||||
account's PRIMARY address, which need not be what they registered with — and can never contain a
|
||||
`+` at all — so without this guard every plus-tagged registration lost its tag on the first Google
|
||||
sign-in. (E9.5 S3 made that reachable by matching adoption across spellings; the rewrite itself is
|
||||
older.)
|
||||
|
||||
Never overwrite with an address another account already owns either: that would hit the unique
|
||||
constraint and 500, wedging this user's sign-in.
|
||||
"""
|
||||
if not email or user.email == email:
|
||||
return
|
||||
if canonical_email(user.email) == canonical_email(email):
|
||||
return # same mailbox, their spelling — leave it alone
|
||||
clash = db.query(User).filter(User.email == email, User.id != user.id).one_or_none()
|
||||
if clash is None:
|
||||
user.email = email
|
||||
|
||||
|
||||
def _store_token(db: Session, user: User, token: dict) -> None:
|
||||
"""Persist the OAuth grant on the user's single token row, creating it if absent. Google
|
||||
only returns a refresh_token on (re)consent, so the previous one is kept otherwise.
|
||||
@@ -435,6 +599,9 @@ def _complete_link(
|
||||
if user.google_sub is not None and user.google_sub != sub:
|
||||
return RedirectResponse(url="/?link=mismatch")
|
||||
user.google_sub = sub
|
||||
linked_email = (userinfo.get("email") or "").strip().lower()
|
||||
if linked_email:
|
||||
user.google_email = linked_email # see the sign-in callback: the other half of the identity
|
||||
if not user.display_name:
|
||||
user.display_name = userinfo.get("name")
|
||||
if not user.avatar_url:
|
||||
@@ -468,15 +635,23 @@ async def logout(request: Request):
|
||||
return JSONResponse({"ok": True, "switched": False})
|
||||
|
||||
|
||||
class AccessRequestIn(EmailIn):
|
||||
# The language of the page this request was made from. Same reasoning as registration's `lang`
|
||||
# (and the same shape): it is the ONLY moment this preference exists anywhere the server can
|
||||
# see, since the requester has no account to carry it, and it decides what language the approval
|
||||
# or denial mail is written in.
|
||||
lang: str | None = None
|
||||
|
||||
|
||||
@router.post("/request-access")
|
||||
async def request_access(
|
||||
body: EmailIn,
|
||||
body: AccessRequestIn,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Public: ask for access. Idempotent — repeat requests for the same email don't
|
||||
duplicate or re-spam admins (upsert returns None for an already-pending row).
|
||||
duplicate or re-spam admins (upsert answers `is_new=False` for an already-pending row).
|
||||
|
||||
Answers a uniform ``"pending"`` for EVERY outcome — already-allowed, newly-pending,
|
||||
already-pending, and rate-limited alike. The old ``"approved"`` reply for an allow-listed
|
||||
@@ -491,10 +666,9 @@ async def request_access(
|
||||
return {"status": "pending"}
|
||||
if is_allowed(db, email):
|
||||
return {"status": "pending"} # already allowed — don't reveal it
|
||||
inv = upsert_pending_invite(db, email)
|
||||
admins = admin_notify_emails(db)
|
||||
if inv is not None and admins:
|
||||
background.add_task(email_mod.send_admin_new_request, admins, email)
|
||||
req = upsert_pending_invite(db, email, body.lang)
|
||||
if req.is_new:
|
||||
notify_admins_of_request(db, background, email)
|
||||
return {"status": "pending"}
|
||||
|
||||
|
||||
@@ -551,6 +725,29 @@ def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
|
||||
return raw
|
||||
|
||||
|
||||
def _peek_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||
"""Is this token still usable — WITHOUT spending it. Same four conditions as _consume_token, and
|
||||
the same "does its user still exist" answer, so the two can never disagree about a given token.
|
||||
|
||||
Deliberately NOT the read half of a read-check-write: _consume_token stays one atomic UPDATE, and
|
||||
this never feeds it. A peek is only ever an early warning — the confirm endpoint remains the
|
||||
authority, because a token can expire, be spent, or lose its account between the two calls.
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
row = db.execute(
|
||||
select(AuthToken).where(
|
||||
AuthToken.token_hash == hash_token(raw),
|
||||
AuthToken.kind == kind,
|
||||
AuthToken.used_at.is_(None),
|
||||
AuthToken.expires_at >= datetime.now(timezone.utc),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
# db.get, exactly as _consume_token does: a deleted account cascades its tokens away, so this is
|
||||
# belt-and-braces — but the two must agree, and agreeing by construction beats agreeing by luck.
|
||||
return db.get(User, row.user_id) if row is not None else None
|
||||
|
||||
|
||||
def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||
"""Validate + burn a token atomically. Returns its user, or None if missing/expired/used/wrong
|
||||
kind. The burn is a single conditional UPDATE (…WHERE used_at IS NULL…RETURNING) rather than a
|
||||
@@ -606,17 +803,59 @@ def register(
|
||||
# session). The create path hashes the password + writes several rows + schedules emails; doing it
|
||||
# inline would make a NEW email respond measurably slower than an already-registered one (which
|
||||
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
|
||||
background.add_task(_register_account, email, password)
|
||||
background.add_task(_register_account, email, password, body.lang)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def _register_account(email: str, password: str) -> None:
|
||||
def _register_account(email: str, password: str, lang: str | None = None) -> None:
|
||||
"""Background worker for /register: create the pending account (+ verification email + admin
|
||||
notice) for a genuinely new email; no-op for an already-registered one. Runs after the response
|
||||
with its own DB session, so the request's timing never reveals whether the email already exists."""
|
||||
with SessionLocal() as db:
|
||||
if db.execute(select(User).where(User.email == email)).scalar_one_or_none() is not None:
|
||||
return # already registered — nothing to do (the uniform "ok" was already returned)
|
||||
# Canonical, so a second spelling of one Gmail mailbox cannot open a SECOND account. Prod
|
||||
# already carries such a pair (user ids 13 and 14, both for npeter83temu@gmail.com); this is
|
||||
# the end that stops new ones appearing, while the Google callback copes with the existing.
|
||||
candidates = (
|
||||
db.execute(
|
||||
select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
# Exact spelling wins when a mailbox carries two rows, same as every other canonical lookup
|
||||
# in this epic (`find_user_by_email`, `_activate_user_by_email`, the Google callback). Without
|
||||
# it `.first()` handed back whichever row Postgres felt like, so a re-registration of the
|
||||
# dotted spelling could resolve to the undotted, already-confirmed twin and send nothing.
|
||||
existing = next((u for u in candidates if u.email == email), None) or next(
|
||||
iter(candidates), None
|
||||
)
|
||||
if existing is not None:
|
||||
# Already registered. This used to return in silence, and that silence is the most
|
||||
# likely explanation for the 2026-09-03 report that a confirmation mail "never arrived":
|
||||
# nothing was ever SENT. The person re-registered, got the same cheerful "check your
|
||||
# inbox", and waited for a message that did not exist.
|
||||
#
|
||||
# Re-sending the link instead leaks nothing new. The response and its timing are
|
||||
# unchanged (uniform "ok", decided off the response path), the mail goes to the address
|
||||
# ON THE ACCOUNT, and this is exactly what the public /auth/verify/resend already does
|
||||
# for the same input — so it is not a new capability, just the end of a dead end. An
|
||||
# account that is already confirmed still gets nothing: there is nothing to confirm.
|
||||
#
|
||||
# And a re-send is ALL it does: the submitted password is discarded. E9.5's closing
|
||||
# review tried "while the address is unverified, the newest registration owns it"
|
||||
# (replace the hash, kill the outstanding verify tokens) as a fix for address
|
||||
# pre-emption, and round 2 showed it was worse than the problem — DO NOT REPEAT IT.
|
||||
# The rule is symmetric, and an attacker can always be last: it hands an unauthenticated
|
||||
# caller the power to overwrite a real registrant's chosen password and delete the
|
||||
# confirmation link already in their inbox, and on a SEC-10-stripped row (password_hash
|
||||
# NULL, password_revoked_at set) it would install a password where the whole point was
|
||||
# that there is none. The owner's real recourse already exists and needs no new
|
||||
# primitive: password-reset accepts this account, proves the mailbox, rewrites the hash
|
||||
# and bumps the session epoch. The residual is logged as SEC-16.
|
||||
user = existing
|
||||
if not user.email_verified and not user.is_demo and email_mod.email_enabled():
|
||||
send_verification_link(db, user)
|
||||
return
|
||||
# Without working SMTP we can't deliver a verification link, so email ownership can't gate
|
||||
# sign-in. Admin approval stays the real gate (is_active=False); mark the account verified so
|
||||
# the flow still completes on a no-SMTP self-host. See email.email_enabled().
|
||||
@@ -626,6 +865,8 @@ def _register_account(email: str, password: str) -> None:
|
||||
password_hash=hash_password(password),
|
||||
is_active=False,
|
||||
email_verified=not email_ok,
|
||||
# Only a supported code is stored, so a forged body can't put junk in preferences.
|
||||
preferences={"language": lang} if lang in mailtext.SUPPORTED else {},
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
@@ -646,17 +887,45 @@ def _register_account(email: str, password: str) -> None:
|
||||
# Commit unconditionally; it is a no-op when upsert already committed the same transaction.
|
||||
db.commit()
|
||||
if email_ok:
|
||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
||||
# Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer (SB3).
|
||||
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
|
||||
admins = admin_notify_emails(db)
|
||||
send_verification_link(db, user)
|
||||
# Fired unconditionally, unlike the other two callers which gate on a fresh invite row: for
|
||||
# an address the admin already approved, nothing lands in the Access-requests queue, so this
|
||||
# is the only place the account's existence is announced at all. Delivery is a separate
|
||||
# matter — with no working SMTP `_send` drops it, which is why the pre-approved registrant
|
||||
# still needs the admin to notice them (roadmap UX-25).
|
||||
if admins:
|
||||
email_mod.send_admin_new_request(admins, email)
|
||||
notify_admins_of_request(db, None, email)
|
||||
|
||||
|
||||
def issue_verification_link(db: Session, user: User, ttl: timedelta | None = None) -> str:
|
||||
"""A fresh confirmation link for `user`, returned rather than sent — for a mail that carries the
|
||||
link inside a larger message (the branching approval/invitation mails, E9.5 S6).
|
||||
|
||||
Same token machinery and the same fragment (SB3) as the standalone confirmation mail, so the two
|
||||
kinds of link cannot drift apart in shape or in how they are consumed."""
|
||||
raw = _issue_token(db, user, "verify", ttl or VERIFY_TTL)
|
||||
return f"{_app_base()}/#verify={raw}"
|
||||
|
||||
|
||||
def send_verification_link(db: Session, user: User) -> bool:
|
||||
"""Issue a fresh verification token for `user` and mail them the link.
|
||||
|
||||
Always to the address ON THE ACCOUNT, never a spelling typed into some form: the account's
|
||||
address is the one its owner chose to be reachable at, and a `+tag` there is usually deliberate.
|
||||
The token rides the URL FRAGMENT (#) so it can never reach a proxy/access log or a Referer (SB3).
|
||||
|
||||
One definition, three callers — registration, the self-service resend, and the admin-side resend
|
||||
(E9.5 S5). The link's shape and TTL must not drift between them; a verification mail that differs
|
||||
by who triggered it is a bug waiting to be discovered by exactly one user.
|
||||
|
||||
Returns whether the mail actually went out, so a caller with a human waiting on the answer (the
|
||||
admin-side resend) can say so instead of reporting success for a message that was dropped.
|
||||
"""
|
||||
return email_mod.send_verify_email(
|
||||
user.email,
|
||||
mailtext.lang_for_user(user),
|
||||
issue_verification_link(db, user),
|
||||
hours=int(VERIFY_TTL.total_seconds() // 3600),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
@@ -664,20 +933,25 @@ def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict:
|
||||
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
|
||||
on any invalid/expired/used token.
|
||||
|
||||
`password_reset_required` tells the SPA to send the user straight to the reset flow: an admin who
|
||||
approved this account BEFORE it was verified tripped SEC-10, so the password chosen at registration
|
||||
is gone and sign-in would answer with the deliberately uniform "Invalid email or password" — true,
|
||||
but unreadable to the person who just proved they own the mailbox. Saying so leaks nothing: holding
|
||||
a valid single-use verify token for this account already proves that much."""
|
||||
`password_reset_required` tells the SPA to send the user straight to the reset flow: SEC-10 had
|
||||
dropped the password this account registered with, so sign-in would answer with the deliberately
|
||||
uniform "Invalid email or password" — true, but unreadable to the person who just proved they own
|
||||
the mailbox. Saying so leaks nothing: holding a valid single-use verify token for this account
|
||||
already proves that much. Since E9.5 S1 the common source of that state is gone (admin approval no
|
||||
longer revokes), leaving accounts stripped before that change and the Google-adoption path, so this
|
||||
flag is rare now — but it must stay, because those users have no other way back in."""
|
||||
user = _consume_token(db, body.token, "verify")
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="This verification link is invalid or has expired."
|
||||
)
|
||||
raise HTTPException(status_code=400, detail={"code": "verify", "reason": "invalid"})
|
||||
user.email_verified = True
|
||||
needs_password = user.password_hash is None and user.password_revoked_at is not None
|
||||
email = user.email
|
||||
db.commit()
|
||||
return {"ok": True, "password_reset_required": needs_password}
|
||||
# Naming the address lets the SPA say WHICH mailbox it just confirmed — the same reasoning as
|
||||
# password_reset_required above: holding a valid single-use verify token for this account already
|
||||
# proves that much, so echoing it back leaks nothing. It matters in a browser that holds a session
|
||||
# for a different account, where an unqualified "email confirmed" would be actively misleading.
|
||||
return {"ok": True, "password_reset_required": needs_password, "email": email}
|
||||
|
||||
|
||||
@router.post("/verify/resend")
|
||||
@@ -707,24 +981,36 @@ def _resend_verify_if_eligible(email: str) -> None:
|
||||
if not email_mod.email_enabled():
|
||||
return
|
||||
with SessionLocal() as db:
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
user = find_user_by_email(db, email)
|
||||
if user is not None and not user.email_verified and not user.is_demo:
|
||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
||||
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
|
||||
send_verification_link(db, user)
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
def verify_email(token: str, db: Session = Depends(get_db)):
|
||||
"""Legacy query-token verification link (pre-SB3 emails still in flight). New emails use the
|
||||
fragment + POST flow above; this bounces to a friendly status for older links until they expire."""
|
||||
user = _consume_token(db, token, "verify")
|
||||
if user is None:
|
||||
return RedirectResponse(url="/?verify=invalid")
|
||||
user.email_verified = True
|
||||
needs_password = user.password_hash is None and user.password_revoked_at is not None
|
||||
db.commit()
|
||||
# Same two outcomes as the POST flow above, so an old link in flight lands on the same guidance.
|
||||
return RedirectResponse(url="/?verify=" + ("ok_reset" if needs_password else "ok"))
|
||||
def _login_refused(status: int, reason: str, user: User | None = None, **extra) -> HTTPException:
|
||||
"""Every refusal password_login can answer with, in the codebase's structured `{code, reason}`
|
||||
shape so the SPA can say it in the user's own language (the old bare English strings were
|
||||
unreadable under a bilingual UI, and unusable as anything but a one-line error).
|
||||
|
||||
The account-shaped reasons carry the address ON THE ACCOUNT plus both gate flags. That is what
|
||||
turns a flat 403 into an honest screen — it names the mailbox and says which of the two gates is
|
||||
still shut. It is not an enumeration leak: this branch is only reachable once the password has
|
||||
been proven, so the caller already knows the account exists. The two reasons that answer BEFORE
|
||||
that proof (`invalid`, `rate_limited`) carry nothing at all, so they stay byte-identical for a
|
||||
real account and an unknown one.
|
||||
|
||||
Since E9.5 S1 the two gates are order-independent, so `approved` really can be true while
|
||||
`email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding
|
||||
rather than assuming a sequence.
|
||||
"""
|
||||
detail: dict = {"code": "login", "reason": reason}
|
||||
if user is not None:
|
||||
detail |= {
|
||||
"email": user.email,
|
||||
"email_verified": user.email_verified,
|
||||
"approved": user.is_active,
|
||||
}
|
||||
detail |= {k: v for k, v in extra.items() if v is not None}
|
||||
return HTTPException(status_code=status, detail=detail)
|
||||
|
||||
|
||||
@router.post("/password-login")
|
||||
@@ -738,29 +1024,23 @@ def password_login(
|
||||
Once the password is proven correct, the owner gets a specific reason if their account is
|
||||
still pending or suspended — that's not an enumeration leak (they already hold the password)."""
|
||||
if not _login_limiter.allow(_client_ip(request)):
|
||||
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
|
||||
raise _login_refused(429, "rate_limited")
|
||||
email = (body.email or "").strip().lower()
|
||||
password = body.password or ""
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
user = find_user_by_email(db, email)
|
||||
# Always run the KDF (against a decoy hash for an unknown / passwordless / demo account) so the
|
||||
# response time is the same whether or not the email exists — no timing-based enumeration.
|
||||
candidate_hash = user.password_hash if (user and not user.is_demo) else None
|
||||
password_ok = verify_password(password, candidate_hash or _DECOY_PASSWORD_HASH)
|
||||
if user is None or user.is_demo or not password_ok:
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password.")
|
||||
raise _login_refused(401, "invalid")
|
||||
if user.is_suspended:
|
||||
_notify_suspended(background, user.email)
|
||||
op = operator_contact()
|
||||
detail = "Your account has been suspended."
|
||||
if op:
|
||||
detail += f" If you have questions, contact the operator at {op}."
|
||||
raise HTTPException(status_code=403, detail=detail)
|
||||
_notify_suspended(background, user)
|
||||
raise _login_refused(403, "suspended", user, operator=operator_contact() or None)
|
||||
if not user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Please verify your email first (check your inbox)."
|
||||
)
|
||||
raise _login_refused(403, "email_unverified", user)
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Your account is awaiting admin approval.")
|
||||
raise _login_refused(403, "pending_approval", user)
|
||||
_establish_session(request, user)
|
||||
log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role)
|
||||
return {"ok": True}
|
||||
@@ -797,12 +1077,35 @@ def _send_reset_if_eligible(email: str) -> None:
|
||||
ineligible on purpose: its owner adds one from Settings (/auth/set-password) while signed in, which
|
||||
proves identity through the session rather than by mail."""
|
||||
with SessionLocal() as db:
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
user = find_user_by_email(db, email)
|
||||
if user is None or user.is_demo:
|
||||
return
|
||||
if user.password_hash or user.password_revoked_at:
|
||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||
email_mod.send_password_reset(email, f"{_app_base()}/#reset={raw}")
|
||||
# The account's own address, as above — never the spelling that was typed into the form.
|
||||
email_mod.send_password_reset(
|
||||
user.email, mailtext.lang_for_user(user), f"{_app_base()}/#reset={raw}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/password-reset/check")
|
||||
def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
"""Is the reset link the SPA just opened still good? Answers without spending the token.
|
||||
|
||||
Why this exists: the card used to render "set a new password" purely because a `#reset=` fragment
|
||||
was present, never asking whether it could still succeed. Reported on 2026-09-05 after deleting an
|
||||
account and then opening its reset link — the user types a new password into a form that answers
|
||||
400. Opening a dead reset link is not an edge case: the token lives one hour, mail is slow or lands
|
||||
in spam, and whoever got two mails usually clicks the older one.
|
||||
|
||||
Not an enumeration oracle: probing requires already holding a 43-character random, and the answer
|
||||
is exactly what submitting it would reveal. Throttled per IP anyway — and a throttled caller is
|
||||
told `true`, never `false`: this check may only ever save a user a wasted form, so when it cannot
|
||||
answer it must degrade to the old behaviour instead of declaring a live link dead.
|
||||
"""
|
||||
if not _reset_check_limiter.allow(_client_ip(request)):
|
||||
return {"ok": True}
|
||||
return {"ok": _peek_token(db, body.token, "reset") is not None}
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm")
|
||||
@@ -813,11 +1116,11 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
|
||||
if len(new_password) < PASSWORD_MIN_LEN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
|
||||
detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN},
|
||||
)
|
||||
user = _consume_token(db, token, "reset")
|
||||
if user is None:
|
||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
|
||||
raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"})
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent
|
||||
# A valid reset link proves control of the mailbox, so completing a reset also verifies the
|
||||
@@ -859,10 +1162,25 @@ def bump_session_epoch(user: User) -> None:
|
||||
def revoke_preset_password_if_unverified(user: User) -> None:
|
||||
"""SEC-10 (account pre-emption): a password set on an account whose email was NEVER verified is
|
||||
untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. When
|
||||
ownership is finally attested (Google adoption, or admin approval), drop that password and bump the
|
||||
epoch to revoke any session it minted; the owner re-sets one via password-reset. No-op for an
|
||||
already-verified account, so a legit no-SMTP registration is unaffected. The single source for this
|
||||
rule — both the Google-callback and admin-activation paths call it so they can't drift.
|
||||
the real owner attests ownership through Google, drop that password and bump the epoch to revoke any
|
||||
session it minted; the owner re-sets one via password-reset. No-op for an already-verified account,
|
||||
so a legit no-SMTP registration is unaffected.
|
||||
|
||||
The Google-adoption callback is the ONLY caller (E9.5 S1). Admin approval used to call this too, and
|
||||
dropping it was right for the reason S1 gave — the revoke destroyed the password a legitimate
|
||||
registrant had just chosen, and since the admin normally decides before the user reads their mail,
|
||||
that made the "your password was cleared" detour the DEFAULT path rather than an edge case.
|
||||
|
||||
S1 also claimed the revoke was REDUNDANT there, because `password_login` refuses an unverified
|
||||
account before it reads `is_active`. That part was wrong, and the epic's own review caught it: the
|
||||
gate holds only while the address is unverified, and S6's approval mail exists to get the real
|
||||
owner to end that state — at which point a pre-emptor's password works. Nothing replaced the
|
||||
revoke, and that is deliberate. The review tried "the newest registration owns an unverified
|
||||
account" and reverted it: the rule is symmetric, so an attacker can simply register last, and it
|
||||
would have handed anonymous callers a write onto a real account's credential. What the owner
|
||||
actually has is password-reset, which proves the mailbox, rewrites the hash and bumps the epoch.
|
||||
The residual is logged as SEC-16; re-adding a caller here would put "your password was cleared"
|
||||
back in front of every legitimate registrant, which is the whole reason S1 removed it.
|
||||
|
||||
`password_revoked_at` is what makes "the owner re-sets one via password-reset" actually true. The
|
||||
revoke leaves `password_hash IS NULL`, which is also what a Google-only account looks like — and
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import audit
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import sysconfig
|
||||
from app.audit import AuditAction
|
||||
from app.auth import admin_user
|
||||
@@ -115,7 +116,7 @@ def send_test_email(
|
||||
lets the admin verify the settings actually work."""
|
||||
if not email_mod.email_enabled():
|
||||
raise HTTPException(status_code=400, detail="SMTP is not configured.")
|
||||
sent = email_mod.send_test(user.email)
|
||||
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
|
||||
if not sent:
|
||||
raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.")
|
||||
return {"sent": True, "to": user.email}
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import (
|
||||
count_admins,
|
||||
count_pending_activations,
|
||||
current_user,
|
||||
google_enabled,
|
||||
has_read_scope,
|
||||
@@ -99,6 +100,7 @@ def get_me(
|
||||
if user is None:
|
||||
return {"authenticated": False}
|
||||
pending_invites = 0
|
||||
pending_activations = 0
|
||||
# Admin-only, because it drives an operator warning: email is fail-soft, so a half-filled SMTP
|
||||
# config costs verification links, password resets and access-request notices with nothing
|
||||
# visible anywhere. Surfacing it here puts the warning on the screens where onboarding happens.
|
||||
@@ -108,6 +110,10 @@ def get_me(
|
||||
db.scalar(select(func.count()).select_from(Invite).where(Invite.status == "pending"))
|
||||
or 0
|
||||
)
|
||||
# Counted separately from pending_invites rather than folded into it: the two are different
|
||||
# facts (a request awaiting a decision vs a decided address whose sign-up never got let in),
|
||||
# and quietly widening one number would make an existing field mean something new.
|
||||
pending_activations = count_pending_activations(db)
|
||||
email_configured = email_mod.email_enabled(db) # this request's session; no second connect
|
||||
return {
|
||||
"authenticated": True,
|
||||
@@ -118,6 +124,12 @@ def get_me(
|
||||
"role": user.role,
|
||||
"is_demo": user.is_demo,
|
||||
"has_google": user.google_sub is not None,
|
||||
# The address Google answers with, when it DIFFERS from the account's own. Same string for
|
||||
# most people, and then this is None so no surface has to decide whether to show it — the
|
||||
# comparison lives here, once, instead of in every consumer.
|
||||
"google_email": (
|
||||
user.google_email if user.google_email and user.google_email != user.email else None
|
||||
),
|
||||
"has_password": user.password_hash is not None,
|
||||
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
|
||||
# YouTube-access affordances and the onboarding nudge when it isn't configured).
|
||||
@@ -127,6 +139,9 @@ def get_me(
|
||||
"can_read": has_read_scope(user),
|
||||
"can_write": has_write_scope(user),
|
||||
"pending_invites": pending_invites,
|
||||
# Sign-ups an admin still has to let in (roadmap UX-25): approved BEFORE they registered, so
|
||||
# nothing ever reached the Access-requests queue and the badge used to read zero.
|
||||
"pending_activations": pending_activations,
|
||||
"email_configured": email_configured,
|
||||
"preferences": user.preferences or {},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app import state, sysconfig
|
||||
from app.auth import PASSWORD_MIN_LEN
|
||||
from app.db import get_db
|
||||
@@ -103,6 +104,9 @@ def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_set
|
||||
|
||||
class SetupTestEmailIn(BaseModel):
|
||||
to: str = ""
|
||||
# The wizard's page language. No account exists yet on a fresh instance, so this is the only
|
||||
# thing that can say what language to write in; anything unsupported falls back to English.
|
||||
lang: str | None = None
|
||||
|
||||
|
||||
@router.post("/test-email")
|
||||
@@ -111,7 +115,7 @@ def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup
|
||||
to = body.to.strip()
|
||||
if "@" not in to:
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
if not email_mod.send_test(to):
|
||||
if not email_mod.send_test(to, mailtext.normalize(body.lang)):
|
||||
raise HTTPException(status_code=400, detail="Couldn't send — check the SMTP settings.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -22,6 +22,39 @@ def valid_email(value: str | None) -> bool:
|
||||
return bool(value and _EMAIL_RE.match(value))
|
||||
|
||||
|
||||
# Gmail (and its googlemail.com alias) delivers to one mailbox regardless of dots in the local part
|
||||
# and of any `+tag`; nobody else is guaranteed to. So the canonical form below strips those for
|
||||
# Gmail only, and merely lowercases everything else.
|
||||
_GMAIL_DOMAINS = {"gmail.com", "googlemail.com"}
|
||||
|
||||
|
||||
def canonical_email(value: str | None) -> str:
|
||||
"""The form used to decide whether two addresses are the SAME MAILBOX. Never used for sending —
|
||||
mail always goes to the address as the user typed it.
|
||||
|
||||
Why this exists (observed on prod, 2026-09-03): someone registered as `npeter83.temu@gmail.com`
|
||||
and was approved, but Google's OAuth `email` claim came back as the account's stored primary,
|
||||
`npeter83temu@gmail.com`. The invite gate only lowercased, found no approved row for that literal
|
||||
string, and turned an approved user's sign-in into a silent SECOND access request — plus a second
|
||||
User row. The `+tag` case is worse and fails every time: a Google account cannot contain `+`, so
|
||||
OAuth can only ever return the base address, meaning anyone who registers with
|
||||
`user+siftlode@gmail.com` (a common throwaway habit) is guaranteed to be denied after approval.
|
||||
|
||||
Scope is deliberately narrow. The dot rule does NOT hold for Google Workspace custom domains
|
||||
unless the admin turned it on, and dots are significant on most providers, so anything outside
|
||||
gmail.com/googlemail.com is only lowercased. Approving one Gmail spelling always did mean
|
||||
approving that mailbox, so collapsing them is the honest reading of an invite, not a widening
|
||||
of it.
|
||||
"""
|
||||
email = (value or "").strip().lower()
|
||||
local, sep, domain = email.partition("@")
|
||||
if not sep or domain not in _GMAIL_DOMAINS:
|
||||
return email
|
||||
local = local.partition("+")[0].replace(".", "")
|
||||
# Normalise the alias domain too, so googlemail and gmail spellings meet.
|
||||
return f"{local}@gmail.com" if local else email
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
"""Current time as a timezone-aware UTC datetime."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""The admin lifeline for a sign-up that is stuck outside (E9.5 S5, roadmap UX-25), DB-lane.
|
||||
|
||||
Approve an address BEFORE the person registers and their sign-up produces no queue row at all —
|
||||
`_decide` only takes a still-pending invite — so the Access-requests screen stayed empty while they
|
||||
sat locked out, and the badge read zero. The only working move was re-typing the address into the
|
||||
whitelist box, which nobody would guess.
|
||||
|
||||
Two earlier attempts at this were reverted after review, and both moved AUTHORITY rather than
|
||||
visibility: auto-activating on registration, and reopening the approved invite to pending. So the
|
||||
tests here pin the boundary as much as the feature — an admin click, gated on an address that is
|
||||
already allowed, and nothing an unauthenticated caller can reach.
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AuditLog, AuthToken, Invite, User
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _pending_access(client):
|
||||
r = client.get("/api/admin/pending-access")
|
||||
assert r.status_code == 200
|
||||
return {u["email"]: u for u in r.json()}
|
||||
|
||||
|
||||
def _stuck_registrant(db, make_user, admin):
|
||||
"""The UX-25 shape: the admin approved the address first, the account arrived afterwards."""
|
||||
db.add(Invite(email="later@example.com", status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
return make_user(email="later@example.com", password=_PW, is_active=False)
|
||||
|
||||
|
||||
def test_a_pre_approved_registrant_is_listed_and_offers_activation(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
_stuck_registrant(db, make_user, admin)
|
||||
|
||||
row = _pending_access(as_user(admin))["later@example.com"]
|
||||
|
||||
assert row["is_active"] is False
|
||||
assert (
|
||||
row["can_activate"] is True
|
||||
) # the address is already allowed — the click just finishes it
|
||||
|
||||
|
||||
def test_an_ordinary_registrant_awaiting_a_decision_is_not_listed_twice(db, make_user, as_user):
|
||||
# They already have a pending invite with Approve on it in the queue above, and their address is
|
||||
# not allowed YET — the decision is the queue's to own. Caught in UAT on 2026-09-05: an earlier
|
||||
# filter keyed on "unconfirmed" put every fresh registration in both lists at once.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="normal@example.com", status="pending"))
|
||||
db.commit()
|
||||
make_user(email="normal@example.com", password=_PW, is_active=False)
|
||||
|
||||
assert "normal@example.com" not in _pending_access(as_user(admin))
|
||||
|
||||
|
||||
def test_a_fresh_registration_stays_in_the_queue_and_out_of_this_list(db, make_user, as_user):
|
||||
# The realistic version of the case above: a brand-new sign-up is inactive AND unconfirmed. It
|
||||
# still belongs only to the queue — the admin's next act is the decision, not a re-send.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="fresh@example.com", status="pending"))
|
||||
db.commit()
|
||||
make_user(email="fresh@example.com", password=_PW, is_active=False, email_verified=False)
|
||||
|
||||
assert "fresh@example.com" not in _pending_access(as_user(admin))
|
||||
|
||||
|
||||
def test_activating_lets_them_in_and_is_recorded(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
stuck = _stuck_registrant(db, make_user, admin)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{stuck.id}/activate")
|
||||
|
||||
assert r.status_code == 200 and r.json()["is_active"] is True
|
||||
db.expire_all()
|
||||
assert db.get(User, stuck.id).is_active is True
|
||||
actions = db.execute(select(AuditLog.action)).scalars().all()
|
||||
assert (
|
||||
"user_activate" in actions
|
||||
) # the admin acted on someone's access; it is not a silent read
|
||||
|
||||
|
||||
def test_activating_an_address_that_is_not_allowed_is_refused(db, make_user, as_user):
|
||||
# The guard that keeps this button from manufacturing an inconsistent account: password sign-in
|
||||
# only checks is_active and would let them in, while their Google sign-in would be turned away
|
||||
# and silently reopened as a fresh access request. Whitelisting is the move that says "let them
|
||||
# in"; this one only finishes a decision already made.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
nobody = make_user(email="nobody@example.com", password=_PW, is_active=False)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{nobody.id}/activate")
|
||||
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"]["reason"] == "not_allowed"
|
||||
db.expire_all()
|
||||
assert db.get(User, nobody.id).is_active is False
|
||||
|
||||
|
||||
def test_activating_twice_is_not_an_error(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
stuck = _stuck_registrant(db, make_user, admin)
|
||||
client = as_user(admin)
|
||||
|
||||
assert client.post(f"/api/admin/users/{stuck.id}/activate").status_code == 200
|
||||
assert client.post(f"/api/admin/users/{stuck.id}/activate").status_code == 200
|
||||
|
||||
|
||||
def test_a_suspended_account_is_not_offered_as_stuck(db, make_user, as_user):
|
||||
# It cannot sign in either, but that is a decision already made — not a person waiting on one.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
db.add(Invite(email="banned@example.com", status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
make_user(email="banned@example.com", password=_PW, is_active=False, is_suspended=True)
|
||||
|
||||
assert "banned@example.com" not in _pending_access(as_user(admin))
|
||||
|
||||
|
||||
# --- the confirmation resend ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_unconfirmed_account_is_listed_and_can_be_sent_a_fresh_link(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app.routes import admin as admin_routes
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: True)
|
||||
sent: list[tuple[str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
auth_routes.email_mod,
|
||||
"send_verify_email",
|
||||
lambda to, lang, link, **kw: sent.append((to, link)) is None,
|
||||
)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
# Active but unconfirmed — a real state since E9.5 S1 made the two gates order-independent. It is
|
||||
# not the admin's to decide, but the mail may simply never have arrived, so it is theirs to help
|
||||
# with. The approved invite is what puts them on this screen at all: the list is "the address may
|
||||
# sign in, this account still can't", so an undecided request stays with the queue that owns it.
|
||||
db.add(Invite(email="unconfirmed@example.com", status="approved", decided_by=admin.email))
|
||||
db.commit()
|
||||
u = make_user(email="unconfirmed@example.com", password=_PW, email_verified=False)
|
||||
|
||||
assert "unconfirmed@example.com" in _pending_access(as_user(admin))
|
||||
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
|
||||
|
||||
assert r.status_code == 200 and r.json()["email"] == "unconfirmed@example.com"
|
||||
assert [to for to, _ in sent] == ["unconfirmed@example.com"]
|
||||
tokens = db.execute(select(AuthToken).where(AuthToken.user_id == u.id)).scalars().all()
|
||||
assert [t.kind for t in tokens] == ["verify"]
|
||||
|
||||
|
||||
def test_the_resend_says_so_when_the_instance_cannot_send_mail(db, make_user, as_user, monkeypatch):
|
||||
from app.routes import admin as admin_routes
|
||||
|
||||
monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: False)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
u = make_user(email="unconfirmed@example.com", password=_PW, email_verified=False)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
|
||||
|
||||
# Mail is fail-soft everywhere else, which is exactly what left the 0.78.5 sign-ups with nothing
|
||||
# to fall back on. An admin pressing a button gets the failure, not a success toast.
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "email_off"
|
||||
|
||||
|
||||
def test_resending_to_an_already_confirmed_account_is_refused(db, make_user, as_user):
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
u = make_user(email="fine@example.com", password=_PW)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
|
||||
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "not_applicable"
|
||||
|
||||
|
||||
# --- the badge -----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_badge_counts_stuck_sign_ups_but_not_ones_the_queue_already_shows(
|
||||
db, make_user, client
|
||||
):
|
||||
# Signed in for real, not via the as_user override: /api/me resolves its user through
|
||||
# optional_current_user, which that override does not cover (see test_me_email_flag).
|
||||
admin = make_user(email="admin@example.com", password=_PW, role="admin")
|
||||
_stuck_registrant(db, make_user, admin) # approved first → counts
|
||||
db.add(Invite(email="normal@example.com", status="pending")) # already in the queue → doesn't
|
||||
db.commit()
|
||||
make_user(email="normal@example.com", password=_PW, is_active=False)
|
||||
make_user(email="unconfirmed@example.com", password=_PW, email_verified=False) # not ours
|
||||
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/password-login", json={"email": admin.email, "password": _PW}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
me = client.get("/api/me").json()
|
||||
assert me["authenticated"] is True
|
||||
|
||||
# An unconfirmed account waits on its OWNER, not on the admin — listing it is help, badging it
|
||||
# would be nagging about something the admin did not cause.
|
||||
assert me["pending_activations"] == 1
|
||||
assert me["pending_invites"] == 1 # unchanged meaning: still just the undecided requests
|
||||
@@ -97,28 +97,85 @@ def test_reset_confirm_is_single_use_and_kills_existing_sessions(client, make_us
|
||||
assert replay.status_code == 400
|
||||
|
||||
|
||||
def test_activating_an_unverified_account_drops_its_preset_password(db, make_user):
|
||||
# SEC-10 (account pre-emption): an attacker registers victim@x with a password before the approved
|
||||
# owner arrives. The address was never verified, so admin approval must NOT hand that pre-set
|
||||
# password a working login — it is dropped and any session it minted is revoked.
|
||||
def test_approving_an_unverified_account_keeps_its_password(db, make_user):
|
||||
# E9.5 S1, inverting an earlier rule. Approval used to drop a password set while the email was
|
||||
# unverified (SEC-10, account pre-emption). It was dropped because it destroyed the password a
|
||||
# legitimate registrant had just chosen, in what is the NORMAL ordering — the admin decides
|
||||
# before the user reads their mail — making "your password was cleared for security" the default
|
||||
# experience.
|
||||
#
|
||||
# S1 ALSO argued the revoke was redundant, because password_login refuses an unverified account
|
||||
# before it reads is_active. The epic's closing review refuted that: the gate holds only while
|
||||
# the address is unverified, and S6's approval mail exists to get the real owner to end that
|
||||
# state. Do not restore that reasoning from this comment — the residual is SEC-16, and round 2
|
||||
# of the review tried and reverted a guard for it (see test_review_round.py).
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
victim = make_user(
|
||||
email="victim@example.com",
|
||||
password="attacker-chosen",
|
||||
user = make_user(
|
||||
email="approved-first@example.com",
|
||||
password="chosen at registration",
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
)
|
||||
epoch0 = victim.session_epoch or 0
|
||||
_activate_user_by_email(db, "victim@example.com")
|
||||
assert victim.is_active is True
|
||||
assert victim.password_hash is None
|
||||
assert (victim.session_epoch or 0) == epoch0 + 1
|
||||
epoch0 = user.session_epoch or 0
|
||||
|
||||
_activate_user_by_email(db, "approved-first@example.com")
|
||||
|
||||
assert user.is_active is True
|
||||
assert user.password_hash is not None
|
||||
assert user.password_revoked_at is None
|
||||
assert (user.session_epoch or 0) == epoch0 # nothing to revoke, so no epoch bump either
|
||||
|
||||
|
||||
def test_an_approved_but_unverified_account_still_cannot_sign_in(client, make_user):
|
||||
# The gate that stops a pre-emptor BEFORE the address is confirmed, whatever the admin decided.
|
||||
# It is not the whole story — confirming the address opens it, which is SEC-16 — but it is the
|
||||
# invariant this test owns, and a change that let an unverified account sign in would be a
|
||||
# straightforwardly worse bug than the residual.
|
||||
make_user(
|
||||
email="notyet@example.com",
|
||||
password="attacker-chosen",
|
||||
email_verified=False,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
r = _login(client, "notyet@example.com", "attacker-chosen")
|
||||
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"]["reason"] == "email_unverified"
|
||||
|
||||
|
||||
def test_verifying_afterwards_lets_the_original_password_in(client, db, make_user):
|
||||
# The payoff for the legitimate registrant: the password chosen at registration still works once
|
||||
# the mailbox is proven — no "your password was cleared for security" detour, no reset round-trip.
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(
|
||||
email="ordering@example.com",
|
||||
password="chosen at registration",
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
)
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
_activate_user_by_email(db, "ordering@example.com") # admin decides FIRST — the common ordering
|
||||
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
||||
db.commit()
|
||||
|
||||
verified = client.post("/auth/verify", json={"token": raw})
|
||||
|
||||
assert verified.status_code == 200
|
||||
assert verified.json() == {
|
||||
"ok": True,
|
||||
"password_reset_required": False,
|
||||
"email": "ordering@example.com",
|
||||
}
|
||||
assert _login(client, "ordering@example.com", "chosen at registration").status_code == 200
|
||||
|
||||
|
||||
def test_activating_a_verified_account_keeps_its_password(db, make_user):
|
||||
# A legit account whose email IS verified (e.g. a no-SMTP registration) keeps its password on
|
||||
# approval — the guard must not break the normal flow.
|
||||
# The no-SMTP shape: registration marks such an account verified up front, so it was never in
|
||||
# scope for the SEC-10 guard even before E9.5 S1. Kept as the other half of the pair.
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
user = make_user(
|
||||
@@ -151,7 +208,9 @@ def _no_smtp(monkeypatch):
|
||||
notified: list[str] = []
|
||||
monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
auth_mod.email_mod, "send_admin_new_request", lambda admins, to: notified.append(to) or True
|
||||
auth_mod.email_mod,
|
||||
"send_admin_new_request",
|
||||
lambda admins, lang, to: notified.append(to) or True,
|
||||
)
|
||||
return notified
|
||||
|
||||
@@ -212,7 +271,7 @@ def test_registration_with_an_already_pending_request_persists_the_account(
|
||||
def test_an_admin_can_activate_a_pre_approved_registrant(db, monkeypatch):
|
||||
# The recovery path for the account above, pinned so it cannot quietly break: re-entering the
|
||||
# address in the Access-requests whitelist box (POST /api/admin/invites) runs
|
||||
# _activate_user_by_email, which is also what keeps the SEC-10 preset-password guard in play.
|
||||
# _activate_user_by_email.
|
||||
from app.models import Invite
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
@@ -225,7 +284,7 @@ def test_an_admin_can_activate_a_pre_approved_registrant(db, monkeypatch):
|
||||
|
||||
db.expire_all()
|
||||
assert user.is_active is True
|
||||
assert user.password_hash is not None # a verified account keeps its own password
|
||||
assert user.password_hash is not None # approval never touches the password (E9.5 S1)
|
||||
|
||||
|
||||
def test_registration_of_an_unknown_email_still_persists_account_and_pending_invite(
|
||||
@@ -271,8 +330,10 @@ def test_registration_of_a_pre_approved_email_persists_the_account_with_smtp(db,
|
||||
|
||||
sent: list[str] = []
|
||||
monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: True)
|
||||
monkeypatch.setattr(auth_mod.email_mod, "send_verify_email", lambda to, link: sent.append(to))
|
||||
monkeypatch.setattr(auth_mod.email_mod, "send_admin_new_request", lambda admins, to: True)
|
||||
monkeypatch.setattr(
|
||||
auth_mod.email_mod, "send_verify_email", lambda to, lang, link, **kw: sent.append(to)
|
||||
)
|
||||
monkeypatch.setattr(auth_mod.email_mod, "send_admin_new_request", lambda admins, lang, to: True)
|
||||
db.add(Invite(email="rita@example.com", status="approved", decided_by="admin@example.com"))
|
||||
db.commit()
|
||||
|
||||
@@ -346,11 +407,24 @@ def test_register_route_is_refused_when_registration_is_closed(client, db, monke
|
||||
# --- SEC-10 lockout: a revoked preset password must stay RECOVERABLE ---------------------------
|
||||
#
|
||||
# Regression pack for the prod incident of 2026-09-02. An account registered with a password, then
|
||||
# approved by an admin BEFORE its email was verified, ended up with password_hash = NULL. That is
|
||||
# also what a Google-only account looks like, and reset eligibility keyed off `password_hash` alone —
|
||||
# so the account could neither sign in (no password) nor obtain a reset link, and every response on
|
||||
# the way was a deliberately uniform "ok"/401. `password_revoked_at` is the trace that tells the two
|
||||
# apart; these pin each step of the way back in.
|
||||
# stripped of it by the SEC-10 guard, ended up with password_hash = NULL. That is also what a
|
||||
# Google-only account looks like, and reset eligibility keyed off `password_hash` alone — so the
|
||||
# account could neither sign in (no password) nor obtain a reset link, and every response on the way
|
||||
# was a deliberately uniform "ok"/401. `password_revoked_at` is the trace that tells the two apart;
|
||||
# these pin each step of the way back in.
|
||||
#
|
||||
# E9.5 S1 removed the admin-approval caller, so Google adoption is the only path that still produces
|
||||
# this state — plus every account stripped on prod before that change, which is why the whole recovery
|
||||
# route below must keep working. These tests manufacture the state through the surviving producer.
|
||||
|
||||
|
||||
def _revoke_as_google_adoption(db, user) -> None:
|
||||
"""Put an account into the SEC-10-revoked state the way the ONLY remaining producer does: the
|
||||
Google callback adopting an account whose email was never verified."""
|
||||
from app.routes.auth import revoke_preset_password_if_unverified
|
||||
|
||||
revoke_preset_password_if_unverified(user)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _capture_reset_links(monkeypatch) -> list[str]:
|
||||
@@ -358,13 +432,15 @@ def _capture_reset_links(monkeypatch) -> list[str]:
|
||||
from app.routes import auth as auth_mod
|
||||
|
||||
sent: list[str] = []
|
||||
monkeypatch.setattr(auth_mod.email_mod, "send_password_reset", lambda to, link: sent.append(to))
|
||||
monkeypatch.setattr(
|
||||
auth_mod.email_mod, "send_password_reset", lambda to, lang, link: sent.append(to)
|
||||
)
|
||||
return sent
|
||||
|
||||
|
||||
def test_the_approval_revoke_stamps_password_revoked_at(db, make_user):
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
|
||||
def test_the_google_adoption_revoke_stamps_password_revoked_at(db, make_user):
|
||||
# The guard still fires where it earns its keep: the real owner has just proven ownership through
|
||||
# Google, so a password set on the address while it was unverified must not survive.
|
||||
victim = make_user(
|
||||
email="stamped@example.com",
|
||||
password="attacker-chosen",
|
||||
@@ -372,27 +448,28 @@ def test_the_approval_revoke_stamps_password_revoked_at(db, make_user):
|
||||
is_active=False,
|
||||
)
|
||||
assert victim.password_revoked_at is None
|
||||
epoch0 = victim.session_epoch or 0
|
||||
|
||||
_activate_user_by_email(db, "stamped@example.com")
|
||||
_revoke_as_google_adoption(db, victim)
|
||||
|
||||
assert victim.password_hash is None
|
||||
assert victim.password_revoked_at is not None # …so the reset flow can tell it HAD a password
|
||||
assert (victim.session_epoch or 0) == epoch0 + 1 # any session that password minted is killed
|
||||
|
||||
|
||||
def test_an_account_whose_password_was_revoked_can_still_get_a_reset_link(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
# THE lockout bug: without the stamp this sent nothing, and the account was unrecoverable.
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
from app.routes.auth import _send_reset_if_eligible
|
||||
|
||||
make_user(
|
||||
victim = make_user(
|
||||
email="lockedout@example.com",
|
||||
password="chosen at registration",
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
)
|
||||
_activate_user_by_email(db, "lockedout@example.com")
|
||||
_revoke_as_google_adoption(db, victim)
|
||||
sent = _capture_reset_links(monkeypatch)
|
||||
|
||||
_send_reset_if_eligible("lockedout@example.com")
|
||||
@@ -414,16 +491,15 @@ def test_a_google_only_account_is_still_not_reset_eligible(db, make_user, monkey
|
||||
|
||||
|
||||
def test_completing_a_reset_clears_the_revocation_stamp(client, db, make_user):
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(
|
||||
email="recovered@example.com",
|
||||
password="chosen at registration",
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
is_active=True, # already approved; reset-confirm verifies the email but never activates
|
||||
)
|
||||
_activate_user_by_email(db, "recovered@example.com")
|
||||
_revoke_as_google_adoption(db, user)
|
||||
raw = _issue_token(db, user, "reset", timedelta(hours=1))
|
||||
db.commit()
|
||||
|
||||
@@ -442,7 +518,6 @@ def test_verify_tells_a_revoked_account_that_it_needs_a_new_password(client, db,
|
||||
# The banner's input. Saying so leaks nothing: the caller just proved mailbox control by holding a
|
||||
# valid single-use verify token. Without it the user meets only the uniform "Invalid email or
|
||||
# password" and has no way to learn why the password they chose stopped working.
|
||||
from app.routes.admin import _activate_user_by_email
|
||||
from app.routes.auth import _issue_token
|
||||
|
||||
user = make_user(
|
||||
@@ -451,14 +526,18 @@ def test_verify_tells_a_revoked_account_that_it_needs_a_new_password(client, db,
|
||||
email_verified=False,
|
||||
is_active=False,
|
||||
)
|
||||
_activate_user_by_email(db, "needspw@example.com")
|
||||
_revoke_as_google_adoption(db, user)
|
||||
raw = _issue_token(db, user, "verify", timedelta(hours=1))
|
||||
db.commit()
|
||||
|
||||
r = client.post("/auth/verify", json={"token": raw})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "password_reset_required": True}
|
||||
assert r.json() == {
|
||||
"ok": True,
|
||||
"password_reset_required": True,
|
||||
"email": "needspw@example.com",
|
||||
}
|
||||
|
||||
|
||||
def test_verify_reports_no_reset_needed_for_an_intact_account(client, db, make_user):
|
||||
@@ -471,4 +550,8 @@ def test_verify_reports_no_reset_needed_for_an_intact_account(client, db, make_u
|
||||
r = client.post("/auth/verify", json={"token": raw})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "password_reset_required": False}
|
||||
assert r.json() == {
|
||||
"ok": True,
|
||||
"password_reset_required": False,
|
||||
"email": "intact@example.com",
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ def test_a_skipped_email_notifies_every_admin(db, make_user):
|
||||
admin_b = make_user(email="admin-b@example.com", role="admin")
|
||||
plain = make_user(email="plain@example.com")
|
||||
|
||||
assert email_mod.send_test("someone@example.com") is False # fail-soft, as before
|
||||
assert email_mod.send_test("someone@example.com", "en") is False # fail-soft, as before
|
||||
|
||||
db.expire_all()
|
||||
for admin in (admin_a, admin_b):
|
||||
@@ -57,7 +57,7 @@ def test_the_notice_is_throttled_so_a_dead_relay_cannot_flood_the_inbox(db, make
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
|
||||
for _ in range(5):
|
||||
email_mod.send_test("someone@example.com")
|
||||
email_mod.send_test("someone@example.com", "en")
|
||||
|
||||
db.expire_all()
|
||||
assert len(_notifications(db, admin.id)) == 1
|
||||
@@ -84,7 +84,7 @@ def test_a_send_failure_is_reported_separately_from_a_missing_config(db, make_us
|
||||
|
||||
monkeypatch.setattr(email_mod.smtplib, "SMTP", _boom)
|
||||
|
||||
assert email_mod.send_test("someone@example.com") is False
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
db.expire_all()
|
||||
(n,) = _notifications(db, admin.id)
|
||||
@@ -96,7 +96,7 @@ def test_no_admins_means_no_notification_and_no_error(db, make_user):
|
||||
# rather than blowing up inside the fail-soft path it is supposed to make visible.
|
||||
plain = make_user(email="plain@example.com")
|
||||
|
||||
assert email_mod.send_test("someone@example.com") is False
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
db.expire_all()
|
||||
assert _notifications(db, plain.id) == []
|
||||
@@ -106,10 +106,10 @@ def test_a_drop_with_nobody_to_tell_does_not_burn_the_hour(db, make_user):
|
||||
# The throttle window must only be spent on a notice that was actually written. Otherwise a drop
|
||||
# during setup — before any admin exists — silences the next hour for the admin created a minute
|
||||
# later, which is exactly the blind spot this alert exists to remove.
|
||||
email_mod.send_test("someone@example.com") # no admins yet
|
||||
email_mod.send_test("someone@example.com", "en") # no admins yet
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
|
||||
assert email_mod.send_test("someone@example.com") is False
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
db.expire_all()
|
||||
assert len(_notifications(db, admin.id)) == 1
|
||||
@@ -127,10 +127,10 @@ def test_a_failed_alert_does_not_burn_the_hour_either(db, make_user, monkeypatch
|
||||
return real(*a, **kw)
|
||||
|
||||
monkeypatch.setattr(email_mod, "create_notification", _maybe_boom)
|
||||
email_mod.send_test("someone@example.com") # the write blows up; nothing recorded
|
||||
email_mod.send_test("someone@example.com", "en") # the write blows up; nothing recorded
|
||||
boom["on"] = False
|
||||
|
||||
assert email_mod.send_test("someone@example.com") is False
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
db.expire_all()
|
||||
assert len(_notifications(db, admin.id)) == 1
|
||||
@@ -146,4 +146,4 @@ def test_a_broken_alert_never_breaks_the_send(db, make_user, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(email_mod, "create_notification", _boom)
|
||||
|
||||
assert email_mod.send_test("someone@example.com") is False
|
||||
assert email_mod.send_test("someone@example.com", "en") is False
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"""One mailbox, many spellings — the canonical-email identity rule (E9.5 S3).
|
||||
|
||||
Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE inbox. The invite gate
|
||||
used to compare literal strings, so on 2026-09-03 an approved user's Google sign-in became a silent
|
||||
SECOND access request: they had registered as `npeter83.temu@gmail.com`, and OAuth returned the
|
||||
account's stored primary `npeter83temu@gmail.com`. The `+tag` case fails every time — a Google account
|
||||
cannot contain `+`, so OAuth can only ever answer with the base address.
|
||||
|
||||
The rule is narrow on purpose: dots are significant on most providers, and the Gmail dot rule does not
|
||||
hold for Google Workspace custom domains unless their admin enabled it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select, text
|
||||
|
||||
_UAT_PW = "correct horse battery"
|
||||
|
||||
from app.auth import is_allowed
|
||||
from app.models import Invite, User
|
||||
from app.utils import canonical_email
|
||||
|
||||
# (input, expected canonical) — the matrix the sprint promised, plus the shapes that must NOT fold.
|
||||
CASES = [
|
||||
# Gmail: dots and +tags are noise…
|
||||
("npeter83.temu@gmail.com", "npeter83temu@gmail.com"),
|
||||
("npeter83temu@gmail.com", "npeter83temu@gmail.com"),
|
||||
("n.p.e.t.e.r@gmail.com", "npeter@gmail.com"),
|
||||
("user+siftlode@gmail.com", "user@gmail.com"),
|
||||
("u.ser+a+b@gmail.com", "user@gmail.com"),
|
||||
("USer.Name@GMail.COM", "username@gmail.com"),
|
||||
# …and googlemail is the same mailbox as gmail.
|
||||
("user.name@googlemail.com", "username@gmail.com"),
|
||||
# Everyone else: lowercase ONLY. Dots stay significant.
|
||||
("first.last@example.com", "first.last@example.com"),
|
||||
("First.Last@Example.COM", "first.last@example.com"),
|
||||
("first+tag@example.com", "first+tag@example.com"),
|
||||
# A Workspace domain is not Gmail, however Gmail-ish it looks.
|
||||
("first.last@gmail.company.com", "first.last@gmail.company.com"),
|
||||
# Degenerate input must not explode or invent an address.
|
||||
("", ""),
|
||||
(None, ""),
|
||||
("not-an-email", "not-an-email"),
|
||||
(".@gmail.com", ".@gmail.com"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", CASES)
|
||||
def test_canonical_email(raw, expected):
|
||||
assert canonical_email(raw) == expected
|
||||
|
||||
|
||||
def test_the_migration_sql_agrees_with_the_helper(db):
|
||||
"""The 0079 backfill reimplements canonical_email in SQL (a migration must not import app code
|
||||
that keeps changing). That duplication is only safe while the two agree, so pin them together —
|
||||
a drift here means rows backfilled into a form the application will never look up."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0079_email_canonical.py"
|
||||
spec = importlib.util.spec_from_file_location("m0079", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
sql = module._CANON_SQL.format(col=":addr")
|
||||
for raw, expected in CASES:
|
||||
if raw is None:
|
||||
continue # the column is NOT NULL; the backfill skips NULL emails explicitly
|
||||
got = db.execute(text(f"SELECT {sql}"), {"addr": raw}).scalar_one()
|
||||
assert got == expected, f"SQL and helper disagree on {raw!r}: {got!r} != {expected!r}"
|
||||
|
||||
|
||||
def test_the_column_is_kept_in_sync_by_writing_the_email(db):
|
||||
"""No write site has to remember: the model validator derives it. If this breaks, every lookup
|
||||
below silently stops matching."""
|
||||
user = User(email="Dotted.Name+tag@GoogleMail.com")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
assert user.email == "Dotted.Name+tag@GoogleMail.com" # stored AS TYPED — mail goes here
|
||||
assert user.email_canonical == "dottedname@gmail.com" # …matched on this
|
||||
|
||||
user.email = "someone.else@example.com"
|
||||
db.commit()
|
||||
assert user.email_canonical == "someone.else@example.com"
|
||||
|
||||
|
||||
def test_an_approval_covers_every_spelling_of_the_same_gmail_mailbox(db):
|
||||
# THE 2026-09-03 lockout, in one assertion: approved under the dotted spelling, signing in with
|
||||
# the primary Google returns.
|
||||
db.add(Invite(email="npeter83.temu@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "npeter83temu@gmail.com") is True
|
||||
assert is_allowed(db, "npeter83.temu@gmail.com") is True
|
||||
assert is_allowed(db, "npeter83.temu+throwaway@gmail.com") is True
|
||||
|
||||
|
||||
def test_a_plus_tagged_registration_is_not_locked_out_after_approval(db):
|
||||
# The case that ALWAYS broke: a Google account cannot contain `+`, so OAuth can only return the
|
||||
# base address, and a literal match could never find the invite.
|
||||
db.add(Invite(email="someone+siftlode@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "someone@gmail.com") is True
|
||||
|
||||
|
||||
def test_dots_stay_significant_outside_gmail(db):
|
||||
db.add(Invite(email="first.last@example.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "first.last@example.com") is True
|
||||
assert is_allowed(db, "firstlast@example.com") is False # a DIFFERENT mailbox elsewhere
|
||||
|
||||
|
||||
def test_an_approved_row_wins_over_a_denied_sibling(db):
|
||||
# Prod carries approved pairs for one mailbox (oldest 2026-06-16). The API cannot flip an approved
|
||||
# invite to denied, so approved-beside-denied means the admin approved that mailbox under another
|
||||
# spelling — not that they revoked it. Revoking is deleting the row or suspending the account.
|
||||
db.add(Invite(email="two.spellings@gmail.com", status="denied"))
|
||||
db.add(Invite(email="twospellings@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "twospellings@gmail.com") is True
|
||||
|
||||
|
||||
def test_a_denied_mailbox_stays_denied(db):
|
||||
db.add(Invite(email="no.thanks@gmail.com", status="denied"))
|
||||
db.commit()
|
||||
|
||||
assert is_allowed(db, "nothanks@gmail.com") is False
|
||||
|
||||
|
||||
def test_a_second_spelling_does_not_open_a_second_access_request(db):
|
||||
"""The other half of the 2026-09-03 report: the admin queue got a duplicate request for a user
|
||||
who was already approved."""
|
||||
from app.routes.auth import upsert_pending_invite
|
||||
|
||||
db.add(Invite(email="already.approved@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
req = upsert_pending_invite(db, "alreadyapproved@gmail.com")
|
||||
|
||||
assert req.is_new is False # nothing new to notify an admin about
|
||||
assert req.invite is not None and req.invite.email == "already.approved@gmail.com"
|
||||
assert len(db.execute(select(Invite)).scalars().all()) == 1
|
||||
|
||||
|
||||
def test_manually_whitelisting_another_spelling_reuses_the_row(db):
|
||||
"""How prod ended up with hartmann.peter1986@ AND hartmannpeter1986@ both approved on the same
|
||||
day: the admin typed the other spelling and got a second row instead of a no-op."""
|
||||
from app.routes.admin import add_invite, AddInviteIn
|
||||
|
||||
admin = User(email="admin@example.com", role="admin", is_active=True, email_verified=True)
|
||||
db.add(admin)
|
||||
db.add(Invite(email="hartmann.peter1986@gmail.com", status="approved"))
|
||||
db.commit()
|
||||
|
||||
add_invite(AddInviteIn(email="hartmannpeter1986@gmail.com"), admin=admin, db=db)
|
||||
|
||||
rows = db.execute(select(Invite)).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].email == "hartmann.peter1986@gmail.com" # the original spelling is preserved
|
||||
|
||||
|
||||
def _capture_reset_links(monkeypatch) -> list[str]:
|
||||
"""Patch the reset mailer and collect the recipients it is asked to write to."""
|
||||
from app.routes import auth as auth_mod
|
||||
|
||||
sent: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
auth_mod.email_mod, "send_password_reset", lambda to, lang, link: sent.append(to)
|
||||
)
|
||||
return sent
|
||||
|
||||
|
||||
# --- signing in and recovering an account, whichever spelling of it the person types -------------
|
||||
#
|
||||
# The three flows that look an account up BY TYPED ADDRESS used to match literally, so a Gmail user
|
||||
# who registered `x.y@` and typed `xy@` met "Invalid email or password" at sign-in — and, worse,
|
||||
# total silence from password-reset and verify-resend, which answer uniformly by design.
|
||||
|
||||
|
||||
def test_the_password_works_with_any_spelling_of_the_same_mailbox(db):
|
||||
from app.auth import find_user_by_email
|
||||
|
||||
make = User(
|
||||
email="t.e.st+tag@gmail.com", email_verified=True, is_active=True, password_hash="x"
|
||||
)
|
||||
db.add(make)
|
||||
db.commit()
|
||||
|
||||
for typed in (
|
||||
"t.e.st+tag@gmail.com", # as registered
|
||||
"test@gmail.com", # the mailbox itself
|
||||
"t.e.st@gmail.com", # dots only
|
||||
"TEST@googlemail.com", # the alias domain
|
||||
):
|
||||
assert find_user_by_email(db, typed) is make, typed
|
||||
|
||||
|
||||
def test_an_unrelated_address_still_finds_nothing(db):
|
||||
from app.auth import find_user_by_email
|
||||
|
||||
db.add(User(email="someone@gmail.com", email_verified=True, is_active=True))
|
||||
db.commit()
|
||||
|
||||
assert find_user_by_email(db, "someoneelse@gmail.com") is None
|
||||
assert find_user_by_email(db, "some.one@example.com") is None # dots matter off Gmail
|
||||
|
||||
|
||||
def test_the_exact_spelling_wins_when_two_accounts_share_a_mailbox(db):
|
||||
# Prod carries such a pair (ids 13 and 14). Whoever types their own address in full must land on
|
||||
# their own account, not on whichever row happens to come first.
|
||||
from app.auth import find_user_by_email
|
||||
|
||||
dotted = User(email="npeter83.temu@gmail.com", email_verified=True, is_active=True)
|
||||
plain = User(email="npeter83temu@gmail.com", email_verified=True, is_active=True)
|
||||
db.add_all([dotted, plain])
|
||||
db.commit()
|
||||
|
||||
assert find_user_by_email(db, "npeter83.temu@gmail.com") is dotted
|
||||
assert find_user_by_email(db, "npeter83temu@gmail.com") is plain
|
||||
|
||||
|
||||
def test_an_ambiguous_spelling_refuses_rather_than_guessing(db):
|
||||
# Neither spelling is exact, and both are the same mailbox. Handing a password sign-in one of
|
||||
# them at random would let someone into the wrong account, so this answers "no such account" —
|
||||
# the same uniform reply the caller already gives.
|
||||
from app.auth import find_user_by_email
|
||||
|
||||
db.add(User(email="npeter83.temu@gmail.com", email_verified=True, is_active=True))
|
||||
db.add(User(email="npeter83temu@gmail.com", email_verified=True, is_active=True))
|
||||
db.commit()
|
||||
|
||||
assert find_user_by_email(db, "n.peter83.temu+x@gmail.com") is None
|
||||
|
||||
|
||||
def test_a_reset_link_goes_to_the_address_on_the_account(db, monkeypatch):
|
||||
"""Typing another spelling must still reach the owner — and the mail must go to the address they
|
||||
registered with, tag and all, not to the one typed into the form."""
|
||||
from app.routes.auth import _send_reset_if_eligible
|
||||
|
||||
db.add(
|
||||
User(
|
||||
email="me+siftlode@gmail.com",
|
||||
email_verified=True,
|
||||
is_active=True,
|
||||
password_hash="x",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
sent = _capture_reset_links(monkeypatch)
|
||||
|
||||
_send_reset_if_eligible("me@gmail.com")
|
||||
|
||||
assert sent == ["me+siftlode@gmail.com"]
|
||||
|
||||
|
||||
# --- a Google sign-in must not rewrite the address its owner chose ------------------------------
|
||||
|
||||
|
||||
def test_a_google_sign_in_keeps_a_plus_tagged_address(db):
|
||||
"""People register as `me+siftlode@gmail.com` so a later leak from this site is identifiable.
|
||||
Google returns the account's primary address, which can never contain `+`, so following it
|
||||
blindly destroyed the tag on the first Google sign-in — and, since sign-in matches the stored
|
||||
address first, stopped them signing in with the address they had chosen."""
|
||||
from app.routes.auth import _sync_google_email
|
||||
|
||||
user = User(email="me+siftlode@gmail.com", email_verified=True, is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
_sync_google_email(db, user, "me@gmail.com")
|
||||
|
||||
assert user.email == "me+siftlode@gmail.com"
|
||||
|
||||
|
||||
def test_a_google_sign_in_follows_a_real_mailbox_change(db):
|
||||
# The case the sync exists for: the Google account genuinely moved to a different mailbox.
|
||||
from app.routes.auth import _sync_google_email
|
||||
|
||||
user = User(email="old.address@gmail.com", email_verified=True, is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
_sync_google_email(db, user, "brand-new@example.com")
|
||||
|
||||
assert user.email == "brand-new@example.com"
|
||||
|
||||
|
||||
def test_a_mailbox_change_is_refused_when_another_account_owns_it(db):
|
||||
from app.routes.auth import _sync_google_email
|
||||
|
||||
db.add(User(email="taken@example.com", email_verified=True, is_active=True))
|
||||
user = User(email="mover@gmail.com", email_verified=True, is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
_sync_google_email(db, user, "taken@example.com")
|
||||
|
||||
assert user.email == "mover@gmail.com" # unique constraint would have 500'd the sign-in
|
||||
|
||||
|
||||
# --- showing BOTH addresses, once there are two -------------------------------------------------
|
||||
|
||||
|
||||
def test_the_google_address_is_recorded_beside_the_registered_one(db):
|
||||
"""Since the sign-in no longer overwrites the registered address, the account has two true
|
||||
addresses and only one was stored. This is the other half, for the UI to name."""
|
||||
from app.routes.auth import _sync_google_email
|
||||
|
||||
user = User(email="me+siftlode@gmail.com", email_verified=True, is_active=True)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
user.google_email = "me@gmail.com"
|
||||
_sync_google_email(db, user, "me@gmail.com")
|
||||
db.commit()
|
||||
|
||||
assert user.email == "me+siftlode@gmail.com" # still theirs
|
||||
assert user.google_email == "me@gmail.com" # …and now we know the other one
|
||||
|
||||
|
||||
def test_me_reports_the_google_address_only_when_it_differs(client, db, make_user):
|
||||
"""The comparison lives in one place so no surface has to repeat it — and so the account menu
|
||||
stays exactly as it is for the majority, whose two addresses are the same string."""
|
||||
same = make_user(email="plain@gmail.com", password=_UAT_PW)
|
||||
same.google_email = "plain@gmail.com"
|
||||
db.commit()
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/password-login", json={"email": "plain@gmail.com", "password": _UAT_PW}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.get("/api/me").json()["google_email"] is None
|
||||
|
||||
client.post("/auth/logout")
|
||||
tagged = make_user(email="me+siftlode@gmail.com", password=_UAT_PW)
|
||||
tagged.google_email = "me@gmail.com"
|
||||
db.commit()
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/password-login", json={"email": "me+siftlode@gmail.com", "password": _UAT_PW}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert client.get("/api/me").json()["google_email"] == "me@gmail.com"
|
||||
@@ -103,7 +103,9 @@ def test_the_sent_message_is_multipart_with_the_plain_body_intact(monkeypatch):
|
||||
monkeypatch.setattr(email_mod.smtplib, "SMTP", _FakeSMTP)
|
||||
|
||||
assert (
|
||||
email_mod.send_verify_email("someone@example.com", "https://siftlode.example/#verify=tok")
|
||||
email_mod.send_verify_email(
|
||||
"someone@example.com", "en", "https://siftlode.example/#verify=tok"
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""What a refused sign-in SAYS (E9.5 S4), DB-lane.
|
||||
|
||||
The states behind a 403 were true and useless: "Your account is awaiting admin approval." never
|
||||
revealed WHICH mailbox the account was on. On 2026-09-03 a registration as `npeter83.temu@gmail.com`
|
||||
met a Google claim of `npeter83temu@gmail.com`, and that missing address hid the mismatch for a day.
|
||||
So these pin the DETAIL, not just the status code — the address, and both gates, since E9.5 S1 made
|
||||
the two order-independent and the screen must report whichever is actually outstanding.
|
||||
|
||||
The two refusals that answer BEFORE the password is proven carry nothing, and that is the security
|
||||
half of the same contract: `invalid` must stay byte-identical for a real account and an unknown one.
|
||||
"""
|
||||
|
||||
from app.config import settings
|
||||
from app.models import Invite
|
||||
from app.routes.auth import _access_notice_url, upsert_pending_invite
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _login(client, email, password=_PW):
|
||||
return client.post("/auth/password-login", json={"email": email, "password": password})
|
||||
|
||||
|
||||
def test_pending_approval_names_the_address_the_account_is_on(client, make_user):
|
||||
# The typed spelling and the stored one are the same mailbox; the screen must show the STORED
|
||||
# one, because that is the mismatch a user cannot otherwise see.
|
||||
make_user(email="np.temu@gmail.com", password=_PW, is_active=False)
|
||||
|
||||
detail = _login(client, "nptemu@gmail.com").json()["detail"]
|
||||
|
||||
assert detail["reason"] == "pending_approval"
|
||||
assert detail["email"] == "np.temu@gmail.com"
|
||||
assert detail["email_verified"] is True and detail["approved"] is False
|
||||
|
||||
|
||||
def test_an_already_approved_account_still_reports_its_missing_verification(client, make_user):
|
||||
# S1 made the gates order-independent, so this really happens: approved, not yet verified. A
|
||||
# screen that assumed a sequence would tell this user to wait for an admin who already decided.
|
||||
make_user(
|
||||
email="approved.first@example.com", password=_PW, email_verified=False, is_active=True
|
||||
)
|
||||
|
||||
detail = _login(client, "approved.first@example.com").json()["detail"]
|
||||
|
||||
assert detail["reason"] == "email_unverified"
|
||||
assert detail["email_verified"] is False and detail["approved"] is True
|
||||
|
||||
|
||||
def test_a_suspended_account_is_told_who_to_contact(client, make_user, monkeypatch):
|
||||
monkeypatch.setattr(settings, "admin_emails", "ops@example.com")
|
||||
make_user(email="banned@example.com", password=_PW, is_suspended=True)
|
||||
|
||||
r = _login(client, "banned@example.com")
|
||||
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"]["reason"] == "suspended"
|
||||
assert r.json()["detail"]["operator"] == "ops@example.com"
|
||||
|
||||
|
||||
def test_a_refusal_before_the_password_is_proven_carries_no_account_detail(client, make_user):
|
||||
make_user(email="real@example.com", password=_PW, is_active=False)
|
||||
|
||||
wrong_pw = _login(client, "real@example.com", "not the password").json()["detail"]
|
||||
unknown = _login(client, "nobody@example.com").json()["detail"]
|
||||
|
||||
# No address, no flags — the pending state above must not leak through the wrong-password door.
|
||||
assert wrong_pw == unknown == {"code": "login", "reason": "invalid"}
|
||||
|
||||
|
||||
# --- the access-request notice -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_access_notice_names_the_address_and_rides_the_fragment():
|
||||
url = _access_notice_url("requested", "someone@example.com")
|
||||
|
||||
# A fragment never reaches a proxy access log or a Referer header — the same reasoning that put
|
||||
# the verify/reset tokens there (SB3), applied to an address.
|
||||
assert url == "/#access=requested&email=someone%40example.com"
|
||||
|
||||
|
||||
def test_a_plus_tagged_address_survives_the_round_trip():
|
||||
# URLSearchParams reads a bare `+` in a fragment as a SPACE, so the encoder must emit %2B — or
|
||||
# the very address family this epic exists to protect arrives mangled.
|
||||
url = _access_notice_url("requested", "me+siftlode@gmail.com")
|
||||
|
||||
assert "me%2Bsiftlode%40gmail.com" in url and "+siftlode" not in url
|
||||
|
||||
|
||||
def test_a_request_already_on_file_under_another_spelling_says_so(db):
|
||||
db.add(Invite(email="hartmann.peter@gmail.com", status="pending"))
|
||||
db.commit()
|
||||
|
||||
req = upsert_pending_invite(db, "hartmannpeter@gmail.com")
|
||||
url = _access_notice_url("requested", "hartmannpeter@gmail.com", req.invite)
|
||||
|
||||
assert req.is_new is False # one mailbox, one request — no second row for the admin queue
|
||||
assert "linked=hartmann.peter%40gmail.com" in url
|
||||
|
||||
|
||||
def test_the_same_spelling_twice_is_just_a_repeat_and_says_nothing_extra(db):
|
||||
db.add(Invite(email="same@example.com", status="pending"))
|
||||
db.commit()
|
||||
|
||||
req = upsert_pending_invite(db, "same@example.com")
|
||||
|
||||
# Nothing to explain: telling this user we "linked" their request to itself would be noise.
|
||||
assert "linked" not in _access_notice_url("requested", "same@example.com", req.invite)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""E9.5 S7 — every outbound mail is written in the reader's language.
|
||||
|
||||
Two halves. The first is the catalog invariant: HU/EN parity with identical placeholders, enforced
|
||||
here because a mail is the one user-facing surface nobody can re-render by switching the UI language
|
||||
— it arrives once, in whatever language we picked. The second is the resolution chain: which
|
||||
language a given recipient actually gets, and where that signal was captured, since for an address
|
||||
with no account it exists only at the moment they asked us.
|
||||
"""
|
||||
|
||||
import re
|
||||
import string
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import mailtext
|
||||
from app.models import Invite, User
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _placeholders(template: str) -> set[str]:
|
||||
return {name for _, name, _, _ in string.Formatter().parse(template) if name}
|
||||
|
||||
|
||||
# --- the catalog itself ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT))
|
||||
def test_every_mail_string_exists_in_both_languages(key):
|
||||
assert set(mailtext.MAIL_TEXT[key]) == set(mailtext.SUPPORTED)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT))
|
||||
def test_every_translation_takes_the_same_placeholders(key):
|
||||
# The parity that actually bites: a translation that drops `{link}` renders a mail with no link
|
||||
# in it, and a translation that invents one raises KeyError inside a background task nobody is
|
||||
# watching. Both are silent in production and loud here.
|
||||
variants = mailtext.MAIL_TEXT[key]
|
||||
expected = _placeholders(variants[mailtext.DEFAULT])
|
||||
for lang, template in variants.items():
|
||||
assert _placeholders(template) == expected, f"{key} [{lang}]"
|
||||
|
||||
|
||||
def _app_sources_without_the_catalog() -> str:
|
||||
"""Every app source, with the catalog LITERAL cut out of mailtext.py.
|
||||
|
||||
Cutting it is the whole point: the dict spells each key as a quoted literal, so a scan that
|
||||
included it reported every key as used no matter what the catalog held. Cutting the whole FILE
|
||||
is too much, though — `link_expiry` legitimately asks for `duration.*`, and those keys would
|
||||
then look orphaned."""
|
||||
from pathlib import Path
|
||||
|
||||
app_dir = Path(__file__).resolve().parents[1] / "app"
|
||||
out = []
|
||||
for f in app_dir.rglob("*.py"):
|
||||
src = f.read_text(encoding="utf-8")
|
||||
if f.name == "mailtext.py":
|
||||
src = src[: src.index("MAIL_TEXT: dict")]
|
||||
out.append(src)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def test_no_catalog_entry_is_orphaned():
|
||||
"""Every key is referenced from the app, so a mail that stopped being sent can't leave its text
|
||||
behind to be 'kept up to date' by the next person who greps for it."""
|
||||
sources = _app_sources_without_the_catalog()
|
||||
assert [k for k in mailtext.MAIL_TEXT if f'"{k}"' not in sources] == []
|
||||
|
||||
|
||||
def test_every_key_the_app_asks_for_exists():
|
||||
"""The other direction, and the one that raises in production: `text()` looks the key up in a
|
||||
dict, so a renamed or mistyped key is a KeyError inside a background task with no user in sight."""
|
||||
asked = set(re.findall(r'text\(\s*"([a-z_]+\.[a-z_]+)"', _app_sources_without_the_catalog()))
|
||||
assert asked, "the scan found no mailtext.text(...) call sites — the pattern has rotted"
|
||||
assert sorted(asked - set(mailtext.MAIL_TEXT)) == []
|
||||
|
||||
|
||||
def test_an_unknown_language_falls_back_to_english_instead_of_raising():
|
||||
# `preferences.language` is free-form JSON written by older builds and by locales we may stop
|
||||
# shipping. A send that raised on one would take out a background task with no user in sight.
|
||||
assert mailtext.text("test.subject", "de") == mailtext.text("test.subject", "en")
|
||||
assert mailtext.text("test.subject", None) == mailtext.text("test.subject", "en")
|
||||
|
||||
|
||||
# --- who gets which language ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_users_own_preference_decides_their_language(make_user):
|
||||
hu = make_user(email="hu@example.com", preferences={"language": "hu"})
|
||||
en = make_user(email="en@example.com", preferences={"language": "en"})
|
||||
none = make_user(email="none@example.com", preferences=None)
|
||||
|
||||
assert mailtext.lang_for_user(hu) == "hu"
|
||||
assert mailtext.lang_for_user(en) == "en"
|
||||
assert mailtext.lang_for_user(none) == "en"
|
||||
|
||||
|
||||
def test_an_address_with_no_account_falls_back_to_the_language_it_asked_in(db):
|
||||
db.add(Invite(email="asked@example.com", status="pending", lang="hu"))
|
||||
db.commit()
|
||||
|
||||
assert mailtext.lang_for_email(db, "asked@example.com") == "hu"
|
||||
|
||||
|
||||
def test_the_account_wins_over_the_invite_row(db, make_user):
|
||||
make_user(email="both@example.com", preferences={"language": "en"})
|
||||
db.add(Invite(email="both@example.com", status="approved", lang="hu"))
|
||||
db.commit()
|
||||
|
||||
# The invite records how they asked once; the account records what they have since chosen.
|
||||
assert mailtext.lang_for_email(db, "both@example.com") == "en"
|
||||
|
||||
|
||||
def test_a_row_that_never_recorded_a_language_is_walked_past(db, make_user):
|
||||
# Prod carries TWO accounts for one Gmail mailbox (the 2026-09-03 pair). Stopping at the first
|
||||
# row would answer English for a person whose other account says Hungarian.
|
||||
make_user(email="dup.one@gmail.com", preferences={})
|
||||
make_user(email="dupone@gmail.com", preferences={"language": "hu"})
|
||||
|
||||
assert mailtext.lang_for_email(db, "dup.one@gmail.com") == "hu"
|
||||
|
||||
|
||||
def test_the_lookup_is_canonical_like_every_other_identity_match(db, make_user):
|
||||
make_user(email="canon.match@gmail.com", preferences={"language": "hu"})
|
||||
|
||||
assert mailtext.lang_for_email(db, "canonmatch+tag@gmail.com") == "hu"
|
||||
|
||||
|
||||
# --- capturing the signal at the one moment it exists ---------------------------------------------
|
||||
|
||||
|
||||
def test_registration_records_the_language_of_the_sign_up_page(db, client):
|
||||
r = client.post(
|
||||
"/auth/register", json={"email": "signup@example.com", "password": _PW, "lang": "hu"}
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
user = db.execute(select(User).where(User.email == "signup@example.com")).scalar_one()
|
||||
assert (user.preferences or {}).get("language") == "hu"
|
||||
|
||||
|
||||
def test_an_access_request_records_the_language_it_was_made_in(db, client):
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
assert inv.lang == "hu"
|
||||
|
||||
|
||||
def test_asking_again_from_another_language_updates_the_row(db, client):
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
|
||||
|
||||
db.expire_all()
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
# The newest thing they told us wins — and there is still exactly one row (S3's canonical upsert).
|
||||
assert inv.lang == "hu"
|
||||
assert db.execute(select(Invite)).scalars().all() == [inv]
|
||||
|
||||
|
||||
def test_an_unsupported_language_is_ignored_rather_than_stored(db, client):
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "../../etc"})
|
||||
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
assert inv.lang is None
|
||||
|
||||
|
||||
# --- the mails themselves -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bodies(monkeypatch):
|
||||
"""Capture what `_send` would have put on the wire, rather than the arguments a caller passed —
|
||||
the language only matters if it reaches the body."""
|
||||
from app import email as email_mod
|
||||
|
||||
out: list[tuple[list[str], str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
email_mod,
|
||||
"_send",
|
||||
lambda to, subject, body, reply_to=None: out.append((to, subject, body)) is None,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def test_the_confirmation_mail_is_written_in_the_accounts_language(db, client, monkeypatch):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
|
||||
client.post("/auth/register", json={"email": "hu@example.com", "password": _PW, "lang": "hu"})
|
||||
|
||||
(_, subject, body) = sent[0]
|
||||
assert subject == mailtext.MAIL_TEXT["verify_email.subject"]["hu"]
|
||||
assert "#verify=" in body
|
||||
|
||||
|
||||
def test_the_approval_mail_answers_in_the_language_the_request_was_made_in(
|
||||
db, client, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
sent.clear() # drop the admin notification; this test is about the reply to the requester
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve")
|
||||
|
||||
assert r.status_code == 200
|
||||
(to, subject, _) = sent[0]
|
||||
assert to == ["asking@example.com"]
|
||||
assert subject == mailtext.MAIL_TEXT["access_approved.subject"]["hu"]
|
||||
|
||||
|
||||
def test_a_denial_answers_in_that_language_too(db, client, make_user, as_user, monkeypatch):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
|
||||
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
|
||||
sent.clear()
|
||||
|
||||
as_user(admin).post(f"/api/admin/invites/{inv.id}/deny")
|
||||
|
||||
assert sent[0][1] == mailtext.MAIL_TEXT["access_denied.subject"]["hu"]
|
||||
|
||||
|
||||
def test_the_admins_pick_decides_an_invitation_and_is_remembered(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
# Hand-whitelisted: nobody has ever told us anything about this mailbox, which is exactly why
|
||||
# the send button asks.
|
||||
inv = Invite(email="stranger@example.com", status="approved")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite", json={"lang": "hu"})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["hu"]
|
||||
db.expire_all()
|
||||
# Recorded, so a later mail to this address matches the one the admin already sent.
|
||||
assert db.get(Invite, inv.id).lang == "hu"
|
||||
|
||||
|
||||
def test_an_invitation_without_a_pick_still_goes_out(db, make_user, as_user, monkeypatch):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
sent = _bodies(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="stranger@example.com", status="approved")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["en"]
|
||||
db.expire_all()
|
||||
# Nothing was picked, so nothing is remembered: the column means "what this person told us",
|
||||
# not "what we last defaulted to".
|
||||
assert db.get(Invite, inv.id).lang is None
|
||||
|
||||
|
||||
def test_admins_are_notified_in_their_own_languages(db, make_user):
|
||||
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
|
||||
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
|
||||
|
||||
groups = auth_routes.admin_notify_groups(db)
|
||||
|
||||
# One mail per language, not one blast that has to be wrong for somebody.
|
||||
assert groups == {"hu": ["hu.admin@example.com"], "en": ["en.admin@example.com"]}
|
||||
@@ -0,0 +1,90 @@
|
||||
"""What a dead email link says, and what it costs to ask (E9.5 S4 follow-up), DB-lane.
|
||||
|
||||
Reported 2026-09-05: delete an account, then open its password-reset link. The card drew a full
|
||||
"set a new password" form — because a `#reset=` fragment was present, nothing more — and only after
|
||||
the user typed a password did the server answer 400. No security hole (the account's tokens cascade
|
||||
away with it, so nothing can be set on a deleted or a re-created account), but a dead end of exactly
|
||||
the kind this epic exists to remove.
|
||||
|
||||
The fix is a check that does NOT spend the token, so the pre-flight can never cost the user their
|
||||
one real attempt. That is the property most worth pinning here.
|
||||
"""
|
||||
|
||||
from app.routes.auth import RESET_TTL, _issue_token
|
||||
|
||||
_PW = "correct horse battery"
|
||||
_NEW = "brand new password"
|
||||
|
||||
|
||||
def _issue(db, user):
|
||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||
db.commit()
|
||||
return raw
|
||||
|
||||
|
||||
def test_checking_a_live_link_does_not_spend_it(client, db, make_user):
|
||||
raw = _issue(db, make_user(email="live@example.com", password=_PW))
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
|
||||
|
||||
# The whole point: after any number of checks the one real attempt is still there.
|
||||
assert client.post(
|
||||
"/auth/password-reset/confirm", json={"token": raw, "password": _NEW}
|
||||
).json() == {"ok": True}
|
||||
|
||||
|
||||
def test_a_link_whose_account_was_deleted_is_reported_dead(client, db, make_user):
|
||||
user = make_user(email="gone@example.com", password=_PW)
|
||||
raw = _issue(db, user)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
|
||||
|
||||
# And the confirm agrees, in the structured shape the card renders as a named screen — the two
|
||||
# must never disagree about one token.
|
||||
r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW})
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == {"code": "reset", "reason": "invalid"}
|
||||
|
||||
|
||||
def test_a_spent_link_is_reported_dead(client, db, make_user):
|
||||
raw = _issue(db, make_user(email="spent@example.com", password=_PW))
|
||||
client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW})
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
|
||||
|
||||
|
||||
def test_a_too_short_password_does_not_burn_the_link(client, db, make_user):
|
||||
raw = _issue(db, make_user(email="short@example.com", password=_PW))
|
||||
|
||||
r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": "short"})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["reason"] == "weak_password"
|
||||
# A mistyped password must not cost the link — the length check runs BEFORE the token is spent,
|
||||
# and the card tells the two failures apart by `reason` so only one of them takes the form down.
|
||||
assert client.post(
|
||||
"/auth/password-reset/confirm", json={"token": raw, "password": _NEW}
|
||||
).json() == {"ok": True}
|
||||
|
||||
|
||||
def test_a_dead_verification_link_answers_in_the_same_structured_shape(client):
|
||||
r = client.post("/auth/verify", json={"token": "no-such-token"})
|
||||
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == {"code": "verify", "reason": "invalid"}
|
||||
|
||||
|
||||
def test_the_check_never_reports_a_live_link_dead_when_throttled(
|
||||
client, db, make_user, monkeypatch
|
||||
):
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
raw = _issue(db, make_user(email="throttled@example.com", password=_PW))
|
||||
monkeypatch.setattr(auth_routes._reset_check_limiter, "allow", lambda _ip: False)
|
||||
|
||||
# Fail OPEN. This check may only ever save a wasted form; a throttled probe that answered "dead"
|
||||
# would lock someone out of a link that works.
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
|
||||
@@ -0,0 +1,350 @@
|
||||
"""E9.5 review round — the invariants the epic's own review found unpinned.
|
||||
|
||||
Every test here corresponds to a defect a reviewer could still introduce with the whole suite green:
|
||||
a mail whose body never renders in any test, an activation that only works for the exact spelling, a
|
||||
"we already wrote to them" stamp for a mail that was dropped, an env allow-list that answers a
|
||||
different question than the gate it backs, and the address pre-emption that E9.5 S1's argument
|
||||
covered only while the address stayed unverified.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import auth as auth_core
|
||||
from app import email as email_mod
|
||||
from app import mailtext
|
||||
from app.config import settings
|
||||
from app.models import AuthToken, Invite, User
|
||||
from app.routes import auth as auth_routes
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wire(monkeypatch):
|
||||
"""Capture what `_send` would have put on the wire. Mails are the one surface where a body that
|
||||
never renders in a test is a body that first renders in someone's inbox."""
|
||||
out: list[tuple[list[str], str, str]] = []
|
||||
monkeypatch.setattr(
|
||||
email_mod,
|
||||
"_send",
|
||||
lambda to, subject, body, reply_to=None: out.append((to, subject, body)) is None,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smtp_on(monkeypatch):
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
|
||||
|
||||
# --- every mail actually renders ------------------------------------------------------------------
|
||||
|
||||
# One call per `send_*`, with arguments of the right shape. The parity test compares HU against EN
|
||||
# and can therefore never catch a template whose placeholders disagree with its CALLER — that
|
||||
# mismatch is a KeyError inside a background task, i.e. a mail that silently never goes out. Three
|
||||
# mailers this epic rewrote (`password_reset`, `role_changed`, `account_unsuspended`) had no test
|
||||
# that executed their `.format()` at all.
|
||||
_EVERY_MAIL = [
|
||||
("send_access_approved", ("to@example.com", "LANG", "https://x/#verify=t", True, 168)),
|
||||
("send_access_approved", ("to@example.com", "LANG", None, True, 24)),
|
||||
("send_access_approved", ("to@example.com", "LANG", None, False, 24)),
|
||||
("send_access_invitation", ("to@example.com", "LANG", "https://x/#verify=t", True, 168)),
|
||||
("send_access_invitation", ("to@example.com", "LANG", None, False, 24)),
|
||||
("send_access_denied", ("to@example.com", "LANG")),
|
||||
("send_role_changed", ("to@example.com", "LANG", "admin", "op@example.com")),
|
||||
("send_role_changed", ("to@example.com", "LANG", "user", None)),
|
||||
("send_account_unsuspended", ("to@example.com", "LANG", "op@example.com")),
|
||||
("send_account_deleted", ("to@example.com", "LANG", None)),
|
||||
("send_account_suspended", ("to@example.com", "LANG", "op@example.com")),
|
||||
("send_account_suspended", ("to@example.com", "LANG", None)),
|
||||
("send_admin_new_request", (["a@example.com"], "LANG", "who@example.com")),
|
||||
("send_verify_email", ("to@example.com", "LANG", "https://x/#verify=t", 24)),
|
||||
("send_password_reset", ("to@example.com", "LANG", "https://x/#reset=t")),
|
||||
("send_test", ("to@example.com", "LANG")),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lang", mailtext.SUPPORTED)
|
||||
@pytest.mark.parametrize("name,args", _EVERY_MAIL, ids=[f"{n}{a[2:3]}" for n, a in _EVERY_MAIL])
|
||||
def test_every_mail_renders_in_every_language(wire, name, args, lang):
|
||||
getattr(email_mod, name)(*[lang if a == "LANG" else a for a in args])
|
||||
|
||||
(_, subject, body) = wire[0]
|
||||
assert subject and body
|
||||
# A template that lost its interpolation renders the brace text verbatim.
|
||||
assert "{" not in subject and "{" not in body
|
||||
|
||||
|
||||
def test_a_weeks_link_is_described_in_days_not_hours(wire):
|
||||
# The S6 approval token lives seven days, and the mail used to say "expires in 168 hours".
|
||||
email_mod.send_access_approved("to@example.com", "en", "https://x/#verify=t", True, 168)
|
||||
assert "7 days" in wire[0][2] and "168" not in wire[0][2]
|
||||
|
||||
email_mod.send_verify_email("to@example.com", "hu", "https://x/#verify=t", 24)
|
||||
assert "24 óra" in wire[1][2]
|
||||
|
||||
|
||||
# --- what a second registration may and may not do -------------------------------------------------
|
||||
|
||||
|
||||
def test_a_later_registration_never_touches_an_existing_password(db, client, smtp_on, wire):
|
||||
"""The rule E9.5's review round 2 put back, having tried the opposite and found it worse.
|
||||
|
||||
"While the address is unverified, the newest registration owns it" looked like a fix for address
|
||||
pre-emption. It is symmetric — an attacker can always be last — so it closed nothing, and it
|
||||
handed an unauthenticated caller the power to overwrite a real registrant's chosen password and
|
||||
delete the confirmation link already sitting in their inbox. On a SEC-10-stripped row it would
|
||||
have installed a password where the whole point is that there is none. Reverted; the owner's
|
||||
recourse is password-reset, which proves the mailbox. Residual logged as SEC-16.
|
||||
"""
|
||||
client.post(
|
||||
"/auth/register", json={"email": "victim@example.com", "password": "the first password"}
|
||||
)
|
||||
db.expire_all()
|
||||
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
|
||||
before = user.password_hash
|
||||
|
||||
client.post(
|
||||
"/auth/register", json={"email": "victim@example.com", "password": "the second password"}
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(User, user.id).password_hash == before
|
||||
|
||||
|
||||
def test_a_later_registration_leaves_a_link_already_in_flight_alive(db, client, smtp_on, wire):
|
||||
client.post(
|
||||
"/auth/register", json={"email": "victim@example.com", "password": "the first password"}
|
||||
)
|
||||
db.expire_all()
|
||||
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
|
||||
first = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
|
||||
# Not just a subset check below: `set() <= set()` is vacuously true, so without this the test
|
||||
# would go green while asserting nothing the day registration stops issuing a token.
|
||||
assert len(first) == 1
|
||||
|
||||
client.post(
|
||||
"/auth/register", json={"email": "victim@example.com", "password": "the second password"}
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
live = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
|
||||
# The re-send adds a link; it must not invalidate the one the owner may already be holding.
|
||||
assert set(first) <= set(live) and len(live) == len(first) + 1
|
||||
|
||||
|
||||
def test_a_stripped_account_keeps_its_recovery_stamp(db, client, smtp_on, make_user):
|
||||
"""Prod carries accounts SEC-10 left with no password and a `password_revoked_at` stamp. That
|
||||
stamp is the only reason password-reset still accepts them; an anonymous POST must not be able to
|
||||
clear it, nor to put a password on a row whose whole state is "there is none"."""
|
||||
stripped = make_user(
|
||||
email="stripped@example.com",
|
||||
password=None,
|
||||
email_verified=False,
|
||||
is_active=True,
|
||||
password_revoked_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
client.post(
|
||||
"/auth/register", json={"email": "stripped@example.com", "password": "an attacker password"}
|
||||
)
|
||||
|
||||
db.expire_all()
|
||||
row = db.get(User, stripped.id)
|
||||
assert row.password_hash is None and row.password_revoked_at is not None
|
||||
|
||||
|
||||
def test_a_denied_sibling_is_not_reopened_while_a_pending_row_exists(db, client):
|
||||
# Canonical siblings exist on prod. Picking the denied one reopens it and re-notifies every
|
||||
# admin, putting one person in the queue twice — and the exact-spelling tie-break made that
|
||||
# deterministic rather than merely possible.
|
||||
db.add_all(
|
||||
[
|
||||
Invite(email="pair.one@gmail.com", status="pending"),
|
||||
Invite(email="pairone@gmail.com", status="denied"),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
client.post("/auth/request-access", json={"email": "pairone@gmail.com"})
|
||||
|
||||
db.expire_all()
|
||||
rows = {i.email: i.status for i in db.execute(select(Invite)).scalars().all()}
|
||||
assert rows == {"pair.one@gmail.com": "pending", "pairone@gmail.com": "denied"}
|
||||
|
||||
|
||||
# --- the env bootstrap answers the same question as the gate --------------------------------------
|
||||
|
||||
|
||||
def test_the_env_allow_list_is_matched_canonically(db, monkeypatch):
|
||||
# An operator writing the dotted spelling into ALLOWED_EMAILS was refused when Google answered
|
||||
# with the undotted primary — the 2026-09-03 lockout, on the bootstrap path.
|
||||
monkeypatch.setattr(settings, "allowed_emails", "first.last@gmail.com")
|
||||
|
||||
assert auth_core.is_allowed(db, "firstlast@gmail.com") is True
|
||||
assert auth_core.is_allowed(db, "first.last+tag@gmail.com") is True
|
||||
assert auth_core.is_allowed(db, "someone.else@gmail.com") is False
|
||||
|
||||
|
||||
def test_an_env_granted_address_cannot_be_shadowed_by_a_stranger(db, client, monkeypatch):
|
||||
# `is_allowed` short-circuits on ANY invite row for the canonical form, so before this an
|
||||
# unauthenticated caller could request access under a sibling spelling and permanently shadow an
|
||||
# env grant. With the env list matched canonically the request never opens a row.
|
||||
monkeypatch.setattr(settings, "allowed_emails", "bob@gmail.com")
|
||||
|
||||
client.post("/auth/request-access", json={"email": "b.ob@gmail.com"})
|
||||
|
||||
assert db.execute(select(Invite)).scalars().all() == []
|
||||
assert auth_core.is_allowed(db, "bob@gmail.com") is True
|
||||
|
||||
|
||||
def test_a_dotted_admin_emails_entry_still_bootstraps_its_admin(monkeypatch):
|
||||
monkeypatch.setattr(settings, "admin_emails", "the.operator+siftlode@gmail.com")
|
||||
|
||||
assert "theoperator@gmail.com" in auth_core.env_admin_canonicals()
|
||||
|
||||
|
||||
# --- approving decides about a MAILBOX, not a spelling --------------------------------------------
|
||||
|
||||
|
||||
def test_approving_one_spelling_activates_the_account_registered_under_another(
|
||||
db, make_user, as_user, smtp_on, wire
|
||||
):
|
||||
# The 2026-09-03 incident in one test: the admin approves the address as typed into the request,
|
||||
# and the account exists under the other Gmail spelling of the same mailbox.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
user = make_user(email="dotless@gmail.com", password=_PW, is_active=False)
|
||||
user.email = "dotless@gmail.com"
|
||||
inv = Invite(email="dot.less@gmail.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve")
|
||||
|
||||
assert r.status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(User, user.id).is_active is True
|
||||
|
||||
|
||||
def test_deleting_one_of_two_accounts_leaves_the_other_one_allowed(db, make_user, as_user):
|
||||
# Prod carries two accounts for one Gmail mailbox. A canonical invite sweep on delete took the
|
||||
# survivor's grant with it, refusing their next sign-in — this epic's founding bug, self-inflicted.
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
doomed = make_user(email="pair.one@gmail.com", password=_PW)
|
||||
make_user(email="pairone@gmail.com", password=_PW)
|
||||
db.add_all([Invite(email="pair.one@gmail.com", status="approved")])
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).delete(f"/api/admin/users/{doomed.id}")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert auth_core.is_allowed(db, "pairone@gmail.com") is True
|
||||
|
||||
|
||||
# --- a button an admin pressed must not report a send that did not happen -------------------------
|
||||
|
||||
|
||||
def test_a_failed_invitation_changes_nothing_the_admin_was_told_about(
|
||||
db, make_user, as_user, smtp_on, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(email_mod, "send_access_invitation", lambda *a, **kw: False)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
stranger = make_user(
|
||||
email="stranger@example.com", password=_PW, email_verified=False, is_active=False
|
||||
)
|
||||
inv = Invite(email="stranger@example.com", status="approved")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite", json={"lang": "en"})
|
||||
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "send_failed"
|
||||
db.expire_all()
|
||||
# The 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. A dropped send must not make it say yes.
|
||||
assert db.get(Invite, inv.id).invited_at is None
|
||||
# And a request the UI reports as a failure must not have let somebody in behind the admin's
|
||||
# back — the activation belongs after a mail that actually left.
|
||||
assert db.get(User, stranger.id).is_active is False
|
||||
|
||||
|
||||
def test_a_failed_resend_says_so_instead_of_reporting_ok(
|
||||
db, make_user, as_user, smtp_on, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(email_mod, "send_verify_email", lambda *a, **kw: False)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
stuck = make_user(email="stuck@example.com", password=_PW, email_verified=False)
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/users/{stuck.id}/resend-verification")
|
||||
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "send_failed"
|
||||
|
||||
|
||||
# --- refusals and links that are already dead ------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_expired_reset_link_is_reported_dead_without_being_spent(db, client, make_user):
|
||||
# The endpoint exists because a spent or stale link used to draw the full "set a new password"
|
||||
# form. Expiry is the reason its docstring gives, and it was the one case no test covered.
|
||||
user = make_user(email="late@example.com", password=_PW)
|
||||
raw = auth_routes._issue_token(db, user, "reset", timedelta(hours=1))
|
||||
token = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalar_one()
|
||||
token.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
db.commit()
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
|
||||
r = client.post(
|
||||
"/auth/password-reset/confirm", json={"token": raw, "password": "a new one 123"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_a_verify_token_cannot_pass_as_a_reset_link(db, client, make_user):
|
||||
user = make_user(email="mixed@example.com", password=_PW)
|
||||
raw = auth_routes._issue_token(db, user, "verify", timedelta(hours=1))
|
||||
|
||||
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
|
||||
|
||||
|
||||
def test_a_throttled_login_says_nothing_about_the_account(db, client, make_user, monkeypatch):
|
||||
# The two refusals that answer BEFORE the password is proven must stay byte-identical for a real
|
||||
# account and an unknown one; only `invalid` was pinned.
|
||||
make_user(email="real@example.com", password=_PW)
|
||||
monkeypatch.setattr(auth_routes._login_limiter, "allow", lambda key: False)
|
||||
|
||||
real = client.post("/auth/password-login", json={"email": "real@example.com", "password": _PW})
|
||||
unknown = client.post(
|
||||
"/auth/password-login", json={"email": "nobody@example.com", "password": _PW}
|
||||
)
|
||||
|
||||
assert real.status_code == unknown.status_code == 429
|
||||
assert real.json() == unknown.json() == {"detail": {"code": "login", "reason": "rate_limited"}}
|
||||
|
||||
|
||||
# --- admins hear about a request in their own language --------------------------------------------
|
||||
|
||||
|
||||
def test_two_admins_two_languages_two_mails(db, client, make_user, smtp_on, wire):
|
||||
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
|
||||
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
|
||||
|
||||
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
|
||||
|
||||
notices = [w for w in wire if "asking@example.com" in w[1]]
|
||||
assert len(notices) == 2
|
||||
by_recipient = {w[0][0]: w[1] for w in notices}
|
||||
assert by_recipient["hu.admin@example.com"] == mailtext.MAIL_TEXT["admin_new_request.subject"][
|
||||
"hu"
|
||||
].format(requester="asking@example.com")
|
||||
assert by_recipient["en.admin@example.com"] == mailtext.MAIL_TEXT["admin_new_request.subject"][
|
||||
"en"
|
||||
].format(requester="asking@example.com")
|
||||
|
||||
|
||||
def test_an_env_admin_without_an_account_is_written_to_in_the_default(db, monkeypatch):
|
||||
monkeypatch.setattr(settings, "admin_emails", "bootstrap@example.com")
|
||||
|
||||
assert auth_routes.admin_notify_groups(db) == {mailtext.DEFAULT: ["bootstrap@example.com"]}
|
||||
@@ -0,0 +1,249 @@
|
||||
"""One mail, one click — the branching welcome/invitation mail (E9.5 S6), DB-lane.
|
||||
|
||||
The funnel's common ordering makes a person open TWO messages: one saying their access is approved,
|
||||
another asking them to confirm their address. The C option carries the confirmation link inside the
|
||||
mail that is already going out, so the usual case is one mail and one click.
|
||||
|
||||
Also here: the silent no-op that is the most likely explanation for the 2026-09-03 report that a
|
||||
confirmation mail "never arrived". Re-registering an address that already had an unconfirmed account
|
||||
sent nothing at all, while answering with the same cheerful "check your inbox".
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import AuditLog, AuthToken, Invite, User
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _capture(monkeypatch, name):
|
||||
"""Record the arguments a given email_mod.send_* was called with."""
|
||||
from app import email as email_mod
|
||||
|
||||
calls: list[tuple] = []
|
||||
monkeypatch.setattr(email_mod, name, lambda *a, **kw: calls.append((a, kw)) or True)
|
||||
return calls
|
||||
|
||||
|
||||
def _smtp_on(monkeypatch):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
|
||||
|
||||
def _approve(client, invite_id):
|
||||
r = client.post(f"/api/admin/invites/{invite_id}/approve")
|
||||
assert r.status_code == 200
|
||||
return r
|
||||
|
||||
|
||||
# --- the approval mail branches ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_unconfirmed_account_gets_the_confirmation_link_in_the_approval_mail(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="waiting@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
make_user(email="waiting@example.com", password=_PW, email_verified=False, is_active=False)
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
(email, _lang, link, has_account, hours), _ = sent[0]
|
||||
assert email == "waiting@example.com" and has_account is True
|
||||
assert link and "#verify=" in link
|
||||
# The whole point: this is the ONLY message they now have to open.
|
||||
assert hours == 24 * 7
|
||||
|
||||
|
||||
def test_the_link_in_that_mail_actually_confirms_the_address(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="waiting@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
user = make_user(
|
||||
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
|
||||
)
|
||||
_approve(as_user(admin), inv.id)
|
||||
token = sent[0][0][2].split("#verify=")[1]
|
||||
|
||||
r = as_user(admin).post("/auth/verify", json={"token": token})
|
||||
|
||||
assert r.status_code == 200
|
||||
db.expire_all()
|
||||
assert db.get(User, user.id).email_verified is True
|
||||
|
||||
|
||||
def test_that_link_outlives_a_weekend(db, make_user, as_user, monkeypatch):
|
||||
# An approval is not a mail anyone sits waiting for; the standard 24h would routinely expire
|
||||
# before it is read, and the one-mail promise with it.
|
||||
_smtp_on(monkeypatch)
|
||||
_capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="waiting@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
user = make_user(
|
||||
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
|
||||
)
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
tok = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalars().all()[-1]
|
||||
assert tok.expires_at > datetime.now(timezone.utc) + timedelta(days=6)
|
||||
|
||||
|
||||
def test_an_already_confirmed_account_is_just_told_to_sign_in(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="ready@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
make_user(email="ready@example.com", password=_PW, is_active=False)
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
(_, _lang, link, has_account, _), _ = sent[0]
|
||||
assert link is None and has_account is True # no link to offer, and none needed
|
||||
|
||||
|
||||
def test_someone_with_no_account_yet_is_told_a_second_mail_is_coming(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_access_approved")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="stranger@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
_approve(as_user(admin), inv.id)
|
||||
|
||||
(_, _lang, link, has_account, _), _ = sent[0]
|
||||
# Nothing to link to yet. Saying a confirmation mail follows is what makes its ABSENCE
|
||||
# noticeable — the failure that went unnoticed on 2026-09-03.
|
||||
assert link is None and has_account is False
|
||||
|
||||
|
||||
# --- the deliberate invitation --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_adding_an_address_to_the_whitelist_mails_nobody(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
approved = _capture(monkeypatch, "send_access_approved")
|
||||
invited = _capture(monkeypatch, "send_access_invitation")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
|
||||
r = as_user(admin).post("/api/admin/invites", json={"email": "maybe@example.com"})
|
||||
|
||||
# Whitelisting opens a door as a possibility — often already agreed in person — and an address
|
||||
# can sit approved for weeks. A mail fired automatically here would be nudging someone who never
|
||||
# asked to be nudged.
|
||||
assert r.status_code == 200 and r.json()["invited_at"] is None
|
||||
assert approved == [] and invited == []
|
||||
|
||||
|
||||
def test_the_admin_can_then_invite_them_on_purpose(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
invited = _capture(monkeypatch, "send_access_invitation")
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 200 and r.json()["invited_at"] is not None
|
||||
assert invited[0][0][0] == "maybe@example.com"
|
||||
# Recorded on the row, so "did I already write to them?" has an answer where the decision is
|
||||
# made — the wrong answer means mailing a real person twice.
|
||||
db.expire_all()
|
||||
assert db.get(Invite, inv.id).invited_at is not None
|
||||
assert "invite_send" in db.execute(select(AuditLog.action)).scalars().all()
|
||||
|
||||
|
||||
def test_an_undecided_request_cannot_be_invited(db, make_user, as_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="asking@example.com", status="pending")
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "not_approved"
|
||||
|
||||
|
||||
def test_the_invitation_says_so_when_the_instance_cannot_send_mail(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app import email as email_mod
|
||||
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
|
||||
admin = make_user(email="admin@example.com", role="admin")
|
||||
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
|
||||
db.add(inv)
|
||||
db.commit()
|
||||
|
||||
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
|
||||
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "email_off"
|
||||
db.expire_all()
|
||||
assert db.get(Invite, inv.id).invited_at is None # nothing sent, nothing recorded
|
||||
|
||||
|
||||
# --- the silent register no-op --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registering_again_re_sends_the_link_instead_of_sending_nothing(db, client, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_verify_email")
|
||||
assert (
|
||||
client.post(
|
||||
"/auth/register", json={"email": "twice@example.com", "password": _PW}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert len(sent) == 1
|
||||
|
||||
r = client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
|
||||
|
||||
# The old behaviour was to return in silence: same cheerful "check your inbox", no mail. That is
|
||||
# the most likely explanation for the 2026-09-03 confirmation mail that "never arrived" — none
|
||||
# was ever sent. The response stays uniform; only the dead end is gone.
|
||||
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||
assert len(sent) == 2
|
||||
assert sent[1][0][0] == "twice@example.com"
|
||||
|
||||
|
||||
def test_registering_again_does_not_create_a_second_account(db, client, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
_capture(monkeypatch, "send_verify_email")
|
||||
for _ in range(2):
|
||||
client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
|
||||
|
||||
db.expire_all()
|
||||
rows = db.execute(select(User).where(User.email == "twice@example.com")).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
def test_a_confirmed_account_still_gets_nothing(db, client, make_user, monkeypatch):
|
||||
_smtp_on(monkeypatch)
|
||||
sent = _capture(monkeypatch, "send_verify_email")
|
||||
make_user(email="done@example.com", password=_PW) # already verified
|
||||
|
||||
r = client.post("/auth/register", json={"email": "done@example.com", "password": _PW})
|
||||
|
||||
# There is nothing to confirm, so there is nothing to send — and mailing "confirm your address"
|
||||
# to someone who already did would be a lie.
|
||||
assert r.status_code == 200
|
||||
assert sent == []
|
||||
+14
-3
@@ -17,6 +17,7 @@ import {
|
||||
import { notify, removeByMetaKind } from "./lib/notifications";
|
||||
import { LS, setAccountRaw } from "./lib/storage";
|
||||
import { usePref } from "./lib/usePref";
|
||||
import { useVerifyEntry } from "./lib/useVerifyEntry";
|
||||
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||
import { useChannelLayout } from "./components/ChannelsProvider";
|
||||
@@ -37,6 +38,7 @@ import { StateMessage } from "./components/QueryState";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
import VerifyBanner from "./components/VerifyBanner";
|
||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
||||
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
|
||||
@@ -54,6 +56,11 @@ export default function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { page, channelView, ytSearch } = useNavigation();
|
||||
const { setPage, enterYtSearch } = useNavigationActions();
|
||||
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
|
||||
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
|
||||
// that branch never renders when signed in — the token was silently dropped. Signed out, the
|
||||
// outcome goes to the auth card's banners; signed in, to the top-of-app bar.
|
||||
const verifyEntry = useVerifyEntry();
|
||||
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
|
||||
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
|
||||
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
|
||||
@@ -240,7 +247,10 @@ export default function App() {
|
||||
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
|
||||
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
|
||||
removeByMetaKind("access-requests");
|
||||
const pending = meQuery.data.pending_invites ?? 0;
|
||||
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
|
||||
// address was approved before they registered. The second used to produce NO signal at all —
|
||||
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
|
||||
const pending = (meQuery.data.pending_invites ?? 0) + (meQuery.data.pending_activations ?? 0);
|
||||
if (pending > 0) {
|
||||
notify({
|
||||
level: "warning",
|
||||
@@ -259,7 +269,7 @@ export default function App() {
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [meQuery.data?.role, meQuery.data?.pending_invites]);
|
||||
}, [meQuery.data?.role, meQuery.data?.pending_invites, meQuery.data?.pending_activations]);
|
||||
|
||||
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
||||
function changeLanguage(code: LangCode) {
|
||||
@@ -288,7 +298,7 @@ export default function App() {
|
||||
</StateMessage>
|
||||
</div>
|
||||
);
|
||||
if (!meQuery.data) return <Welcome />;
|
||||
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
|
||||
|
||||
// Shown briefly while a lazy page/module chunk loads (first visit to that page only).
|
||||
const pageFallback = (
|
||||
@@ -386,6 +396,7 @@ export default function App() {
|
||||
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
|
||||
banners={
|
||||
<>
|
||||
<VerifyBanner entry={verifyEntry} />
|
||||
{meQuery.data!.is_demo && <DemoBanner />}
|
||||
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
|
||||
</>
|
||||
|
||||
@@ -2,13 +2,34 @@ import { useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
|
||||
import { api, HttpError, type AdminUserRow, type Invite, type Me } from "../lib/api";
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
Clock,
|
||||
MailWarning,
|
||||
Mails,
|
||||
RotateCcw,
|
||||
Send,
|
||||
Shield,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
HttpError,
|
||||
type AdminUserRow,
|
||||
type Invite,
|
||||
type Me,
|
||||
type PendingAccessRow,
|
||||
} from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||
import Tooltip from "./Tooltip";
|
||||
import InlineNotice from "./InlineNotice";
|
||||
import { Section } from "./ui/form";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import { LANGUAGES, type LangCode } from "../i18n";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
@@ -27,7 +48,14 @@ export default function AdminUsers() {
|
||||
const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles");
|
||||
const tabs = [
|
||||
{ id: "roles", label: t("users.tabs.roles") },
|
||||
{ id: "access", label: t("users.tabs.access"), badge: me.pending_invites },
|
||||
// Both kinds of "waiting on the admin": a request needing a decision, and a sign-up whose
|
||||
// address was approved BEFORE they registered — the second never reaches the invite queue,
|
||||
// so the badge used to read zero while somebody sat locked out (roadmap UX-25).
|
||||
{
|
||||
id: "access",
|
||||
label: t("users.tabs.access"),
|
||||
badge: me.pending_invites + me.pending_activations,
|
||||
},
|
||||
{ id: "demo", label: t("users.tabs.demo") },
|
||||
];
|
||||
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
|
||||
@@ -41,7 +69,12 @@ export default function AdminUsers() {
|
||||
</PageToolbar>
|
||||
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
|
||||
{active === "roles" && <UsersRoles me={me} />}
|
||||
{active === "access" && <AdminInvites />}
|
||||
{active === "access" && (
|
||||
<>
|
||||
<AdminInvites />
|
||||
<PendingAccess />
|
||||
</>
|
||||
)}
|
||||
{active === "demo" && <AdminDemo />}
|
||||
</div>
|
||||
</>
|
||||
@@ -261,10 +294,205 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Did the invitation fail because this instance has no working SMTP? */
|
||||
function inviteEmailOff(err: HttpError): boolean {
|
||||
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "invite_send" && d.reason === "email_off";
|
||||
}
|
||||
|
||||
// --- Sign-ups that are still outside (roadmap UX-25) --------------------------------------------
|
||||
//
|
||||
// The gap this closes: approve an address BEFORE the person registers and their sign-up produces no
|
||||
// queue row at all — `_decide` only takes a pending invite — so the Access-requests screen above was
|
||||
// empty while they sat locked out. The only working move was re-typing the address into the
|
||||
// whitelist box, which nobody would guess. This section makes the state visible and the fix one
|
||||
// click, WITHOUT changing who may cause what: the button is admin-authenticated and explicit. Two
|
||||
// earlier attempts moved authority instead (auto-activating on registration, reopening the approved
|
||||
// invite) and were reverted after review.
|
||||
function PendingAccess() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const rows = useQuery({
|
||||
queryKey: qk.adminPendingAccess(),
|
||||
queryFn: () => api.adminPendingAccess(),
|
||||
});
|
||||
// Activating changes /api/me's badge count too, so refresh both or the number lies until reload.
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
|
||||
qc.invalidateQueries({ queryKey: qk.me() });
|
||||
qc.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
};
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (u: PendingAccessRow) => api.activateUser(u.id),
|
||||
onSuccess: (_r, u) => {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
|
||||
});
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: (u: PendingAccessRow) => api.resendUserVerification(u.id),
|
||||
onSuccess: (_r, u) => {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) });
|
||||
},
|
||||
// The public resend is deliberately silent; this one is not, so say WHY it failed — an instance
|
||||
// with no working SMTP would otherwise show a success toast for a mail that was never sent.
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message:
|
||||
err instanceof HttpError && isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
}),
|
||||
});
|
||||
|
||||
const list = rows.data ?? [];
|
||||
const busy = activate.isPending || resend.isPending;
|
||||
if (rows.isSuccess && list.length === 0) return null; // nothing stuck: no empty box to explain
|
||||
|
||||
return (
|
||||
<Section card title={t("users.pendingAccess.title")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.pendingAccess.body")}</p>
|
||||
{rows.isError && !rows.data ? (
|
||||
<StateMessage tone="error" onRetry={() => rows.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : rows.isLoading && !rows.data ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{list.map((u) => (
|
||||
<PendingAccessRowView
|
||||
key={u.id}
|
||||
row={u}
|
||||
busy={busy}
|
||||
onActivate={() => activate.mutate(u)}
|
||||
onResend={() => resend.mutate(u)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Did the resend fail because this instance has no working SMTP? */
|
||||
function isEmailOff(err: HttpError): boolean {
|
||||
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "resend" && d.reason === "email_off";
|
||||
}
|
||||
|
||||
// One stuck sign-up. The row names WHICH gate is shut rather than just offering buttons — the same
|
||||
// reasoning as the sign-in card's blocked panel, from the admin's side: an admin about to let someone
|
||||
// in should see that the address has not been confirmed yet, because on a no-SMTP instance that is
|
||||
// the difference between the owner and whoever registered the address first (roadmap SEC-15).
|
||||
function PendingAccessRowView({
|
||||
row,
|
||||
busy,
|
||||
onActivate,
|
||||
onResend,
|
||||
}: {
|
||||
row: PendingAccessRow;
|
||||
busy: boolean;
|
||||
onActivate: () => void;
|
||||
onResend: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm truncate">{row.email}</div>
|
||||
<div className="text-2xs text-muted flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{row.created_at && (
|
||||
<span>
|
||||
{t("users.pendingAccess.signedUp", {
|
||||
time: new Date(row.created_at).toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{row.email_verified ? (
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<Check className="w-3 h-3" />
|
||||
{t("users.pendingAccess.emailConfirmed")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-warn">
|
||||
<MailWarning className="w-3 h-3" />
|
||||
{t("users.pendingAccess.emailUnconfirmed")}
|
||||
</span>
|
||||
)}
|
||||
{!row.is_active && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{t("users.pendingAccess.inactive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!row.email_verified && (
|
||||
<Tooltip hint={t("users.pendingAccess.resendHint")}>
|
||||
<button
|
||||
onClick={onResend}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-fg disabled:opacity-50 transition"
|
||||
aria-label={t("users.pendingAccess.resend")}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.can_activate && (
|
||||
<Tooltip hint={t("users.pendingAccess.activateHint")}>
|
||||
<button
|
||||
onClick={onActivate}
|
||||
disabled={busy}
|
||||
className="shrink-0 px-2.5 py-1.5 rounded-lg border border-accent/40 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-50 transition"
|
||||
>
|
||||
{t("users.pendingAccess.activate")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The mail language for one invitation. Owns its own display state and reports every change up
|
||||
* through `onPick`, because the dialog that hosts it resolves to a plain boolean — the choice has
|
||||
* to live in the caller's closure, not in this component. Endonyms come from the app's own
|
||||
* language list, so a third locale would appear here for free. */
|
||||
function InviteLangPicker({
|
||||
initial,
|
||||
onPick,
|
||||
}: {
|
||||
initial: LangCode;
|
||||
onPick: (lang: LangCode) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [lang, setLang] = useState<LangCode>(initial);
|
||||
return (
|
||||
<SegmentedToggle
|
||||
className="mt-3"
|
||||
size="md"
|
||||
ariaLabel={t("settings.invites.inviteLang")}
|
||||
label={t("settings.invites.inviteLang")}
|
||||
options={LANGUAGES.map((l) => ({ value: l.code, label: l.label }))}
|
||||
value={lang}
|
||||
onChange={(v) => {
|
||||
setLang(v);
|
||||
onPick(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Access requests (the Invite whitelist) — moved verbatim from Settings → Account -------
|
||||
|
||||
function AdminInvites() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const me = useMe();
|
||||
@@ -273,6 +501,10 @@ function AdminInvites() {
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.adminInvites() });
|
||||
qc.invalidateQueries({ queryKey: qk.me() }); // updates the pending badge
|
||||
// The stuck-sign-ups section below describes the same backlog and changes with every decision
|
||||
// here: approving an address moves a registrant INTO it (active, still unverified), whitelisting
|
||||
// one takes a row out. Leaving it stale hides the resend button this epic added for them.
|
||||
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
|
||||
};
|
||||
const approve = useMutation({
|
||||
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
|
||||
@@ -338,6 +570,53 @@ function AdminInvites() {
|
||||
});
|
||||
if (ok) remove.mutate(i.id);
|
||||
};
|
||||
// Sending is its OWN act, never a side effect of approving an address: the operator opens a door
|
||||
// as a possibility (often already agreed in person), and a mail fired automatically would be
|
||||
// nudging someone who never asked. `invited_at` comes back on the row so a second click is a
|
||||
// decision rather than an accident.
|
||||
const invite = useMutation({
|
||||
mutationFn: (v: { inv: Invite; lang: LangCode }) => api.sendInvitation(v.inv.id, v.lang),
|
||||
onSuccess: (_r, v) => {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
|
||||
},
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message:
|
||||
err instanceof HttpError && inviteEmailOff(err)
|
||||
? t("settings.invites.inviteEmailOff")
|
||||
: t("settings.invites.inviteFailed"),
|
||||
}),
|
||||
});
|
||||
// The one recipient the app has no language signal about: the admin typed this address in, so
|
||||
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
|
||||
// is also where the "you already wrote to them" warning belongs, next to the button that would
|
||||
// do it a second time, rather than only in a tooltip the admin may never hover.
|
||||
const onInvite = async (inv: Invite) => {
|
||||
let lang: LangCode = LANGUAGES.some((l) => l.code === inv.lang)
|
||||
? (inv.lang as LangCode)
|
||||
: ((i18n.language.split("-")[0] === "hu" ? "hu" : "en") as LangCode);
|
||||
const ok = await confirm({
|
||||
title: t("settings.invites.inviteTitle"),
|
||||
message: (
|
||||
<>
|
||||
<p>
|
||||
{t(
|
||||
inv.invited_at
|
||||
? "settings.invites.inviteConfirmAgain"
|
||||
: "settings.invites.inviteConfirm",
|
||||
{ email: inv.email, time: new Date(inv.invited_at ?? 0).toLocaleString() },
|
||||
)}
|
||||
</p>
|
||||
<InviteLangPicker initial={lang} onPick={(v) => (lang = v)} />
|
||||
</>
|
||||
),
|
||||
confirmLabel: t("settings.invites.inviteSend"),
|
||||
});
|
||||
if (ok) invite.mutate({ inv, lang });
|
||||
};
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (email: string) => api.addInvite(email),
|
||||
onSuccess: (_d, email) => {
|
||||
@@ -414,6 +693,35 @@ function AdminInvites() {
|
||||
<span className={i.status === "approved" ? "text-accent" : "text-danger"}>
|
||||
{i.status}
|
||||
</span>
|
||||
{/* Only an approved row with NOBODY behind it yet: this button means "the door is
|
||||
open, come in", which is meaningless to someone already inside — and half the
|
||||
whitelist is existing users. An account that exists but is still stuck has its
|
||||
own surface above, with a re-send. The label carries the answer to "did I already
|
||||
write to them?", the question whose wrong answer means mailing a person twice. */}
|
||||
{i.status === "approved" && !i.has_account && (
|
||||
<Tooltip
|
||||
hint={
|
||||
i.invited_at
|
||||
? t("settings.invites.invitedAt", {
|
||||
time: new Date(i.invited_at).toLocaleString(),
|
||||
})
|
||||
: t("settings.invites.inviteHint")
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => onInvite(i)}
|
||||
disabled={invite.isPending}
|
||||
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
|
||||
i.invited_at
|
||||
? "text-muted/50 hover:text-fg"
|
||||
: "text-muted hover:text-accent"
|
||||
}`}
|
||||
aria-label={t("settings.invites.inviteLabel", { email: i.email })}
|
||||
>
|
||||
<Mails className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* An entry with an account behind it stays read-only: deleting it would revoke that
|
||||
user's access, since is_allowed reads this row. Delete the user instead. */}
|
||||
{i.has_account ? (
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function Banner({
|
||||
action,
|
||||
onDismiss,
|
||||
dismissTitle,
|
||||
role,
|
||||
}: {
|
||||
tone: BannerTone;
|
||||
icon: LucideIcon;
|
||||
@@ -33,10 +34,17 @@ export default function Banner({
|
||||
action?: { label: string; onClick: () => void };
|
||||
onDismiss?: () => void;
|
||||
dismissTitle?: string;
|
||||
/** `"status"` for a bar that APPEARS in response to something the user just did, so a screen
|
||||
* reader announces it. Left unset for the permanent bars (demo, version): those are part of the
|
||||
* page when it loads, and announcing them on every visit is noise, not information. */
|
||||
role?: "status";
|
||||
}) {
|
||||
const c = TONES[tone];
|
||||
return (
|
||||
<div className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}>
|
||||
<div
|
||||
role={role}
|
||||
className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
|
||||
<span className="min-w-0">{children}</span>
|
||||
{action && (
|
||||
|
||||
@@ -94,7 +94,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
onClose={() => close(null)}
|
||||
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
|
||||
>
|
||||
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
|
||||
<div className="text-sm text-muted leading-relaxed">{state.opts.message}</div>
|
||||
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
|
||||
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
|
||||
these are peers. Ordered least to most destructive, top to bottom. */}
|
||||
|
||||
@@ -281,6 +281,20 @@ export default function NavSidebar({
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{name}</div>
|
||||
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||
{/* The address Google answers with, when it isn't the one above. /api/me sends it
|
||||
only when the two differ, so for most accounts this line never exists — and for
|
||||
someone who registered as `me+siftlode@gmail.com` it answers the question their
|
||||
sign-in screen would otherwise leave open: which address gets me in? A text
|
||||
label rather than a brand glyph, since the app draws one icon set (lucide) and a
|
||||
generic mail icon here would say nothing. */}
|
||||
{me.google_email && (
|
||||
<div
|
||||
className="text-2xs text-muted truncate mt-0.5"
|
||||
title={t("header.account.googleAddress", { email: me.google_email })}
|
||||
>
|
||||
<span className="uppercase tracking-wide">Google</span> · {me.google_email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{me.role === "admin" && (
|
||||
|
||||
@@ -413,6 +413,15 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
? t("settings.account.googleLink.connectedHint")
|
||||
: t("settings.account.googleLink.connectHint")}
|
||||
</p>
|
||||
{/* Which address Google actually answers with — sent only when it differs from the
|
||||
account's own (see /api/me). "Connected" alone left that unanswerable, and it is
|
||||
exactly what someone who registered with a `+tag` needs to know. */}
|
||||
{me.google_email && (
|
||||
<p className="text-xs text-fg/80 mt-1.5">
|
||||
<span className="text-muted">{t("settings.account.googleLink.signedInAs")}</span>{" "}
|
||||
{me.google_email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{me.has_google ? (
|
||||
<span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent">
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import Banner from "./Banner";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
// The top-of-app outcome of an emailed verification link opened in a browser that ALREADY holds a
|
||||
// session. That case used to be silent: the token was only ever consumed inside Welcome.tsx, which a
|
||||
// signed-in visit never reaches, so the link opened a tab, the fragment was stripped, and nothing
|
||||
// happened. Signed OUT, the same outcome is rendered by the auth card's own banners instead — this
|
||||
// bar is only for the signed-in half.
|
||||
//
|
||||
// No "send a new link" action here, unlike the auth card: this viewer is signed in, so the sign-in
|
||||
// screen (where resending lives) is not where they are, and the message is a report, not a task. A
|
||||
// wrong-account case is why the address is named.
|
||||
export default function VerifyBanner({ entry }: { entry: VerifyEntry }) {
|
||||
const { t } = useTranslation();
|
||||
if (!entry.outcome) return null;
|
||||
|
||||
const ok = entry.outcome !== "invalid";
|
||||
const text =
|
||||
entry.outcome === "invalid"
|
||||
? t("header.verify.invalid")
|
||||
: entry.email
|
||||
? t("header.verify.confirmedNamed", { email: entry.email })
|
||||
: t("header.verify.confirmed");
|
||||
|
||||
return (
|
||||
<Banner
|
||||
tone={ok ? "accent" : "warning"}
|
||||
icon={ok ? CheckCircle2 : AlertTriangle}
|
||||
role="status"
|
||||
onDismiss={entry.dismiss}
|
||||
dismissTitle={t("header.banner.dismiss")}
|
||||
>
|
||||
{text}
|
||||
{entry.outcome === "ok_reset" && (
|
||||
<span className="text-muted"> {t("header.verify.reset")}</span>
|
||||
)}
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
@@ -3,27 +3,34 @@ import { useHistorySubview, type SubviewUrl } from "../lib/history";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Clapperboard,
|
||||
Clock,
|
||||
Code2,
|
||||
Download,
|
||||
Expand,
|
||||
Filter,
|
||||
Inbox,
|
||||
Link2Off,
|
||||
ListVideo,
|
||||
Lock,
|
||||
MailWarning,
|
||||
PlayCircle,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
// Siftlode is open source; the landing footer links to the public repository.
|
||||
const REPO_URL = "https://git.b1fr0st.eu/peter/siftlode";
|
||||
import { api } from "../lib/api";
|
||||
import { api, deadResetLink, loginBlocked, type LoginBlocked } from "../lib/api";
|
||||
import { HttpError } from "../lib/api";
|
||||
import { setLanguage, type LangCode } from "../i18n";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { entryHash, entrySearch } from "../lib/entryUrl";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
const PASSWORD_MIN = 10;
|
||||
@@ -63,7 +70,7 @@ const SUBMIT_KEY: Record<Mode, string> = {
|
||||
// password, Google, explicit demo) over a short marketing pitch with feature cards. Shown
|
||||
// whenever the user isn't signed in (see App.tsx). The auth forms talk to /auth/* (see the
|
||||
// backend); errors are surfaced inline (the api calls use the quiet flag).
|
||||
export default function Welcome() {
|
||||
export default function Welcome({ verify }: { verify: VerifyEntry }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
// Which screenshot (if any) is open in the full-size lightbox.
|
||||
const [zoomed, setZoomed] = useState<{ src: string; label: string } | null>(null);
|
||||
@@ -87,7 +94,7 @@ export default function Welcome() {
|
||||
<p className="text-muted mt-4 text-lg leading-relaxed">{t("welcome.hero.subtitle")}</p>
|
||||
<p className="text-sm text-muted mt-4">{t("welcome.hero.trust")}</p>
|
||||
</div>
|
||||
<AuthCard />
|
||||
<AuthCard verify={verify} />
|
||||
</section>
|
||||
|
||||
{/* App preview */}
|
||||
@@ -301,50 +308,54 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
||||
);
|
||||
}
|
||||
|
||||
function AuthCard() {
|
||||
// One answer per reset token, however many times React mounts this card. StrictMode double-invokes
|
||||
// effects in dev and the shell remounts the card as the session probe settles — measured in UAT on
|
||||
// 2026-09-05: FOUR probes went out for one opened link. That would burn the endpoint's per-IP budget
|
||||
// after a couple of reloads and silently drop back to drawing the form unchecked. Holding the
|
||||
// in-flight promise means a remount joins the request already running instead of starting another.
|
||||
const resetTokenChecks = new Map<string, Promise<boolean>>();
|
||||
|
||||
function checkResetToken(token: string): Promise<boolean> {
|
||||
let pending = resetTokenChecks.get(token);
|
||||
if (!pending) {
|
||||
pending = api
|
||||
.checkPasswordReset(token)
|
||||
.then((r) => r.ok)
|
||||
// A probe that FAILED says nothing about the token, so it answers "go ahead" and forgets
|
||||
// itself: a network blip must never be cached as "this link is dead".
|
||||
.catch(() => {
|
||||
resetTokenChecks.delete(token);
|
||||
return true;
|
||||
});
|
||||
resetTokenChecks.set(token, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
const { t } = useTranslation();
|
||||
// Snapshot the pre-login params ONCE (query + fragment) so we can clean the address bar below
|
||||
// without dismissing the banners/reset-mode they drive. Secret tokens (reset, verify) ride the
|
||||
// URL FRAGMENT so they never reach the server / proxy logs / Referer (SB3); plain status flags
|
||||
// stay in the query string.
|
||||
const [params] = useState(() => new URLSearchParams(window.location.search));
|
||||
const [hashParams] = useState(() => new URLSearchParams(window.location.hash.replace(/^#/, "")));
|
||||
// The entry URL is snapshotted once at import time (lib/entryUrl) rather than here, so nothing can
|
||||
// rewrite the address bar out from under this card before it mounts — and so the App-level hook and
|
||||
// this card read the SAME values regardless of which mounts first. Clearing the address bar is that
|
||||
// hook's job now, since it is the one thing that runs on every visit, signed in or not.
|
||||
//
|
||||
// State, not a const: once the token has been spent the reset view must stop being the card's
|
||||
// root, or a Back from the "password changed" screen lands on a form whose token is already used.
|
||||
const [resetToken, setResetToken] = useState<string | null>(() => hashParams.get("reset"));
|
||||
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
|
||||
const bounced = params.get("access"); // Google denied → "requested" | "denied"
|
||||
const deleted = params.get("deleted"); // self-service account deletion just completed
|
||||
const [resetToken, setResetToken] = useState<string | null>(() => entryHash.get("reset"));
|
||||
// The Google access-request notice, off the FRAGMENT (see lib/entryUrl): it names the address now,
|
||||
// and an address in the query string would land in every reverse-proxy access log. `linked` is set
|
||||
// only when the request already existed under ANOTHER spelling of the same mailbox — the case worth
|
||||
// explaining; the same spelling twice is just a repeat.
|
||||
const bounced = entryHash.get("access"); // Google denied → "requested" | "denied"
|
||||
const bouncedEmail = entryHash.get("email");
|
||||
const bouncedLinked = entryHash.get("linked");
|
||||
const deleted = entrySearch.get("deleted"); // self-service account deletion just completed
|
||||
// Google login outcome → "suspended" | "oauth_cancelled". State (not a const) so it clears on the
|
||||
// first deliberate action, like the verify banner — otherwise it lingers stalely on later screens.
|
||||
const [login, setLogin] = useState<string | null>(() => params.get("login"));
|
||||
// Verification outcome: from POSTing the fragment token (new flow), or the legacy
|
||||
// ?verify=ok|invalid redirect (pre-SB3 emails still in flight).
|
||||
const [verify, setVerify] = useState<string | null>(() => params.get("verify"));
|
||||
|
||||
// Strip BOTH the query string and the fragment so the address bar stays clean and the token
|
||||
// doesn't linger in history.
|
||||
useEffect(() => {
|
||||
if (window.location.search || window.location.hash) {
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Confirm a fragment verification token by POSTing it (new SB3 flow). Fire ONCE: the token is
|
||||
// single-use, so a double POST (StrictMode remount / any re-render) would 400 on the second call
|
||||
// and wrongly flip the banner to "invalid".
|
||||
const verifyFired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!verifyToken || verifyFired.current) return;
|
||||
verifyFired.current = true;
|
||||
api
|
||||
.verifyEmail(verifyToken)
|
||||
// "ok_reset": verified, but SEC-10 had dropped the password this account registered with (an
|
||||
// admin approved it before it was verified). Sign-in would answer with the uniform credentials
|
||||
// error, so point the user at the reset flow instead. Same value the legacy ?verify= redirect uses.
|
||||
.then((r) => setVerify(r.password_reset_required ? "ok_reset" : "ok"))
|
||||
.catch(() => setVerify("invalid"));
|
||||
}, [verifyToken]);
|
||||
const [login, setLogin] = useState<string | null>(() => entrySearch.get("login"));
|
||||
// The verification outcome is confirmed in App (above the signed-in branch) and handed down, so
|
||||
// one emailed link behaves the same whether or not this browser already holds a session.
|
||||
const verifyOutcome = verify.outcome;
|
||||
|
||||
const { view: mode, open: setMode } = useHistorySubview<Mode>(
|
||||
resetToken ? "reset" : "signin",
|
||||
@@ -365,12 +376,43 @@ function AuthCard() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const [done, setDone] = useState<string>(""); // success/info message replacing the form
|
||||
// Why password sign-in refused, when the server named a state worth a screen rather than a red line
|
||||
// (E9.5 S4). It REPLACES the form: in all three of those states the password was already proven
|
||||
// correct, so there is nothing to retype — leaving the form up would only invite a pointless retry.
|
||||
const [blocked, setBlocked] = useState<LoginBlocked | null>(null);
|
||||
// The emailed reset link turned out to be spent, expired, or to belong to an account that no longer
|
||||
// exists. Same reasoning as `blocked`: the form cannot succeed, so it must not be on screen.
|
||||
const [deadReset, setDeadReset] = useState(false);
|
||||
// Ask whether the `#reset=` token is still usable BEFORE drawing the form, and hold the card blank
|
||||
// until the answer lands. Reported 2026-09-05: deleting an account and then opening its reset link
|
||||
// rendered a full "set a new password" form that could only ever answer 400 — the user finds out
|
||||
// after typing. This is advisory (the confirm below stays the authority); a check that errors is
|
||||
// treated as "go ahead", never as "dead", so a broken probe cannot lock anyone out of a live link.
|
||||
const [checkingReset, setCheckingReset] = useState(() => entryHash.get("reset") !== null);
|
||||
useEffect(() => {
|
||||
const token = entryHash.get("reset");
|
||||
if (!token) return;
|
||||
let live = true;
|
||||
void checkResetToken(token).then((ok) => {
|
||||
if (!live) return;
|
||||
if (!ok) {
|
||||
setDeadReset(true);
|
||||
setResetToken(null); // and stop the reset view being the card's root, so Back can't return
|
||||
}
|
||||
setCheckingReset(false);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
// `done` and `error` belong to the view that produced them, and the render is `done ? msg : form`
|
||||
// — without this, Back out of a submitted form would show the old success message under the
|
||||
// previous view's title. Clearing on view change covers both Back/Forward and the mode links.
|
||||
useEffect(() => {
|
||||
setDone("");
|
||||
setError("");
|
||||
setBlocked(null);
|
||||
setDeadReset(false);
|
||||
}, [mode]);
|
||||
|
||||
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
|
||||
@@ -383,9 +425,11 @@ function AuthCard() {
|
||||
const reset = () => {
|
||||
setError("");
|
||||
setDone("");
|
||||
setBlocked(null);
|
||||
setDeadReset(false);
|
||||
// The verify/login banners are one-time entry notices — dismiss once the user takes any
|
||||
// deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens.
|
||||
setVerify(null);
|
||||
verify.dismiss();
|
||||
setLogin(null);
|
||||
};
|
||||
|
||||
@@ -439,6 +483,20 @@ function AuthCard() {
|
||||
setDone(t("welcome.auth.resetDone"));
|
||||
}
|
||||
} catch (err) {
|
||||
// An account-shaped refusal becomes a named screen; `invalid` / `rate_limited` stay a line
|
||||
// under the form, because there the form IS the next step.
|
||||
const refusal = err instanceof HttpError ? loginBlocked(err.detailData) : null;
|
||||
if (refusal && PANEL_REASONS.has(refusal.reason)) {
|
||||
setBlocked(refusal);
|
||||
return;
|
||||
}
|
||||
// The token died between the pre-flight check and this submit — or was never checked, because
|
||||
// the probe itself failed. Either way the answer is the same screen, not a red line.
|
||||
if (mode === "reset" && err instanceof HttpError && deadResetLink(err.detailData)) {
|
||||
setDeadReset(true);
|
||||
setResetToken(null);
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"),
|
||||
);
|
||||
@@ -467,15 +525,29 @@ function AuthCard() {
|
||||
}
|
||||
}
|
||||
|
||||
const titleKey = TITLE_KEY[mode];
|
||||
// A named state owns the card's heading while it is up, so it reads as the card's subject rather
|
||||
// than a box sitting inside a heading that still says "Set a new password".
|
||||
const titleKey = blocked
|
||||
? `welcome.auth.blocked.${blocked.reason}.title`
|
||||
: deadReset
|
||||
? "welcome.auth.deadLink.title"
|
||||
: TITLE_KEY[mode];
|
||||
const StateIcon = blocked ? BLOCKED_ICON[blocked.reason] : deadReset ? Link2Off : null;
|
||||
|
||||
return (
|
||||
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
|
||||
<h2 className="text-lg font-semibold mb-1">{t(titleKey)}</h2>
|
||||
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5">
|
||||
{StateIcon && (
|
||||
<span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0">
|
||||
<StateIcon className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
)}
|
||||
{t(titleKey)}
|
||||
</h2>
|
||||
|
||||
{/* One-time status banners from a redirect (Google-denied, email verification). */}
|
||||
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
||||
{verify === "ok_reset" && (
|
||||
{verifyOutcome === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
||||
{verifyOutcome === "ok_reset" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyOkReset")}{" "}
|
||||
<button onClick={goForgot} className="font-semibold text-accent hover:underline">
|
||||
@@ -483,7 +555,7 @@ function AuthCard() {
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{verify === "invalid" && (
|
||||
{verifyOutcome === "invalid" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyInvalid")}{" "}
|
||||
<button onClick={goResend} className="font-semibold text-accent hover:underline">
|
||||
@@ -491,7 +563,26 @@ function AuthCard() {
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>}
|
||||
{bounced === "requested" &&
|
||||
(bouncedEmail ? (
|
||||
<Banner tone="ok">
|
||||
<span className="block">{t("welcome.auth.accessRequestedFor")}</span>
|
||||
<Address value={bouncedEmail} />
|
||||
<span className="block">{t("welcome.auth.accessRequestedReview")}</span>
|
||||
</Banner>
|
||||
) : (
|
||||
<Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>
|
||||
))}
|
||||
{/* The alias insight, stated only when it applies: Google answered with one spelling of a
|
||||
mailbox whose request is already on file under another. Silence here is what let the
|
||||
2026-09-03 registrant believe nothing had been recorded at all. */}
|
||||
{bounced === "requested" && bouncedLinked && (
|
||||
<Banner tone="warn">
|
||||
<span className="block">{t("welcome.auth.accessLinked")}</span>
|
||||
<Address value={bouncedLinked} />
|
||||
<span className="block">{t("welcome.auth.accessLinkedTail")}</span>
|
||||
</Banner>
|
||||
)}
|
||||
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
|
||||
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
|
||||
{login === "oauth_cancelled" && (
|
||||
@@ -499,7 +590,22 @@ function AuthCard() {
|
||||
)}
|
||||
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
|
||||
|
||||
{done ? (
|
||||
{blocked ? (
|
||||
<BlockedPanel blocked={blocked} onResend={goResend} onBack={() => setBlocked(null)} />
|
||||
) : deadReset ? (
|
||||
<StatePanel
|
||||
body={t("welcome.auth.deadLink.body")}
|
||||
cta={{ label: t("welcome.auth.deadLink.cta"), onClick: goForgot }}
|
||||
onBack={() => {
|
||||
reset();
|
||||
setMode("signin");
|
||||
}}
|
||||
/>
|
||||
) : checkingReset ? (
|
||||
// Blank rather than the form: showing a form we may be about to withdraw is the flicker this
|
||||
// whole check exists to remove.
|
||||
<p className="mt-3 text-sm text-muted">{t("welcome.auth.working")}</p>
|
||||
) : done ? (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-fg/80 leading-relaxed">{done}</p>
|
||||
<button
|
||||
@@ -551,6 +657,18 @@ function AuthCard() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* A confirmation mail can be slow, filtered, or — before E9.5 S6 — never sent at all for a
|
||||
repeat sign-up. Offered right under the form rather than below the mode switches: it is
|
||||
the second thing a stuck person needs, after the password they just tried. */}
|
||||
{mode === "signin" && (
|
||||
<button
|
||||
onClick={goResend}
|
||||
className="mt-3 block w-full text-center text-xs text-muted hover:text-fg"
|
||||
>
|
||||
{t("welcome.auth.resendLink")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mode switches */}
|
||||
<div className="flex items-center justify-between mt-3 text-xs">
|
||||
{mode === "signin" ? (
|
||||
@@ -589,14 +707,6 @@ function AuthCard() {
|
||||
|
||||
{mode === "signin" && (
|
||||
<>
|
||||
{/* A verification email can be lost or never arrive, so offer a resend path from
|
||||
sign-in too — not only from the expired-link banner (needs a broken link to show). */}
|
||||
<button
|
||||
onClick={goResend}
|
||||
className="mt-2 block w-full text-center text-xs text-muted hover:text-fg"
|
||||
>
|
||||
{t("welcome.auth.resendLink")}
|
||||
</button>
|
||||
<div className="flex items-center gap-3 my-4 text-2xs uppercase tracking-wide text-muted">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
{t("welcome.auth.or")}
|
||||
@@ -647,9 +757,155 @@ function AuthCard() {
|
||||
);
|
||||
}
|
||||
|
||||
// An email address the user has to READ and compare, character by character — the recorded address on
|
||||
// a blocked-sign-in panel, the mailbox a request was filed under. Monospaced and breakable because
|
||||
// that comparison is the whole job: a dot or a `+tag` in a proportional font is exactly what nobody
|
||||
// noticed on 2026-09-03.
|
||||
function Address({ value }: { value: string }) {
|
||||
return <span className="block font-mono font-medium break-all my-0.5">{value}</span>;
|
||||
}
|
||||
|
||||
// Which refusals earn a screen of their own. The other two (`invalid`, `rate_limited`) are answered
|
||||
// BEFORE the password is proven, carry no account detail, and leave the form as the next step — a
|
||||
// line under it is the honest size for them.
|
||||
const PANEL_REASONS = new Set<LoginBlocked["reason"]>([
|
||||
"email_unverified",
|
||||
"pending_approval",
|
||||
"suspended",
|
||||
]);
|
||||
|
||||
const BLOCKED_ICON: Partial<Record<LoginBlocked["reason"], LucideIcon>> = {
|
||||
email_unverified: MailWarning,
|
||||
pending_approval: Clock,
|
||||
suspended: ShieldAlert,
|
||||
};
|
||||
|
||||
// The frame every named state in this card shares: a sentence, whatever detail that state can give,
|
||||
// then the way out. The icon and title sit in the card's own h2 (see titleKey/StateIcon) so a state
|
||||
// reads as the card's subject. One frame, because two states that mean "this form cannot succeed"
|
||||
// should not drift apart in shape.
|
||||
function StatePanel({
|
||||
body,
|
||||
cta,
|
||||
onBack,
|
||||
children,
|
||||
}: {
|
||||
body: string;
|
||||
cta?: { label: string; onClick: () => void };
|
||||
onBack: () => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Announced and focused, because this panel REPLACES the form the user just submitted: without a
|
||||
// live region a screen reader reports nothing at all, and without the focus move the button they
|
||||
// were standing on is unmounted, dropping focus to <body> so Tab restarts at the top of the page.
|
||||
// The Banner in this same file got `role="status"` for the same reason; the panel is the louder
|
||||
// half — it is the whole answer to a submit — so it announces assertively.
|
||||
const heading = useRef<HTMLParagraphElement>(null);
|
||||
useEffect(() => heading.current?.focus(), []);
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-4" role="alert" aria-live="assertive">
|
||||
<p
|
||||
ref={heading}
|
||||
tabIndex={-1}
|
||||
className="text-sm text-fg/80 leading-relaxed focus:outline-hidden"
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
{children}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button onClick={onBack} className="text-sm text-accent hover:underline">
|
||||
{t("welcome.auth.backToSignin")}
|
||||
</button>
|
||||
{cta && (
|
||||
<button
|
||||
onClick={cta.onClick}
|
||||
className="shrink-0 px-3 py-2 rounded-xl text-xs font-semibold bg-card border border-border hover:border-accent transition"
|
||||
>
|
||||
{cta.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The named state behind a 403, in place of the sign-in form (E9.5 S4). It says WHICH mailbox the
|
||||
// account is on and WHICH of the two gates is still shut, because the flat "awaiting admin approval"
|
||||
// string it replaces was true and useless: it never revealed that the address the account carried was
|
||||
// a different spelling of the one the user had just typed.
|
||||
function BlockedPanel({
|
||||
blocked,
|
||||
onResend,
|
||||
onBack,
|
||||
}: {
|
||||
blocked: LoginBlocked;
|
||||
onResend: () => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { reason, email, emailVerified, approved, operator } = blocked;
|
||||
// Suspension is not a gate that will open on its own, so the checklist would be a lie there.
|
||||
const gated = reason !== "suspended";
|
||||
return (
|
||||
<StatePanel
|
||||
body={t(`welcome.auth.blocked.${reason}.body`)}
|
||||
cta={
|
||||
reason === "email_unverified"
|
||||
? { label: t("welcome.auth.resendCta"), onClick: onResend }
|
||||
: undefined
|
||||
}
|
||||
onBack={onBack}
|
||||
>
|
||||
{gated && (
|
||||
<div className="rounded-xl border border-border bg-card/60 px-3.5 py-3 flex flex-col gap-2">
|
||||
<Gate done={emailVerified === true} k="email" />
|
||||
<Gate done={approved === true} k="approval" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{email && (
|
||||
<div className="border-l-2 border-border pl-3 flex flex-col gap-0.5">
|
||||
<span className="text-2xs uppercase tracking-wide text-muted">
|
||||
{t("welcome.auth.blocked.addressLabel")}
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
<Address value={email} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{operator && (
|
||||
<p className="text-xs text-muted leading-relaxed">
|
||||
{t("welcome.auth.blocked.operator", { contact: operator })}
|
||||
</p>
|
||||
)}
|
||||
</StatePanel>
|
||||
);
|
||||
}
|
||||
|
||||
// One row of the two-gate checklist. Both states get their own full sentence rather than a shared
|
||||
// label plus a status word: Hungarian puts them together differently, and gluing translated
|
||||
// fragments is how a bilingual UI ends up reading like neither language.
|
||||
function Gate({ done, k }: { done: boolean; k: "email" | "approval" }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = done ? Check : Clock;
|
||||
return (
|
||||
<span className={`flex items-center gap-2 text-xs ${done ? "text-fg/80" : "text-muted"}`}>
|
||||
<Icon className={`w-4 h-4 shrink-0 ${done ? "text-success" : ""}`} />
|
||||
{t(`welcome.auth.blocked.gate.${k}${done ? "Done" : "Pending"}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// `role="status"` because these appear in response to something that just happened (a verification
|
||||
// link, a denied Google sign-in, a completed deletion) rather than being part of the card at rest —
|
||||
// without it a screen-reader user gets no announcement that anything changed.
|
||||
function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) {
|
||||
const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-warn/15 text-fg";
|
||||
return (
|
||||
<div className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>{children}</div>
|
||||
<div role="status" className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,12 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
type ConfirmOpts = { title?: string; message?: string; confirmLabel?: string; danger?: boolean };
|
||||
type ConfirmOpts = {
|
||||
title?: string;
|
||||
message?: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
};
|
||||
const confirmMock = vi.fn(async (_opts: ConfirmOpts) => true);
|
||||
vi.mock("./ConfirmProvider", () => ({
|
||||
useConfirm: () => confirmMock,
|
||||
@@ -37,7 +42,13 @@ vi.mock("../lib/notifications", async (importOriginal) => {
|
||||
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
||||
});
|
||||
|
||||
const meMock = { pending_invites: 0, email_configured: true, id: 1, role: "admin" };
|
||||
const meMock = {
|
||||
pending_invites: 0,
|
||||
pending_activations: 0,
|
||||
email_configured: true,
|
||||
id: 1,
|
||||
role: "admin",
|
||||
};
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
|
||||
|
||||
// The tab strip is rendered into an app-level toolbar slot that does not exist here, so pin the
|
||||
@@ -49,6 +60,7 @@ vi.mock("./Tabs", () => ({
|
||||
|
||||
const listMock = vi.fn();
|
||||
const deleteMock = vi.fn();
|
||||
const inviteMock = vi.fn();
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
@@ -57,6 +69,10 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
...actual.api,
|
||||
adminInvites: () => listMock(),
|
||||
deleteInvite: (id: number) => deleteMock(id),
|
||||
sendInvitation: (id: number, lang: string) => inviteMock(id, lang),
|
||||
// The access tab also renders the stuck-sign-ups section (E9.5 S5); nothing stuck here, so it
|
||||
// renders nothing and this file stays about the delete affordance.
|
||||
adminPendingAccess: () => Promise.resolve([]),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -74,6 +90,8 @@ const invite = (over: Partial<Record<string, unknown>> = {}) => ({
|
||||
decided_by: null,
|
||||
has_account: false,
|
||||
still_allowed_by_env: false,
|
||||
invited_at: null,
|
||||
lang: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -91,6 +109,9 @@ beforeEach(() => {
|
||||
notifyMock.mockClear();
|
||||
deleteMock.mockReset().mockResolvedValue({ deleted: 1, still_allowed_by_env: false });
|
||||
listMock.mockReset().mockResolvedValue([invite()]);
|
||||
inviteMock
|
||||
.mockReset()
|
||||
.mockResolvedValue(invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }));
|
||||
meMock.email_configured = true;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
@@ -214,3 +235,113 @@ describe("Access requests — the delete affordance", () => {
|
||||
expect(await screen.findByText("settings.invites.emailOff")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// Whitelisting an address is opening a door as a possibility, not knocking on one — the operator has
|
||||
// usually already agreed it with the person, and a row can sit approved for weeks. So adding stays
|
||||
// silent and telling them is its own act, on its own button (E9.5 S6).
|
||||
describe("inviting someone the admin whitelisted", () => {
|
||||
// The decided rows live behind a <details>; await the summary rather than the section title, or
|
||||
// the click races the invite query that populates it.
|
||||
const open = async () => {
|
||||
renderPanel();
|
||||
fireEvent.click(await screen.findByText(/settings.invites.decided/));
|
||||
};
|
||||
|
||||
it("offers a send button on an approved row", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
// Falls back to the admin's own UI language: nobody ever told us anything about this mailbox.
|
||||
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "en"));
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "settings.invites.invited:asker@example.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("asks first, and writes to nobody when the admin backs out", async () => {
|
||||
confirmMock.mockResolvedValue(false);
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(confirmMock).toHaveBeenCalledTimes(1));
|
||||
expect(inviteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to the language this person asked us in", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved", lang: "hu" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
|
||||
});
|
||||
|
||||
it("sends in the language picked in the dialog, not the default", async () => {
|
||||
// The dialog is the only place this decision can be made — and the pick has to survive back to
|
||||
// the caller, because the confirmation resolves to a plain boolean.
|
||||
let release: (ok: boolean) => void = () => {};
|
||||
confirmMock.mockImplementation(() => new Promise<boolean>((r) => (release = r)));
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
|
||||
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
|
||||
fireEvent.click(dialog.getByText("Magyar"));
|
||||
release(true);
|
||||
|
||||
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
|
||||
});
|
||||
|
||||
it("warns in the dialog when this person has already been written to", async () => {
|
||||
// The warning used to live only in a tooltip, which the admin may never hover. It belongs next
|
||||
// to the button that would mail a real person a second time.
|
||||
listMock.mockResolvedValue([
|
||||
invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }),
|
||||
]);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
|
||||
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
|
||||
expect(dialog.container.textContent).toContain("settings.invites.inviteConfirmAgain");
|
||||
});
|
||||
|
||||
it("offers none on a denied row, which would contradict the decision", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "denied" })]);
|
||||
await open();
|
||||
|
||||
expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers none once somebody is already behind that address", async () => {
|
||||
// "The door is open, come in" is meaningless to someone already inside, and most of a mature
|
||||
// whitelist is existing users. An account that exists but is stuck has its own row above.
|
||||
listMock.mockResolvedValue([invite({ status: "approved", has_account: true })]);
|
||||
await open();
|
||||
|
||||
expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("says the mail was never sent when the instance has no SMTP", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
inviteMock.mockRejectedValue(
|
||||
new HttpError(409, "ignored", { code: "invite_send", reason: "email_off" }),
|
||||
);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
// A button the admin pressed must not answer with a success toast for a mail nobody received.
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "settings.invites.inviteEmailOff" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The admin lifeline for a sign-up that is stuck outside (E9.5 S5, roadmap UX-25). Approve an address
|
||||
// BEFORE the person registers and their sign-up produces no queue row at all, so the Access-requests
|
||||
// screen was empty while they sat locked out and the only working move — re-typing the address into
|
||||
// the whitelist box — was undiscoverable. These pin what the section shows and, just as importantly,
|
||||
// what it does NOT show: the section is a discovery surface, and a person the queue above already
|
||||
// covers must not appear twice.
|
||||
//
|
||||
// i18n is a key-passthrough so assertions read stable keys; the API and the notify singleton are
|
||||
// stubbed so the panel can be driven without a server.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars?.email ? `${key}:${String(vars.email)}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./ConfirmProvider", () => ({
|
||||
useConfirm: () => vi.fn(async () => true),
|
||||
ConfirmProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
const notifyMock = vi.fn();
|
||||
vi.mock("../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
||||
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
|
||||
});
|
||||
|
||||
const meMock = {
|
||||
pending_invites: 0,
|
||||
pending_activations: 0,
|
||||
email_configured: true,
|
||||
id: 1,
|
||||
role: "admin",
|
||||
};
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
|
||||
|
||||
// The tab strip renders into an app-level toolbar slot that does not exist here; pin the tab.
|
||||
vi.mock("./Tabs", () => ({
|
||||
default: () => null,
|
||||
usePersistedTab: () => ["access", () => {}],
|
||||
}));
|
||||
|
||||
const pendingMock = vi.fn();
|
||||
const activateMock = vi.fn();
|
||||
const resendMock = vi.fn();
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
adminInvites: () => Promise.resolve([]),
|
||||
adminPendingAccess: () => pendingMock(),
|
||||
activateUser: (id: number) => activateMock(id),
|
||||
resendUserVerification: (id: number) => resendMock(id),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import AdminUsers from "./AdminUsers";
|
||||
|
||||
/** A row as /api/admin/pending-access serves it. */
|
||||
const row = (over: Record<string, unknown> = {}) => ({
|
||||
id: 7,
|
||||
email: "later@example.com",
|
||||
display_name: null,
|
||||
role: "user",
|
||||
is_active: false,
|
||||
is_suspended: false,
|
||||
email_verified: true,
|
||||
is_demo: false,
|
||||
has_password: true,
|
||||
has_google: false,
|
||||
created_at: "2026-09-01T10:00:00Z",
|
||||
can_activate: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AdminUsers />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pendingMock.mockReset();
|
||||
activateMock.mockReset().mockResolvedValue({});
|
||||
resendMock.mockReset().mockResolvedValue({ ok: true, email: "later@example.com" });
|
||||
notifyMock.mockReset();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("the stuck sign-ups section", () => {
|
||||
it("offers Activate for a registrant whose address was already approved", async () => {
|
||||
pendingMock.mockResolvedValue([row()]);
|
||||
mount();
|
||||
|
||||
const button = await screen.findByText("users.pendingAccess.activate");
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => expect(activateMock).toHaveBeenCalledWith(7));
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "users.pendingAccess.activated:later@example.com" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no Activate when the address is not allowed, since that would be a different decision", async () => {
|
||||
// Activating an address with no approved invite makes an account that signs in with a password
|
||||
// but is turned away by Google — the whitelist box is the honest move for that.
|
||||
pendingMock.mockResolvedValue([row({ can_activate: false, email_verified: false })]);
|
||||
mount();
|
||||
|
||||
await screen.findByText("users.pendingAccess.emailUnconfirmed");
|
||||
expect(screen.queryByText("users.pendingAccess.activate")).toBeNull();
|
||||
});
|
||||
|
||||
it("names which gate is shut, so an admin sees an unconfirmed address before letting it in", async () => {
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
mount();
|
||||
|
||||
// On a no-SMTP instance this is the difference between the owner and whoever registered the
|
||||
// address first (roadmap SEC-15), so it belongs on the row, not in a tooltip.
|
||||
expect(await screen.findByText("users.pendingAccess.emailUnconfirmed")).toBeTruthy();
|
||||
expect(screen.getByText("users.pendingAccess.inactive")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("re-sends the confirmation link for an unconfirmed account", async () => {
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
mount();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
|
||||
|
||||
await waitFor(() => expect(resendMock).toHaveBeenCalledWith(7));
|
||||
});
|
||||
|
||||
it("offers no resend once the address is confirmed", async () => {
|
||||
pendingMock.mockResolvedValue([row()]);
|
||||
mount();
|
||||
|
||||
await screen.findByText("users.pendingAccess.activate");
|
||||
expect(screen.queryByLabelText("users.pendingAccess.resend")).toBeNull();
|
||||
});
|
||||
|
||||
it("says the mail was never sent when the instance has no SMTP", async () => {
|
||||
const { HttpError } = await import("../lib/api");
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
resendMock.mockRejectedValue(
|
||||
new HttpError(409, "ignored", { code: "resend", reason: "email_off" }),
|
||||
);
|
||||
mount();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
|
||||
|
||||
// Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to fall
|
||||
// back on. A button the admin pressed must not answer with a success toast.
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "users.pendingAccess.resendEmailOff" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing at all when nobody is stuck", async () => {
|
||||
pendingMock.mockResolvedValue([]);
|
||||
mount();
|
||||
|
||||
// The section withdraws itself rather than leaving an empty box to explain — so wait for it to
|
||||
// GO, not merely for the request to have been made.
|
||||
await waitFor(() => expect(screen.queryByText("users.pendingAccess.title")).toBeNull());
|
||||
expect(pendingMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The honest states of the sign-in card (E9.5 S4). Two things are under test and they share a root:
|
||||
// a screen that does not name the address it is talking about hides an alias mismatch. On 2026-09-03
|
||||
// a registration as `np.temu@gmail.com` met a Google claim of `nptemu@gmail.com` and every screen in
|
||||
// the funnel stayed silent about it.
|
||||
//
|
||||
// entryUrl snapshots window.location at IMPORT time, so the access-notice cases reset the module
|
||||
// registry and set the URL before importing — that is the behaviour under test, not a workaround.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
const noVerify: VerifyEntry = { outcome: null, email: null, dismiss: () => {} };
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
/** Mount the real card at a given entry URL, with i18n as a key-passthrough and /auth/* stubbed. */
|
||||
async function mountCard(url: string, apiOverrides: Record<string, unknown> = {}) {
|
||||
window.history.replaceState(null, "", url);
|
||||
vi.resetModules();
|
||||
vi.doMock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && typeof opts.contact === "string" ? `${key}:${opts.contact}` : key,
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.doMock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
authConfig: () => Promise.resolve({ google_enabled: false }),
|
||||
...apiOverrides,
|
||||
},
|
||||
};
|
||||
});
|
||||
const { HttpError } = await import("../lib/api");
|
||||
const { default: Welcome } = await import("./Welcome");
|
||||
await act(async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
});
|
||||
return { HttpError, Welcome };
|
||||
}
|
||||
|
||||
/** Fill the sign-in form and submit it. */
|
||||
async function signIn(email = "someone@example.com") {
|
||||
fireEvent.change(screen.getByPlaceholderText("welcome.auth.email"), { target: { value: email } });
|
||||
fireEvent.change(screen.getByPlaceholderText("welcome.auth.password"), {
|
||||
target: { value: "correct horse battery" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("welcome.auth.signinButton"));
|
||||
});
|
||||
}
|
||||
|
||||
/** A password-login refusal carrying the server's structured detail. */
|
||||
function refusal(
|
||||
HttpError: typeof import("../lib/api").HttpError,
|
||||
detail: Record<string, unknown>,
|
||||
) {
|
||||
return () => Promise.reject(new HttpError(403, "ignored", { code: "login", ...detail }));
|
||||
}
|
||||
|
||||
describe("a refused sign-in becomes a named state", () => {
|
||||
it("names the address the account is on, not the one that was typed", async () => {
|
||||
const passwordLogin = vi.fn();
|
||||
const { HttpError } = await mountCard("/", { passwordLogin });
|
||||
passwordLogin.mockImplementation(
|
||||
refusal(HttpError, {
|
||||
reason: "pending_approval",
|
||||
email: "np.temu@gmail.com",
|
||||
email_verified: true,
|
||||
approved: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await signIn("nptemu@gmail.com");
|
||||
|
||||
expect(screen.getByText("welcome.auth.blocked.pending_approval.title")).toBeTruthy();
|
||||
expect(screen.getByText("np.temu@gmail.com")).toBeTruthy();
|
||||
// The password was already proven correct, so the form is not the next step and must be gone —
|
||||
// leaving it up would only invite a retry that cannot succeed.
|
||||
expect(screen.queryByPlaceholderText("welcome.auth.password")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports each gate from its own flag, so an approved-but-unverified account is told the truth", async () => {
|
||||
const passwordLogin = vi.fn();
|
||||
const { HttpError } = await mountCard("/", { passwordLogin });
|
||||
passwordLogin.mockImplementation(
|
||||
refusal(HttpError, {
|
||||
reason: "email_unverified",
|
||||
email: "approved.first@example.com",
|
||||
email_verified: false,
|
||||
approved: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await signIn("approved.first@example.com");
|
||||
|
||||
// E9.5 S1 made the two gates order-independent; a screen that assumed a sequence would send this
|
||||
// user off to wait for an admin who has already decided.
|
||||
expect(screen.getByText("welcome.auth.blocked.gate.emailPending")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.blocked.gate.approvalDone")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows no checklist for a suspension, which is not a gate that opens on its own", async () => {
|
||||
const passwordLogin = vi.fn();
|
||||
const { HttpError } = await mountCard("/", { passwordLogin });
|
||||
passwordLogin.mockImplementation(
|
||||
refusal(HttpError, {
|
||||
reason: "suspended",
|
||||
email: "banned@example.com",
|
||||
operator: "ops@example.com",
|
||||
}),
|
||||
);
|
||||
|
||||
await signIn("banned@example.com");
|
||||
|
||||
expect(screen.getByText("welcome.auth.blocked.suspended.title")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.blocked.operator:ops@example.com")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.blocked.gate.emailDone")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a wrong password as a line under the form, where the form IS the next step", async () => {
|
||||
const passwordLogin = vi.fn();
|
||||
const { HttpError } = await mountCard("/", { passwordLogin });
|
||||
passwordLogin.mockImplementation(() =>
|
||||
Promise.reject(new HttpError(401, "localized invalid", { code: "login", reason: "invalid" })),
|
||||
);
|
||||
|
||||
await signIn();
|
||||
|
||||
expect(screen.getByText("localized invalid")).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText("welcome.auth.password")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// Reported 2026-09-05: delete an account, then open its reset link. The card drew the full "set a new
|
||||
// password" form purely because a `#reset=` fragment was present, and only told the user after they
|
||||
// had typed one. Opening a dead reset link is ordinary — the token lives an hour, mail is slow, and
|
||||
// whoever got two mails clicks the older one.
|
||||
describe("an emailed reset link that is already finished", () => {
|
||||
it("refuses to draw a form that cannot succeed", async () => {
|
||||
await mountCard("/#reset=dead-token", {
|
||||
checkPasswordReset: () => Promise.resolve({ ok: false }),
|
||||
});
|
||||
|
||||
expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.deadLink.cta")).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText("welcome.auth.newPassword")).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the form when the link is still good", async () => {
|
||||
await mountCard("/#reset=live-token", {
|
||||
checkPasswordReset: () => Promise.resolve({ ok: true }),
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull();
|
||||
});
|
||||
|
||||
it("takes the form down if the link dies between the check and the submit", async () => {
|
||||
const confirmPasswordReset = vi.fn();
|
||||
const { HttpError } = await mountCard("/#reset=racing-token", {
|
||||
checkPasswordReset: () => Promise.resolve({ ok: true }),
|
||||
confirmPasswordReset,
|
||||
});
|
||||
confirmPasswordReset.mockImplementation(() =>
|
||||
Promise.reject(new HttpError(400, "ignored", { code: "reset", reason: "invalid" })),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), {
|
||||
target: { value: "brand new password" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("welcome.auth.resetButton"));
|
||||
});
|
||||
|
||||
expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the form up for a too-short password, which costs the link nothing", async () => {
|
||||
const confirmPasswordReset = vi.fn();
|
||||
const { HttpError } = await mountCard("/#reset=live-token", {
|
||||
checkPasswordReset: () => Promise.resolve({ ok: true }),
|
||||
confirmPasswordReset,
|
||||
});
|
||||
confirmPasswordReset.mockImplementation(() =>
|
||||
Promise.reject(
|
||||
new HttpError(400, "too short", { code: "reset", reason: "weak_password", min: 10 }),
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), {
|
||||
target: { value: "0123456789ab" },
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("welcome.auth.resetButton"));
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull();
|
||||
});
|
||||
|
||||
it("probes a token once, however many times the card mounts", async () => {
|
||||
const checkPasswordReset = vi.fn().mockResolvedValue({ ok: true });
|
||||
const { Welcome } = await mountCard("/#reset=counted-token", { checkPasswordReset });
|
||||
|
||||
await act(async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
});
|
||||
|
||||
// StrictMode double-invokes effects and the shell remounts this card as the session probe
|
||||
// settles; four probes per opened link would exhaust the endpoint's per-IP budget in two reloads
|
||||
// and silently drop back to drawing the form unchecked.
|
||||
expect(checkPasswordReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the form when the check itself fails, so a broken probe locks nobody out", async () => {
|
||||
await mountCard("/#reset=live-token", {
|
||||
checkPasswordReset: () => Promise.reject(new Error("network")),
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the access-request notice from a denied Google sign-in", () => {
|
||||
it("names the address the request was recorded for", async () => {
|
||||
await mountCard("/#access=requested&email=nptemu%40gmail.com");
|
||||
|
||||
expect(screen.getByText("welcome.auth.accessRequestedFor")).toBeTruthy();
|
||||
expect(screen.getByText("nptemu@gmail.com")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.accessLinked")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains the alias when the request is already on file under another spelling", async () => {
|
||||
await mountCard(
|
||||
"/#access=requested&email=nptemu%40gmail.com&linked=np.temu%2Bsiftlode%40gmail.com",
|
||||
);
|
||||
|
||||
expect(screen.getByText("welcome.auth.accessLinked")).toBeTruthy();
|
||||
// %2B, not a bare `+`: URLSearchParams reads the latter as a space and would mangle exactly the
|
||||
// address family this epic exists to protect.
|
||||
expect(screen.getByText("np.temu+siftlode@gmail.com")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to the unnamed notice when the server sent no address", async () => {
|
||||
await mountCard("/#access=requested");
|
||||
|
||||
expect(screen.getByText("welcome.auth.accessRequested")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.accessRequestedFor")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,11 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import Welcome from "./Welcome";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
// App confirms the emailed token and hands the outcome down; these tests are about the card's
|
||||
// sub-views, so there is nothing to report.
|
||||
const noVerify: VerifyEntry = { outcome: null, email: null, dismiss: () => {} };
|
||||
|
||||
const back = () =>
|
||||
act(() => {
|
||||
@@ -45,7 +50,7 @@ afterEach(() => {
|
||||
|
||||
describe("sign-in card sub-views and the Back button", () => {
|
||||
it("opens Forgot password as a history entry and comes back to sign in", async () => {
|
||||
render(<Welcome />);
|
||||
render(<Welcome verify={noVerify} />);
|
||||
expect(await screen.findByText("welcome.auth.signinTitle")).toBeTruthy();
|
||||
const entries = window.history.length;
|
||||
|
||||
@@ -62,7 +67,7 @@ describe("sign-in card sub-views and the Back button", () => {
|
||||
});
|
||||
|
||||
it("does the same for Create an account and the verification-email link", async () => {
|
||||
render(<Welcome />);
|
||||
render(<Welcome verify={noVerify} />);
|
||||
await screen.findByText("welcome.auth.signinTitle");
|
||||
|
||||
fireEvent.click(screen.getByText("welcome.auth.createLink"));
|
||||
@@ -76,3 +81,41 @@ describe("sign-in card sub-views and the Back button", () => {
|
||||
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// The signed-OUT half of the verification outcome: App confirms the emailed token above its
|
||||
// signed-in branch and hands the result down, so the card no longer posts anything itself. These pin
|
||||
// that each outcome still reaches the right banner — and that the banner is announced (role=status),
|
||||
// which the 2026-09-02 review asked for and which is the whole point when the alternative was a
|
||||
// screen that said nothing at all.
|
||||
describe("the verification outcome handed down from App", () => {
|
||||
const withOutcome = (outcome: VerifyEntry["outcome"]): VerifyEntry => ({
|
||||
outcome,
|
||||
email: "someone@example.com",
|
||||
dismiss: () => {},
|
||||
});
|
||||
|
||||
it("announces a confirmed address", async () => {
|
||||
render(<Welcome verify={withOutcome("ok")} />);
|
||||
const banner = await screen.findByText("welcome.auth.verifyOk");
|
||||
expect(banner.closest('[role="status"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers the reset flow when the account's password had been revoked", async () => {
|
||||
render(<Welcome verify={withOutcome("ok_reset")} />);
|
||||
expect(await screen.findByText("welcome.auth.verifyOkReset")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.verifyOkResetCta")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers a new link when the token is spent", async () => {
|
||||
render(<Welcome verify={withOutcome("invalid")} />);
|
||||
expect(await screen.findByText("welcome.auth.verifyInvalid")).toBeTruthy();
|
||||
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows no banner when there was no token to confirm", async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
await screen.findByText("welcome.auth.signinTitle");
|
||||
expect(screen.queryByText("welcome.auth.verifyOk")).toBeNull();
|
||||
expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"user_suspend": "User suspended",
|
||||
"user_unsuspend": "User reinstated",
|
||||
"user_delete": "User deleted",
|
||||
"user_activate": "Account activated",
|
||||
"user_verify_resend": "Confirmation link re-sent",
|
||||
"invite_approve": "Access request approved",
|
||||
"invite_deny": "Access request denied",
|
||||
"invite_add": "Email whitelisted",
|
||||
@@ -20,5 +22,6 @@
|
||||
"audit_clear": "Audit log cleared",
|
||||
"download_credential_set": "Download cookies set",
|
||||
"download_credential_delete": "Download cookies removed",
|
||||
"invite_delete": "Whitelist entry deleted"
|
||||
"invite_delete": "Whitelist entry deleted",
|
||||
"invite_send": "Invitation sent"
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"signOut": "Sign out",
|
||||
"addAccount": "Add another account",
|
||||
"switchTo": "Switch to {{name}}",
|
||||
"audit": "Audit log"
|
||||
"audit": "Audit log",
|
||||
"googleAddress": "Google account: {{email}}"
|
||||
},
|
||||
"sync": {
|
||||
"yours": "yours",
|
||||
@@ -55,5 +56,11 @@
|
||||
"updated": "Updated to v{{version}} — see what's changed.",
|
||||
"releaseNotes": "Release notes",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"verify": {
|
||||
"confirmed": "Email address confirmed.",
|
||||
"confirmedNamed": "Email address confirmed — {{email}}.",
|
||||
"invalid": "That verification link is no longer valid — it has expired or was already used.",
|
||||
"reset": "The password this account registered with was cleared for security; set a new one before signing in."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
"linked": "Google account linked.",
|
||||
"conflict": "That Google account is already linked to a different Siftlode account.",
|
||||
"mismatch": "That's a different Google account than the one already linked here. Sign in with the linked one.",
|
||||
"error": "Couldn't link the Google account. Please try again."
|
||||
"error": "Couldn't link the Google account. Please try again.",
|
||||
"signedInAs": "Signed in with Google as"
|
||||
},
|
||||
"googleCancelled": "Google sign-in was cancelled.",
|
||||
"password": {
|
||||
@@ -156,6 +157,17 @@
|
||||
"approveFailed": "Approve failed",
|
||||
"denyFailed": "Deny failed",
|
||||
"addedToWhitelist": "{{email}} added to the whitelist",
|
||||
"inviteHint": "Send them an invitation. Adding an address never mails anyone — this does.",
|
||||
"invitedAt": "Invited {{time}}. Sending again will mail them a second time.",
|
||||
"inviteLabel": "Send an invitation to {{email}}",
|
||||
"invited": "Invitation sent to {{email}}.",
|
||||
"inviteFailed": "Couldn't send that invitation.",
|
||||
"inviteEmailOff": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email.",
|
||||
"inviteTitle": "Send invitation?",
|
||||
"inviteConfirm": "Email {{email}} to say their address is whitelisted and Siftlode is open for them.",
|
||||
"inviteConfirmAgain": "You already invited {{email}} on {{time}}. Sending now writes to them a second time.",
|
||||
"inviteSend": "Send",
|
||||
"inviteLang": "Mail language",
|
||||
"addFailed": "Couldn't add that email",
|
||||
"requested": "requested {{time}}",
|
||||
"approveHint": "Approve — whitelist this email and email them they're in.",
|
||||
|
||||
@@ -4,6 +4,23 @@
|
||||
"access": "Access requests",
|
||||
"demo": "Demo"
|
||||
},
|
||||
"pendingAccess": {
|
||||
"title": "Signed up, but still outside",
|
||||
"body": "These people created an account that can't sign in yet. If you approved their address before they registered, no request reached the queue above — let them in here.",
|
||||
"signedUp": "Signed up {{time}}",
|
||||
"emailConfirmed": "email confirmed",
|
||||
"emailUnconfirmed": "email not confirmed",
|
||||
"inactive": "waiting for activation",
|
||||
"activate": "Activate",
|
||||
"activateHint": "Let this account in. Their address is already approved — this only finishes it.",
|
||||
"activated": "{{email}} can sign in now.",
|
||||
"activateFailed": "Couldn't activate that account.",
|
||||
"resend": "Send the confirmation link again",
|
||||
"resendHint": "Send the confirmation link again, to the address on the account.",
|
||||
"resent": "Confirmation link sent to {{email}}.",
|
||||
"resendFailed": "Couldn't send the confirmation link.",
|
||||
"resendEmailOff": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email."
|
||||
},
|
||||
"roles": {
|
||||
"title": "Users & roles",
|
||||
"intro": "Everyone who can sign in. Promote a user to admin, or back to a regular user.",
|
||||
|
||||
@@ -82,17 +82,58 @@
|
||||
"demoEmail": "Demo email",
|
||||
"demoEnter": "Enter",
|
||||
"demoDenied": "That email isn't enabled for the demo. Ask the admin to add it.",
|
||||
"registerDone": "Almost there — check your email for a verification link. After you confirm it, an admin approves your account before you can sign in.",
|
||||
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox.",
|
||||
"registerDone": "Almost there — check your email for a confirmation link, and your spam folder if it isn't there in a minute. After you confirm it, an admin approves your account before you can sign in.",
|
||||
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox, and your spam folder if it hasn't arrived in a minute.",
|
||||
"resetDone": "Your password has been set — you can sign in now.",
|
||||
"deadLink": {
|
||||
"title": "This link is no longer valid",
|
||||
"body": "A password reset link lasts one hour and works once. This one has expired, has already been used, or belongs to an account that no longer exists — so there is nothing to set a password on. Ask for a fresh one and we'll email it.",
|
||||
"short": "This reset link is no longer valid.",
|
||||
"cta": "Request a new link"
|
||||
},
|
||||
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
|
||||
"verifyOkReset": "Email confirmed. Your account was approved before you confirmed it, so the password you registered with was cleared for security — choose a new one to sign in.",
|
||||
"verifyOkReset": "Email confirmed. The password this account registered with had been cleared for security — choose a new one to sign in.",
|
||||
"verifyOkResetCta": "Set a new password",
|
||||
"verifyInvalid": "That verification link is invalid or has expired.",
|
||||
"resendCta": "Send a new link",
|
||||
"resendDone": "If that email has an unverified account, we've sent a new verification link. Check your inbox.",
|
||||
"resendDone": "If that email has an unconfirmed account, we've sent a new confirmation link. Check your inbox, and your spam folder if it hasn't arrived in a minute.",
|
||||
"oauthCancelled": "Google sign-in was cancelled. You can try again or use email and password.",
|
||||
"accessRequested": "Thanks — we recorded your request. An admin will review it.",
|
||||
"accessRequestedFor": "We've recorded your access request for:",
|
||||
"accessRequestedReview": "An admin will review it, and we'll email you either way.",
|
||||
"accessLinked": "You already had a request on file for this mailbox, under a different spelling:",
|
||||
"accessLinkedTail": "Google returns one fixed spelling of a Gmail address, so we linked this sign-in to that request instead of opening a second one. Any mail goes to the address above.",
|
||||
"blocked": {
|
||||
"addressLabel": "Address on file",
|
||||
"operator": "If you think this is a mistake, contact the operator at {{contact}}.",
|
||||
"gate": {
|
||||
"emailDone": "Email address confirmed",
|
||||
"emailPending": "Email address — not confirmed yet",
|
||||
"approvalDone": "Admin approval — granted",
|
||||
"approvalPending": "Admin approval — pending"
|
||||
},
|
||||
"email_unverified": {
|
||||
"title": "Confirm your email address",
|
||||
"body": "Your password is correct — we just can't let you in until the address on this account is confirmed. Open the link we emailed you, or ask for a new one.",
|
||||
"short": "Confirm your email address first — check your inbox."
|
||||
},
|
||||
"pending_approval": {
|
||||
"title": "Waiting for approval",
|
||||
"body": "Your password is correct and your address is confirmed. An admin still has to let you in — we'll email you as soon as they decide.",
|
||||
"short": "Your account is waiting for admin approval."
|
||||
},
|
||||
"suspended": {
|
||||
"title": "Account suspended",
|
||||
"body": "This account has been suspended, so it can't sign in by any method.",
|
||||
"short": "This account has been suspended."
|
||||
},
|
||||
"invalid": {
|
||||
"short": "Invalid email or password."
|
||||
},
|
||||
"rate_limited": {
|
||||
"short": "Too many attempts. Try again shortly."
|
||||
}
|
||||
},
|
||||
"accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access.",
|
||||
"suspended": "This account has been suspended and can't sign in. If you think this is a mistake, contact the operator (check your email for details).",
|
||||
"deleted": "Your account and all its data were permanently deleted. We've emailed you a confirmation."
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"user_suspend": "Felhasználó felfüggesztve",
|
||||
"user_unsuspend": "Felhasználó visszaállítva",
|
||||
"user_delete": "Felhasználó törölve",
|
||||
"user_activate": "Fiók aktiválva",
|
||||
"user_verify_resend": "Megerősítő link újraküldve",
|
||||
"invite_approve": "Hozzáférési kérelem jóváhagyva",
|
||||
"invite_deny": "Hozzáférési kérelem elutasítva",
|
||||
"invite_add": "Email fehérlistázva",
|
||||
@@ -20,5 +22,6 @@
|
||||
"audit_clear": "Napló ürítve",
|
||||
"download_credential_set": "Letöltési cookie-k beállítva",
|
||||
"download_credential_delete": "Letöltési cookie-k eltávolítva",
|
||||
"invite_delete": "Whitelista-bejegyzés törölve"
|
||||
"invite_delete": "Whitelista-bejegyzés törölve",
|
||||
"invite_send": "Meghívó elküldve"
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"signOut": "Kijelentkezés",
|
||||
"addAccount": "Másik fiók hozzáadása",
|
||||
"switchTo": "Váltás erre: {{name}}",
|
||||
"audit": "Naplózás"
|
||||
"audit": "Naplózás",
|
||||
"googleAddress": "Google-fiók: {{email}}"
|
||||
},
|
||||
"sync": {
|
||||
"yours": "tiéd",
|
||||
@@ -55,5 +56,11 @@
|
||||
"updated": "Frissítve a(z) v{{version}} verzióra — nézd meg, mi változott.",
|
||||
"releaseNotes": "Verziójegyzék",
|
||||
"dismiss": "Elvetés"
|
||||
},
|
||||
"verify": {
|
||||
"confirmed": "Megerősítettük az e-mail-címedet.",
|
||||
"confirmedNamed": "Megerősítettük az e-mail-címedet — {{email}}.",
|
||||
"invalid": "Ez a megerősítő link már nem érvényes — lejárt, vagy már felhasználták.",
|
||||
"reset": "A fiók regisztrációkori jelszavát biztonsági okból töröltük; belépés előtt állíts be újat."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
"linked": "Google-fiók összekapcsolva.",
|
||||
"conflict": "Ez a Google-fiók már egy másik Siftlode-fiókhoz van kapcsolva.",
|
||||
"mismatch": "Ez nem az a Google-fiók, amely ide már össze van kapcsolva. Az összekapcsolttal jelentkezz be.",
|
||||
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra."
|
||||
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra.",
|
||||
"signedInAs": "Google-belépés ezzel a címmel:"
|
||||
},
|
||||
"googleCancelled": "A Google-belépés megszakadt.",
|
||||
"password": {
|
||||
@@ -156,6 +157,17 @@
|
||||
"approveFailed": "A jóváhagyás sikertelen",
|
||||
"denyFailed": "Az elutasítás sikertelen",
|
||||
"addedToWhitelist": "{{email}} hozzáadva a fehérlistához",
|
||||
"inviteHint": "Küldj neki meghívót. A cím hozzáadása soha nem küld levelet — ez igen.",
|
||||
"invitedAt": "Meghívva: {{time}}. Az újraküldés másodszor is levelet küld neki.",
|
||||
"inviteLabel": "Meghívó küldése ide: {{email}}",
|
||||
"invited": "Meghívó elküldve ide: {{email}}.",
|
||||
"inviteFailed": "Nem sikerült elküldeni a meghívót.",
|
||||
"inviteEmailOff": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Beállítások → E-mail részt.",
|
||||
"inviteTitle": "Küldesz meghívót?",
|
||||
"inviteConfirm": "Levél megy a(z) {{email}} címre arról, hogy a címe engedélyezve van, és nyitva áll előtte a Siftlode.",
|
||||
"inviteConfirmAgain": "Ezt a címet már meghívtad ekkor: {{time}} ({{email}}). Ha most küldesz, másodszor is írsz neki.",
|
||||
"inviteSend": "Küldés",
|
||||
"inviteLang": "A levél nyelve",
|
||||
"addFailed": "Nem sikerült hozzáadni ezt az e-mailt",
|
||||
"requested": "kérve: {{time}}",
|
||||
"approveHint": "Jóváhagyás — fehérlistára teszi ezt az e-mailt és e-mailben értesíti őket, hogy bekerültek.",
|
||||
|
||||
@@ -4,6 +4,23 @@
|
||||
"access": "Hozzáférési kérések",
|
||||
"demo": "Demo"
|
||||
},
|
||||
"pendingAccess": {
|
||||
"title": "Regisztrált, de még kívül van",
|
||||
"body": "Ezek a fiókok léteznek, de még nem tudnak belépni. Ha a címüket a regisztrációjuk előtt hagytad jóvá, a fenti sorba semmi nem került — itt engedheted be őket.",
|
||||
"signedUp": "Regisztrált: {{time}}",
|
||||
"emailConfirmed": "cím megerősítve",
|
||||
"emailUnconfirmed": "cím nincs megerősítve",
|
||||
"inactive": "aktiválásra vár",
|
||||
"activate": "Aktiválás",
|
||||
"activateHint": "Beengedi ezt a fiókot. A címe már jóvá van hagyva — ez csak befejezi.",
|
||||
"activated": "{{email}} mostantól be tud lépni.",
|
||||
"activateFailed": "Nem sikerült aktiválni a fiókot.",
|
||||
"resend": "Megerősítő link újraküldése",
|
||||
"resendHint": "Újraküldi a megerősítő linket, a fiókon szereplő címre.",
|
||||
"resent": "Megerősítő link elküldve ide: {{email}}.",
|
||||
"resendFailed": "Nem sikerült elküldeni a megerősítő linket.",
|
||||
"resendEmailOff": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Beállítások → E-mail részt."
|
||||
},
|
||||
"roles": {
|
||||
"title": "Felhasználók és szerepek",
|
||||
"intro": "Mindenki, aki be tud lépni. Léptess egy felhasználót adminná, vagy vissza sima felhasználóvá.",
|
||||
|
||||
@@ -82,17 +82,58 @@
|
||||
"demoEmail": "Demó e-mail",
|
||||
"demoEnter": "Belépés",
|
||||
"demoDenied": "Ez az e-mail nincs engedélyezve a demóhoz. Kérd az admint, hogy adja hozzá.",
|
||||
"registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.",
|
||||
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod.",
|
||||
"registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért, és a Spam mappát is, ha egy percen belül nem jön meg. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.",
|
||||
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod, és a Spam mappát is, ha egy percen belül nem érkezik meg.",
|
||||
"resetDone": "A jelszavad be van állítva — most már beléphetsz.",
|
||||
"deadLink": {
|
||||
"title": "Ez a link már nem érvényes",
|
||||
"body": "A jelszó-visszaállító link egy óráig él, és egyszer használható. Ez lejárt, már felhasználták, vagy olyan fiókhoz tartozik, ami már nem létezik — nincs mire jelszót állítani. Kérj újat, és elküldjük.",
|
||||
"short": "Ez a visszaállító link már nem érvényes.",
|
||||
"cta": "Új link kérése"
|
||||
},
|
||||
"verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.",
|
||||
"verifyOkReset": "E-mail megerősítve. A fiókodat még a megerősítés előtt jóváhagyták, ezért biztonsági okból töröltük a regisztrációkor megadott jelszavadat — válassz újat a belépéshez.",
|
||||
"verifyOkReset": "E-mail megerősítve. A fiók regisztrációkori jelszavát biztonsági okból korábban töröltük — válassz újat a belépéshez.",
|
||||
"verifyOkResetCta": "Új jelszó beállítása",
|
||||
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.",
|
||||
"resendCta": "Új link küldése",
|
||||
"resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod.",
|
||||
"resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod, és a Spam mappát is, ha egy percen belül nem érkezik meg.",
|
||||
"oauthCancelled": "A Google-belépés megszakadt. Próbáld újra, vagy lépj be e-maillel és jelszóval.",
|
||||
"accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.",
|
||||
"accessRequestedFor": "Rögzítettük a hozzáférési kérelmedet erre a címre:",
|
||||
"accessRequestedReview": "Egy admin átnézi, és e-mailben szólunk a döntésről.",
|
||||
"accessLinked": "Ehhez a postafiókhoz már volt kérelmed, csak más írásmóddal:",
|
||||
"accessLinkedTail": "A Google egy rögzített írásmódot ad vissza a Gmail-címekből, ezért ezt a belépést a meglévő kérelemhez kötöttük, nem indítottunk újat. A levelek a fenti címre mennek.",
|
||||
"blocked": {
|
||||
"addressLabel": "Rögzített cím",
|
||||
"operator": "Ha tévedésnek gondolod, keresd az üzemeltetőt: {{contact}}.",
|
||||
"gate": {
|
||||
"emailDone": "E-mail-cím megerősítve",
|
||||
"emailPending": "E-mail-cím — még nincs megerősítve",
|
||||
"approvalDone": "Admin jóváhagyás — megvan",
|
||||
"approvalPending": "Admin jóváhagyás — folyamatban"
|
||||
},
|
||||
"email_unverified": {
|
||||
"title": "Erősítsd meg az e-mail-címed",
|
||||
"body": "A jelszavad jó — csak addig nem engedhetünk be, amíg a fiókon lévő cím nincs megerősítve. Nyisd meg a kiküldött linket, vagy kérj újat.",
|
||||
"short": "Előbb erősítsd meg az e-mail-címed — nézd meg a postafiókod."
|
||||
},
|
||||
"pending_approval": {
|
||||
"title": "Jóváhagyásra vár",
|
||||
"body": "A jelszavad jó, a címed megerősítve. Már csak arra várunk, hogy egy admin beengedjen — szólunk e-mailben, amint döntött.",
|
||||
"short": "A fiókod admin jóváhagyásra vár."
|
||||
},
|
||||
"suspended": {
|
||||
"title": "Felfüggesztett fiók",
|
||||
"body": "Ez a fiók fel van függesztve, így semmilyen módon nem tud belépni.",
|
||||
"short": "Ez a fiók fel van függesztve."
|
||||
},
|
||||
"invalid": {
|
||||
"short": "Hibás e-mail-cím vagy jelszó."
|
||||
},
|
||||
"rate_limited": {
|
||||
"short": "Túl sok próbálkozás. Próbáld újra kicsit később."
|
||||
}
|
||||
},
|
||||
"accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól.",
|
||||
"suspended": "Ez a fiók fel van függesztve, nem tud belépni. Ha tévedésnek gondolod, keresd az üzemeltetőt (a részletek az e-mailedben).",
|
||||
"deleted": "A fiókod és minden adata véglegesen törölve lett. A megerősítést e-mailben is elküldtük."
|
||||
|
||||
@@ -27,6 +27,7 @@ import { accountApi } from "./api/account";
|
||||
export type * from "./api/account";
|
||||
import { authApi } from "./api/auth";
|
||||
export type * from "./api/auth";
|
||||
export { loginBlocked, deadResetLink } from "./api/auth";
|
||||
import { setupApi } from "./api/setup";
|
||||
export type * from "./api/setup";
|
||||
import { feedApi } from "./api/feed";
|
||||
|
||||
@@ -10,12 +10,21 @@ export interface Me {
|
||||
role: string;
|
||||
is_demo: boolean;
|
||||
has_google: boolean;
|
||||
/** The address Google answers with, ONLY when it differs from `email` — the backend does that
|
||||
* comparison, so a surface can render this whenever it is present. Someone who registered as
|
||||
* `me+siftlode@gmail.com` keeps that address; this is the other one that also signs them in. */
|
||||
google_email: string | null;
|
||||
has_password: boolean;
|
||||
google_enabled: boolean;
|
||||
plex_enabled: boolean;
|
||||
can_read: boolean;
|
||||
can_write: boolean;
|
||||
pending_invites: number;
|
||||
/** Admin-only: sign-ups an admin still has to let in — approved BEFORE they registered, so nothing
|
||||
* ever reached the Access-requests queue and its badge read zero (roadmap UX-25). Counted apart
|
||||
* from pending_invites because they are different facts, and widening one number silently would
|
||||
* change what an existing field means. */
|
||||
pending_activations: number;
|
||||
/** Admin-only: false when SMTP is incomplete, so every outbound message is silently skipped. */
|
||||
email_configured: boolean;
|
||||
preferences: Record<string, unknown>;
|
||||
|
||||
@@ -25,6 +25,24 @@ export interface Invite {
|
||||
/** The address stays allow-listed by the server's env ALLOWED_EMAILS / ADMIN_EMAILS, so removing
|
||||
* this row would NOT take access away. The delete confirmation says so. */
|
||||
still_allowed_by_env: boolean;
|
||||
/** When the admin last invited this person, or null if never. Adding an address to the whitelist
|
||||
* is deliberately silent — it opens a door rather than knocking — so telling them is a separate
|
||||
* act, and this is how the admin sees they have already done it. */
|
||||
invited_at: string | null;
|
||||
/** The language this person asked us in, or null when nobody ever told us — a row the admin
|
||||
* whitelisted by hand. The invitation dialog preselects it and falls back to the admin's own UI
|
||||
* language, so the guess is visible and correctable before anything is sent. */
|
||||
lang: string | null;
|
||||
}
|
||||
|
||||
/** A sign-up that is still outside, as the admin screen shows it. Same shape as a user row plus the
|
||||
* one thing the client cannot work out for itself. */
|
||||
export interface PendingAccessRow extends AdminUserRow {
|
||||
/** The address is already allowed and the account is merely inactive, so Activate finishes a
|
||||
* decision the admin has already made. False when there is no approved invite behind it —
|
||||
* activating then would make an account that signs in with a password but is turned away by
|
||||
* Google, so the whitelist box is the honest move instead. */
|
||||
can_activate: boolean;
|
||||
}
|
||||
|
||||
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
|
||||
@@ -169,10 +187,28 @@ export const adminApi = {
|
||||
resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
|
||||
req("/api/admin/demo/reset", { method: "POST" }),
|
||||
// --- access invites ---
|
||||
// Sign-ups that registered but still cannot get in — the admin lifeline for roadmap UX-25.
|
||||
adminPendingAccess: (): Promise<PendingAccessRow[]> => req("/api/admin/pending-access"),
|
||||
// Let one in. Admin-only and explicit: it changes discoverability, not authority — the same thing
|
||||
// was always possible by re-entering the address in the whitelist box.
|
||||
activateUser: (id: number): Promise<AdminUserRow> =>
|
||||
req(`/api/admin/users/${id}/activate`, { method: "POST" }, { quiet: true }),
|
||||
// Re-send someone's confirmation link. Unlike the public resend this one says what happened,
|
||||
// because the caller is already an authenticated admin.
|
||||
resendUserVerification: (id: number): Promise<{ ok: boolean; email: string }> =>
|
||||
req(`/api/admin/users/${id}/resend-verification`, { method: "POST" }, { quiet: true }),
|
||||
adminInvites: (status?: string): Promise<Invite[]> =>
|
||||
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
|
||||
approveInvite: (id: number) => req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
|
||||
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
|
||||
// Tell someone their address is whitelisted. Never automatic: adding the address stays silent, so
|
||||
// this is the deliberate act that actually reaches a person.
|
||||
sendInvitation: (id: number, lang: string): Promise<Invite> =>
|
||||
req(
|
||||
`/api/admin/invites/${id}/invite`,
|
||||
{ method: "POST", body: JSON.stringify({ lang }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
addInvite: (email: string) =>
|
||||
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
/** `still_allowed_by_env` is true when the address stays allow-listed by the server's env
|
||||
|
||||
@@ -1,7 +1,56 @@
|
||||
// The sign-in funnel: public auth config, email+password register/login/reset, email verification,
|
||||
// session management, access requests, and the hidden demo login. Errors on the interactive paths
|
||||
// are surfaced inline (quiet) rather than via the global modal. Only touches core's req.
|
||||
import { req } from "./core";
|
||||
import { req, uiLang } from "./core";
|
||||
|
||||
/** Why password sign-in refused. The three account-shaped reasons name the mailbox and both gates,
|
||||
* so the card can render an honest state instead of a red line; `invalid` and `rate_limited` answer
|
||||
* BEFORE the password is proven and therefore carry nothing (they must stay identical for a real
|
||||
* account and an unknown one). Since E9.5 S1 the gates are order-independent — read both flags, never
|
||||
* infer one from the other. */
|
||||
export type LoginBlocked = {
|
||||
reason: "email_unverified" | "pending_approval" | "suspended" | "invalid" | "rate_limited";
|
||||
/** The address ON THE ACCOUNT — not the spelling that was typed. Absent for the two pre-proof
|
||||
* reasons. Naming it is what would have exposed the 2026-09-03 Gmail-alias mismatch in a second. */
|
||||
email?: string;
|
||||
emailVerified?: boolean;
|
||||
approved?: boolean;
|
||||
/** Operator contact, when the instance publishes one (suspended accounts only). */
|
||||
operator?: string;
|
||||
};
|
||||
|
||||
/** Did a password-reset call fail because the LINK itself is finished — spent, expired, or belonging
|
||||
* to an account that no longer exists — rather than because the new password was too short? The two
|
||||
* arrive as the same 400, and only the first one means the form should come down. */
|
||||
export function deadResetLink(detail: unknown): boolean {
|
||||
const d = detail as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "reset" && d.reason === "invalid";
|
||||
}
|
||||
|
||||
const LOGIN_REASONS = new Set<LoginBlocked["reason"]>([
|
||||
"email_unverified",
|
||||
"pending_approval",
|
||||
"suspended",
|
||||
"invalid",
|
||||
"rate_limited",
|
||||
]);
|
||||
|
||||
/** Read a `{code:"login", …}` error detail into LoginBlocked, or null for anything else — including
|
||||
* a reason a NEWER server invented, which the card must fall back to a plain message for rather than
|
||||
* render as a blank panel. */
|
||||
export function loginBlocked(detail: unknown): LoginBlocked | null {
|
||||
const d = detail as Record<string, unknown> | null | undefined;
|
||||
if (!d || d.code !== "login") return null;
|
||||
const reason = d.reason as LoginBlocked["reason"];
|
||||
if (!LOGIN_REASONS.has(reason)) return null;
|
||||
return {
|
||||
reason,
|
||||
email: typeof d.email === "string" ? d.email : undefined,
|
||||
emailVerified: typeof d.email_verified === "boolean" ? d.email_verified : undefined,
|
||||
approved: typeof d.approved === "boolean" ? d.approved : undefined,
|
||||
operator: typeof d.operator === "string" ? d.operator : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
// Public: which sign-in options this instance offers (e.g. hide Google when not configured).
|
||||
@@ -12,12 +61,16 @@ export const authApi = {
|
||||
}> => req("/auth/config"),
|
||||
|
||||
// --- email + password (errors handled inline via quiet) ---
|
||||
// `lang` is stamped here rather than passed in: this is the ONE moment the sign-up language
|
||||
// exists anywhere the server can see it (the account does not exist yet to carry it), and every
|
||||
// mail this person ever gets is written from what lands here.
|
||||
register: (email: string, password: string): Promise<{ status: string }> =>
|
||||
req(
|
||||
"/auth/register",
|
||||
{ method: "POST", body: JSON.stringify({ email, password }) },
|
||||
{ method: "POST", body: JSON.stringify({ email, password, lang: uiLang() }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
// A refusal carries a structured `{code:"login", reason, …}` detail — see LoginBlocked above.
|
||||
passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
|
||||
req(
|
||||
"/auth/password-login",
|
||||
@@ -30,6 +83,15 @@ export const authApi = {
|
||||
{ method: "POST", body: JSON.stringify({ email }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
// Is a `#reset=` token still usable? Answers WITHOUT spending it, so the card can refuse to draw a
|
||||
// form that cannot succeed (a deleted account, an expired or already-used link). Advisory only —
|
||||
// confirmPasswordReset stays the authority, since the token can die between the two calls.
|
||||
checkPasswordReset: (token: string): Promise<{ ok: boolean }> =>
|
||||
req(
|
||||
"/auth/password-reset/check",
|
||||
{ method: "POST", body: JSON.stringify({ token }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
|
||||
req(
|
||||
"/auth/password-reset/confirm",
|
||||
@@ -37,10 +99,15 @@ export const authApi = {
|
||||
{ quiet: true },
|
||||
),
|
||||
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
|
||||
// `password_reset_required` = SEC-10 dropped the password this account registered with (an admin
|
||||
// approved it before it was verified), so signing in would fail with the uniform credentials error.
|
||||
// The card sends the user to the reset flow instead of leaving them to guess.
|
||||
verifyEmail: (token: string): Promise<{ ok: boolean; password_reset_required?: boolean }> =>
|
||||
// `password_reset_required` = SEC-10 dropped the password this account registered with, so signing
|
||||
// in would fail with the uniform credentials error. The card sends the user to the reset flow instead
|
||||
// of leaving them to guess. Rare since E9.5 S1: only accounts stripped before that change, and the
|
||||
// Google-adoption path, still reach it.
|
||||
// `email` is the address that was just confirmed — echoed back so the banner can name it, which
|
||||
// matters when the browser holds a session for a DIFFERENT account.
|
||||
verifyEmail: (
|
||||
token: string,
|
||||
): Promise<{ ok: boolean; password_reset_required?: boolean; email?: string }> =>
|
||||
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
|
||||
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
|
||||
// reveals whether the email has a pending account.
|
||||
@@ -61,8 +128,13 @@ export const authApi = {
|
||||
),
|
||||
|
||||
// --- onboarding: access requests + demo entry ---
|
||||
// Same reasoning as register: the requester has no account to carry a language preference, so the
|
||||
// page they asked from is what decides the language of the approval or denial mail.
|
||||
requestAccess: (email: string): Promise<{ status: string }> =>
|
||||
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
req("/auth/request-access", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, lang: uiLang() }),
|
||||
}),
|
||||
// Hidden demo entry: a whitelisted email logs into the shared demo account, no Google
|
||||
// sign-in. Returns whether a session was established.
|
||||
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
|
||||
|
||||
@@ -8,6 +8,12 @@ import { formatBytes } from "../format";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
|
||||
|
||||
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
|
||||
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
|
||||
* every caller is one every caller can forget, which is how the registration language shipped as a
|
||||
* field nobody ever filled (E9.5 S6 → S7). */
|
||||
export const uiLang = (): string => i18n.language.split("-")[0] || "en";
|
||||
|
||||
export class HttpError extends Error {
|
||||
status: number;
|
||||
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
||||
@@ -31,7 +37,10 @@ function localizeDetail(d: unknown): string {
|
||||
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
|
||||
// "check your input" message rather than leaking them into a translated sentence.
|
||||
if (Array.isArray(d)) return t("errors.validation");
|
||||
const o = d as { code?: string; reason?: string; limit?: number } | null | undefined;
|
||||
const o = d as
|
||||
| { code?: string; reason?: string; limit?: number; min?: number }
|
||||
| null
|
||||
| undefined;
|
||||
if (o?.code === "quota") {
|
||||
if (o.reason === "max_bytes") {
|
||||
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
|
||||
@@ -46,6 +55,19 @@ function localizeDetail(d: unknown): string {
|
||||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||||
if (o?.code === "cookies")
|
||||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
||||
// Password sign-in refusals (E9.5 S4). The card renders the account-shaped ones as a full panel
|
||||
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
|
||||
// gets. A reason this build doesn't know falls through to the generic message rather than showing
|
||||
// a raw key, which is what an i18n miss would otherwise put on screen.
|
||||
if (o?.code === "login")
|
||||
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
|
||||
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
|
||||
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
|
||||
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
|
||||
if (o?.code === "reset")
|
||||
return o.reason === "weak_password"
|
||||
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
|
||||
: t("welcome.auth.deadLink.short");
|
||||
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
|
||||
if (o?.code === "upload_too_large")
|
||||
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// First-run install wizard: probes install state and drives admin/config/email setup with the
|
||||
// one-time token printed to the container logs (carried via setupHeaders). Every write is quiet so
|
||||
// the wizard renders errors inline. Only touches core's req + setupHeaders.
|
||||
import { req, setupHeaders } from "./core";
|
||||
import { req, setupHeaders, uiLang } from "./core";
|
||||
|
||||
export const setupApi = {
|
||||
setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"),
|
||||
@@ -22,7 +22,11 @@ export const setupApi = {
|
||||
setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> =>
|
||||
req(
|
||||
"/api/setup/test-email",
|
||||
{ method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) },
|
||||
{
|
||||
method: "POST",
|
||||
headers: setupHeaders(token),
|
||||
body: JSON.stringify({ to, lang: uiLang() }),
|
||||
},
|
||||
{ quiet: true },
|
||||
),
|
||||
setupFinish: (token: string): Promise<{ ok: boolean }> =>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// The URL the app was OPENED with, captured once when this module is first imported — before any
|
||||
// component renders and before anything rewrites the address bar.
|
||||
//
|
||||
// Why a module-level snapshot rather than a `useState(() => …)` in whichever component needs it: the
|
||||
// 2026-09-02 incident was exactly this race. The nav-param mirrors mount ABOVE the lazily-loaded
|
||||
// <App>, and their mount effect replaceState'd the fragment away before AuthCard had snapshotted it,
|
||||
// so the emailed verification token was gone by the time anyone looked for it. 0.78.7 fixed that by
|
||||
// teaching urlWithParams/clearParams to carry the hash, but that leaves the guarantee resting on
|
||||
// every future URL writer remembering to do the same. Reading the entry URL once, at import time,
|
||||
// cannot lose the race no matter who mounts first or which chunk loads when.
|
||||
//
|
||||
// Secret tokens (verify, reset) ride the FRAGMENT so they never reach the server, a proxy log or a
|
||||
// Referer header (SB3); so does the Google access-request notice (`access`, and the addresses it
|
||||
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
|
||||
// with nothing personal in them (login, deleted) ride the query string.
|
||||
|
||||
export const entrySearch = new URLSearchParams(window.location.search);
|
||||
export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
|
||||
/** One-time QUERY flags: safe to drop once the snapshot above has them, because re-reading them on a
|
||||
* reload would re-show a notice about something that already happened (`access` only appears here
|
||||
* in its pre-S4 shape — it rides the fragment now). Deliberately NOT `link`,
|
||||
* `oauth`, `admin` or the setup `token` — those have their own readers, and some of them (the setup
|
||||
* wizard, the lazily-mounted module views) do not run until several commits later. */
|
||||
const ONE_TIME_QUERY = ["login", "deleted", "access"] as const;
|
||||
|
||||
/** Strip the one-time entry state from the address bar, so a token doesn't linger in history or get
|
||||
* re-sent on a reload. ONE owner calls this (useVerifyEntry, which App mounts unconditionally) — a
|
||||
* second caller would be harmless but makes it unclear who is responsible.
|
||||
*
|
||||
* SURGICAL, and that is the whole of it. This used to blank the entire query string, the fragment
|
||||
* and `history.state`, which was safe only while it lived inside the signed-out auth card; moving it
|
||||
* up to App (E9.5 S2) pointed it at every load. It then ran on the FIRST commit — before the setup
|
||||
* wizard mounts behind its own `?token=`, before App's own `?link=`/`?oauth=` effects read theirs,
|
||||
* and before a lazily-loaded module view reads its `?v=`/`?chan=` deep link — and took
|
||||
* `history.state` (scroll restoration, the channel entry) with it. Everything not listed above is
|
||||
* somebody else's to clear, and `clearParams` is how they do it. */
|
||||
export function clearEntryUrl(): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const dropped = ONE_TIME_QUERY.filter((k) => params.has(k));
|
||||
for (const k of dropped) params.delete(k);
|
||||
// A `#reset=` token that nothing has spent STAYS. Only the signed-out auth card consumes it, so in
|
||||
// a browser that already holds a session nobody reads it — and erasing it there turned the link
|
||||
// into a dead end with no explanation: the page loads, nothing happens, and clicking the link
|
||||
// again does the same, because the token is untouched and the session is still there. Leaving it
|
||||
// in the address bar keeps the recovery `main` had, where this strip lived inside the auth card:
|
||||
// sign out (which reloads, fragment intact) and the reset form is waiting. Once the card has used
|
||||
// it the token is spent, so a later reload meets S4's named dead-link screen — which is true.
|
||||
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
const keep = hash.has("reset") ? window.location.hash : "";
|
||||
if (!dropped.length && !window.location.hash) return;
|
||||
if (!dropped.length && keep) return;
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(
|
||||
// Preserved, not nulled: it carries scroll restoration and the channel entry.
|
||||
window.history.state,
|
||||
"",
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}${keep}`,
|
||||
);
|
||||
}
|
||||
@@ -86,6 +86,7 @@ export const qk = {
|
||||
// --- admin ---
|
||||
adminUsers: () => ["admin-users"] as const,
|
||||
adminInvites: () => ["admin-invites"] as const,
|
||||
adminPendingAccess: () => ["admin-pending-access"] as const,
|
||||
adminConfig: () => ["admin-config"] as const,
|
||||
adminAudit: () => ["admin-audit"] as const,
|
||||
adminStorage: () => ["admin-storage"] as const,
|
||||
|
||||
@@ -14,6 +14,34 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.80.0",
|
||||
date: "2026-09-05",
|
||||
summary:
|
||||
"Getting into Siftlode used to take nine steps, three of which could quietly end in nothing — and one of which deleted the password you had just chosen. It is now one mail and one click in the usual case, every screen says which of the two gates you are actually waiting on, and the messages arrive in your own language.",
|
||||
features: [
|
||||
"Siftlode's emails are now written in Hungarian or English, whichever you use. Every message — confirming your address, an approval, a password reset, a role change, an account closure — follows the language you signed up in or later chose in the app. Nothing else on the site changes: a mail arrives once, in whatever language it was written in, which is the one place you cannot simply switch and re-read.",
|
||||
"Being approved usually takes one message now. If you have already signed up and your address is still unconfirmed, the approval mail carries the confirmation link itself and says so — you no longer have to find and open a second message that arrived hours earlier. When you have not signed up yet, it says a confirmation mail will follow, so its absence is something you would notice.",
|
||||
"The two gates — confirming your email and being let in by an admin — no longer have to happen in a set order, and the screen names the one you are actually waiting on, with the exact address it has on file. If your address needs confirming, it offers to send a fresh link on the spot.",
|
||||
'Signed in with Google under a different address than the one you registered with? Settings and the account menu now show both, so the pill saying "Connected" also says who you are connected as. It only appears when the two differ.',
|
||||
"Admins can see the people who signed up but still cannot get in, and which of the two gates each one is stuck behind. One click lets in someone whose address was approved before they registered, and a resend button gets a fresh confirmation link to anyone whose first one went astray — and says plainly if the instance has no working mail setup rather than reporting a send that never happened.",
|
||||
"Whitelisting an address stays silent, as it always has, but telling that person is now its own button — with a dialog that picks the language to write them in, and warns you if you have already written to them.",
|
||||
],
|
||||
fixes: [
|
||||
"The link in your confirmation email now works wherever you open it. In a browser already signed in to Siftlode it did nothing at all — the page loaded, the link was gone from the address bar, and nothing said whether it had worked; only a private window ever confirmed an address. Pasting the link into a tab that already had Siftlode open failed the same way.",
|
||||
"Gmail addresses that differ only by dots or a +tag are one mailbox again, and Siftlode treats them as one person. Approving hartmann.peter1986@ no longer leaves the account registered as hartmannpeter1986@ locked out, and signing in with Google no longer quietly files a second access request for someone who was approved weeks ago.",
|
||||
"The address you registered with is yours to keep. Signing in with Google used to overwrite it with the Google account's primary address, which cannot contain a +tag — so a me+siftlode@gmail.com sign-up lost its tag and could no longer sign in with the address it had chosen. Password resets and confirmation resends now reach you under any spelling of your mailbox, and the mail goes to the address on your account.",
|
||||
'An admin approving your account no longer deletes the password you chose at sign-up. Because approval usually happens before you read your mail, "your password was cleared for security" had become the normal experience rather than the rare one.',
|
||||
'Signing up again with an address that was already registered but never confirmed sends the confirmation link again. It used to answer with the same cheerful "check your inbox" and send nothing at all, which is the likeliest explanation for confirmation mails that "never arrived".',
|
||||
'Opening a password-reset link that has expired or was already used no longer draws the full "set a new password" form for you to fill in before telling you. It says the link is spent and offers a new one. The check is done without spending the link, so simply opening it costs you nothing.',
|
||||
"A refused sign-in explains itself in your language instead of showing a raw English message, and the emails now say how long their links last and that they work only once.",
|
||||
"The three messages you can be left waiting on now name the spam folder, because the first mail to a brand-new address is the one most likely to land there.",
|
||||
],
|
||||
chores: [
|
||||
"E9.5, seven sprints. S1 removed the SEC-10 revoke from the admin-approval path (it stays on Google adoption, where ownership is attested). S2 moved verify-token consumption above App's signed-in/signed-out branch and snapshots the entry URL at import time, so no later URL writer can steal it; a hashchange listener covers fragment-only navigation, which UAT found on a plain-text mailed link. S3 added utils.canonical_email + a CanonicalEmailMixin deriving users/invites.email_canonical on every write (migration 0079), swept all eight matching sites, and kept the typed address for delivery; users.google_email (0080) surfaces the second address only when it differs. S4 gave every password_login refusal the {code, reason} shape and added a non-consuming password-reset/check. S5 added can_activate/count_pending_activations in core plus the admin surface and a badge that counts what actually waits on an admin. S6 branched the approval mail on what is left to do (7-day token) and recorded invites.invited_at (0081). S7 moved twelve mails into app/mailtext.py with a parity test over keys AND placeholders, resolved the language in the request handler rather than the background task, and captured invites.lang (0082) so a decision mail answers in the language the request was made in.",
|
||||
"The closing review ran four rounds across seven dimensions and converged 12 → 4 → 3 → 2, every finding adversarially verified. It caught that clearEntryUrl had been blanking the whole query string on every load — which would have shipped a broken first-run setup — that the env allow-list still matched literally while invites had gone canonical, that invited_at recorded the intent rather than the send, and that three rewritten mailers never rendered in any test. It also refuted S1's own argument that the approval-time revoke was redundant, and then, in round 2, refuted the round-1 fix for that: \"the newest registration owns an unverified account\" is symmetric, so an attacker can simply register last, and it handed anonymous callers a write onto a real account's password — including the rows SEC-10 had deliberately left password-less. Reverted, with the attempt written into the roadmap so it is not tried again; the residual is SEC-16 and the owner's recourse remains password-reset.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.79.0",
|
||||
date: "2026-09-03",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "./api";
|
||||
import { clearEntryUrl, entryHash } from "./entryUrl";
|
||||
|
||||
/** What confirming the emailed token produced. `ok_reset` means the address is confirmed but this
|
||||
* account's password had been revoked (SEC-10), so signing in would only meet the uniform credentials
|
||||
* error — the user has to set a new one. */
|
||||
type VerifyOutcome = "ok" | "ok_reset" | "invalid";
|
||||
|
||||
export type VerifyEntry = {
|
||||
outcome: VerifyOutcome | null;
|
||||
/** The address that was confirmed, when the server named it. */
|
||||
email: string | null;
|
||||
/** Dismiss the outcome — the banner is a one-time entry notice, not persistent state. */
|
||||
dismiss: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirm a `#verify=` token from the entry URL, wherever the user opened it.
|
||||
*
|
||||
* App calls this ABOVE its signed-in/signed-out branch, which is the whole point: the token used to be
|
||||
* consumed inside Welcome.tsx, and `App.tsx` renders <Welcome /> only when there is no session. Open
|
||||
* the emailed link in a browser that already holds a Siftlode session and the token was never posted,
|
||||
* the fragment was then stripped, and the user got no signal at all — the link simply did nothing.
|
||||
* Incognito worked, which is why it survived so long.
|
||||
*
|
||||
* The link does not always arrive as a page LOAD. When the app is already open in the tab and the URL
|
||||
* differs only in the fragment, the browser performs a same-document navigation — no reload, so the
|
||||
* import-time snapshot never sees the new token. Pasting the emailed link into a running tab's address
|
||||
* bar does exactly that, and our verification mails were plain text until 0.78.8, which makes pasting
|
||||
* the normal way to use one. Without the `hashchange` listener below the token would sit unconsumed in
|
||||
* the address bar: the same silence signed-in users met before, in a second disguise.
|
||||
*
|
||||
* Each token is posted exactly once. It is single-use, so a repeat POST (a StrictMode remount, any
|
||||
* re-render) would 400 and wrongly flip a successful confirmation to "invalid".
|
||||
*/
|
||||
export function useVerifyEntry(): VerifyEntry {
|
||||
const [token, setToken] = useState<string | null>(() => entryHash.get("verify"));
|
||||
const [outcome, setOutcome] = useState<VerifyOutcome | null>(null);
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
const consumed = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const next = new URLSearchParams(window.location.hash.replace(/^#/, "")).get("verify");
|
||||
if (next) setToken(next);
|
||||
};
|
||||
// replaceState (how clearEntryUrl strips the fragment) does NOT fire hashchange, so this cannot
|
||||
// feed itself.
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear the address bar even with no token to consume, so a spent one leaves no trace on a
|
||||
// reload. An unspent `#reset=` is the exception and `clearEntryUrl` says why: nothing in a
|
||||
// signed-in tree reads it, so erasing it there is a dead end rather than a cleanup.
|
||||
clearEntryUrl();
|
||||
if (!token || consumed.current.has(token)) return;
|
||||
consumed.current.add(token);
|
||||
// A second link arriving over an unread first outcome must not leave the OLD banner standing.
|
||||
setOutcome(null);
|
||||
setEmail(null);
|
||||
api
|
||||
.verifyEmail(token)
|
||||
.then((r) => {
|
||||
setOutcome(r.password_reset_required ? "ok_reset" : "ok");
|
||||
setEmail(r.email ?? null);
|
||||
})
|
||||
.catch(() => setOutcome("invalid"));
|
||||
}, [token]);
|
||||
|
||||
return { outcome, email, dismiss: () => setOutcome(null) };
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The emailed verification link, confirmed wherever it is opened. The regression this pins: the token
|
||||
// used to be consumed inside Welcome.tsx, which App renders ONLY when there is no session — so opening
|
||||
// the link in a browser that already held a Siftlode session posted nothing, the fragment was then
|
||||
// stripped, and the user got no signal at all. Incognito worked, which is why it survived.
|
||||
//
|
||||
// entryUrl snapshots window.location at IMPORT time, so each case resets the module registry and sets
|
||||
// the URL before importing — that is the behaviour under test, not a workaround.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, act } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
/** Mount the hook at a given entry URL with a stubbed /auth/verify, and hand back what it reported. */
|
||||
async function mountAt(url: string, verifyEmail: ReturnType<typeof vi.fn>) {
|
||||
// Carries whatever state the case set up: history.state is part of what the hook must not destroy.
|
||||
window.history.replaceState(window.history.state, "", url);
|
||||
vi.resetModules();
|
||||
vi.doMock("./api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./api")>();
|
||||
return { ...actual, api: { ...actual.api, verifyEmail } };
|
||||
});
|
||||
const { useVerifyEntry } = await import("./useVerifyEntry");
|
||||
|
||||
function Probe() {
|
||||
const entry = useVerifyEntry();
|
||||
return <div data-testid="outcome">{`${entry.outcome ?? "none"}:${entry.email ?? "-"}`}</div>;
|
||||
}
|
||||
await act(async () => {
|
||||
render(<Probe />);
|
||||
});
|
||||
return () => screen.getByTestId("outcome").textContent;
|
||||
}
|
||||
|
||||
describe("confirming an emailed verification link", () => {
|
||||
it("posts the fragment token and reports the confirmed address", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-123", verifyEmail);
|
||||
|
||||
expect(verifyEmail).toHaveBeenCalledWith("tok-123");
|
||||
expect(outcome()).toBe("ok:someone@example.com");
|
||||
});
|
||||
|
||||
it("posts exactly once, so a re-render cannot spend the single-use token twice", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "once@example.com" });
|
||||
|
||||
await mountAt("/#verify=tok-once", verifyEmail);
|
||||
|
||||
// A second POST would 400 on the already-consumed token and flip a success to "invalid".
|
||||
expect(verifyEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports ok_reset when the account's password had been revoked", async () => {
|
||||
const verifyEmail = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, password_reset_required: true, email: "revoked@example.com" });
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-reset", verifyEmail);
|
||||
|
||||
expect(outcome()).toBe("ok_reset:revoked@example.com");
|
||||
});
|
||||
|
||||
it("reports invalid when the link is expired or already used", async () => {
|
||||
const verifyEmail = vi.fn().mockRejectedValue(new Error("400"));
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-dead", verifyEmail);
|
||||
|
||||
expect(outcome()).toBe("invalid:-");
|
||||
});
|
||||
|
||||
it("clears the one-time query flags it owns even with nothing to confirm", async () => {
|
||||
const verifyEmail = vi.fn();
|
||||
|
||||
const outcome = await mountAt("/?access=requested", verifyEmail);
|
||||
|
||||
expect(verifyEmail).not.toHaveBeenCalled();
|
||||
expect(outcome()).toBe("none:-");
|
||||
expect(window.location.search).toBe("");
|
||||
});
|
||||
|
||||
// Only the signed-out auth card consumes a `#reset=` token, so in a browser that already holds a
|
||||
// session nobody reads it — and erasing it turned the link into a dead end with no explanation:
|
||||
// the page loads, nothing happens, and clicking it again does the same, because the token is
|
||||
// untouched and the session is still there. Leaving it keeps the recovery: signing out reloads
|
||||
// with the fragment intact and the reset form is waiting.
|
||||
it("leaves an unspent reset link in the address bar", async () => {
|
||||
const verifyEmail = vi.fn();
|
||||
|
||||
await mountAt("/?login=out#reset=unspent", verifyEmail);
|
||||
|
||||
expect(window.location.hash).toBe("#reset=unspent");
|
||||
expect(window.location.search).toBe(""); // the flag it does own still goes
|
||||
});
|
||||
|
||||
// This hook runs on the FIRST commit of App, which is several commits before the setup wizard, the
|
||||
// ?link=/?oauth= effects and the lazily-loaded module views read their own parameters. It used to
|
||||
// blank the whole query and null history.state, which silently broke first-run setup (the wizard
|
||||
// found no ?token=) and every signed-in deep link. It must only clear what it owns.
|
||||
it("leaves parameters it does not own, and keeps history.state", async () => {
|
||||
window.history.replaceState(
|
||||
{ sfScroll: 42 },
|
||||
"",
|
||||
"/?token=setup-1&page=plex&v=show:9&login=out",
|
||||
);
|
||||
|
||||
await mountAt(window.location.href, vi.fn());
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
expect(params.get("token")).toBe("setup-1");
|
||||
expect(params.get("page")).toBe("plex");
|
||||
expect(params.get("v")).toBe("show:9");
|
||||
expect(params.get("login")).toBeNull(); // a one-time notice, ours to drop
|
||||
expect(window.history.state).toEqual({ sfScroll: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
// The link does not always arrive as a page LOAD. Found in UAT on 2026-09-03: navigating a tab that
|
||||
// already runs the app to a URL differing only in the fragment is a same-document navigation, so
|
||||
// nothing reloads, the import-time snapshot never sees the token, and it sat unconsumed in the
|
||||
// address bar — the same silence the sprint set out to remove, wearing a different hat.
|
||||
describe("a verification link that arrives as a fragment-only navigation", () => {
|
||||
it("consumes a token delivered by hashchange, and clears it from the address bar", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "pasted@example.com" });
|
||||
const outcome = await mountAt("/", verifyEmail);
|
||||
expect(verifyEmail).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, "", "/#verify=pasted-token");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(verifyEmail).toHaveBeenCalledWith("pasted-token");
|
||||
expect(outcome()).toBe("ok:pasted@example.com");
|
||||
expect(window.location.hash).toBe("");
|
||||
});
|
||||
|
||||
it("replaces a standing outcome rather than leaving the previous banner up", async () => {
|
||||
const verifyEmail = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, email: "first@example.com" })
|
||||
.mockRejectedValueOnce(new Error("400"));
|
||||
const outcome = await mountAt("/#verify=first-token", verifyEmail);
|
||||
expect(outcome()).toBe("ok:first@example.com");
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, "", "/#verify=spent-token");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(outcome()).toBe("invalid:-");
|
||||
});
|
||||
|
||||
it("posts a given token once even if the same fragment arrives again", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "same@example.com" });
|
||||
await mountAt("/#verify=same-token", verifyEmail);
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, "", "/#verify=same-token");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(verifyEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user