"""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