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>
115 lines
4.3 KiB
Python
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
|