Files
FabledSteward/steward/core/invites.py
T
bvandeusenandClaude Opus 5.5 2b6e12859a
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m17s
CI / unit (push) Successful in 1m43s
CI / publish (push) Skipped
feat(users): invite by email — a one-time link where the invitee makes their own account (milestone 496)
The operator's ask: an invite link sent by mail, the key in the URL, and
the new user choosing their own username, password and email.

- user_invites (migration 0037): the key's sha256 only, the address, the
  role and days the admin chose, who sent it, used/revoked.
- core/invites: create (revokes an older open invite to the address),
  renew (new key, new clock), find_pending, the four states, signup checks.
- /invite/<key>, outside sign-in and throttled like login: the invitee's
  username, email (prefilled, theirs to change) and password; the account
  takes the admin's role, the key is used up in the same transaction and
  they are signed in. A bad key gets one plain message.
- Settings › Users: Invite by email (address, role, 1/7/30 days), sent from
  the admin in their zone; without a mail server, or if sending fails, the
  link is shown once to copy. Invites not yet used are listed with Resend
  and Withdraw.
- Audit: user.invited, user.invite_resent, user.invite_revoked, user.joined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:54:44 -04:00

115 lines
4.3 KiB
Python

"""Invite a user by email (milestone 496).
An admin invites an address with a role and a number of days. The invitee
gets a link carrying a one-time key and makes their own local account from
it: username, email and password. Only the key's sha256 is kept, so a
database read can't produce a working link. A key works once, until it
expires or is revoked; resending replaces it, so an older link stops
working.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from steward.core.api_tokens import hash_token
from steward.models.invites import UserInvite
from steward.models.users import User, UserRole
DAYS = (1, 7, 30)
DEFAULT_DAYS = 7
MAX_USERNAME = 64
def new_key() -> str:
return secrets.token_urlsafe(32)
def state(invite: UserInvite, now: datetime | None = None) -> str:
""""used", "revoked", "expired" or "pending"."""
now = now or datetime.now(timezone.utc)
if invite.used_at is not None:
return "used"
if invite.revoked_at is not None:
return "revoked"
if invite.expires_at <= now:
return "expired"
return "pending"
def link(base: str, key: str) -> str:
return f"{base.rstrip('/')}/invite/{key}"
def _issue(invite: UserInvite, now: datetime) -> str:
"""Give `invite` a fresh key and a fresh clock; returns the raw key."""
key = new_key()
invite.key_hash = hash_token(key)
invite.sent_at = now
invite.expires_at = now + timedelta(days=invite.days)
return key
async def create(db, email: str, role: UserRole, days: int, invited_by: str | None) -> tuple[UserInvite, str]:
"""A new invite, flushed so it has an id, and its raw key (shown or sent
once, never stored). An open invite to the same address is revoked, so
an address holds one working link at a time. Call in a transaction."""
now = datetime.now(timezone.utc)
for old in (await db.execute(select(UserInvite).where(
UserInvite.email == email, UserInvite.used_at.is_(None),
UserInvite.revoked_at.is_(None)))).scalars():
old.revoked_at = now
invite = UserInvite(email=email, role=role, days=days, invited_by=invited_by, created_at=now)
key = _issue(invite, now)
db.add(invite)
await db.flush()
return invite, key
def renew(invite: UserInvite) -> str:
"""Resend: a new key and a new expiry, so the old link stops working."""
return _issue(invite, datetime.now(timezone.utc))
async def find_pending(db, key: str, lock: bool = False) -> UserInvite | None:
"""The invite `key` opens, while it still works; None for any other key."""
if not key or len(key) > 128:
return None
query = select(UserInvite).where(UserInvite.key_hash == hash_token(key))
if lock:
query = query.with_for_update()
invite = (await db.execute(query)).scalar_one_or_none()
return invite if invite is not None and state(invite) == "pending" else None
async def open_invites(db) -> list[UserInvite]:
"""Invites not yet used or revoked, newest first; expired ones included,
so they can be resent."""
return list((await db.execute(select(UserInvite).where(
UserInvite.used_at.is_(None), UserInvite.revoked_at.is_(None))
.order_by(UserInvite.sent_at.desc()))).scalars())
def signup_problem(username: str, email: str, password: str, confirm: str) -> str | None:
"""Why the invitee's form can't make an account, or None."""
from steward.core.mail import is_real_address
from steward.settings.users import password_problem
if not username or len(username) > MAX_USERNAME or username != username.strip():
return f"Pick a username of up to {MAX_USERNAME} characters, without spaces at either end."
if not is_real_address(email) or len(email) > 255:
return "Enter an email address mail can reach."
return password_problem(password, confirm)
async def taken(db, username: str, email: str) -> str | None:
"""Why the username or email is already in use, or None."""
rows = (await db.execute(select(User.username, User.email).where(
(User.username == username) | (User.email == email)))).all()
if any(u == username for u, _ in rows):
return f"The username {username} is taken. Pick another."
if rows:
return "Another account already uses that email address."
return None