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>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""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
|
||||
@@ -262,3 +262,22 @@ def report_email(cfg: dict, data: dict, to: Recipient) -> tuple[str, str, str]:
|
||||
text, html = _render("report", d=data, alerts=alerts, period=period,
|
||||
sent=moment(data["generated_at"], tz), base=base)
|
||||
return f"[Steward] Weekly report, {period}", text, html
|
||||
|
||||
|
||||
ROLE_WORDS = {
|
||||
"viewer": "a viewer: you can read every page",
|
||||
"operator": "an operator: you can also run playbooks and change monitors, hosts and alerts",
|
||||
"admin": "an admin: you can also change Settings and users",
|
||||
}
|
||||
|
||||
|
||||
def invite_email(inviter: str, role: str, expires_at: datetime, url: str,
|
||||
tz: tzinfo) -> tuple[str, str, str]:
|
||||
"""(subject, text, html) for an invite (milestone 496): who sent it, the
|
||||
role it gives, when the link stops working (in the sender's zone, with
|
||||
UTC beside it, since the invitee's zone isn't known) and the link."""
|
||||
local, utc = moment(expires_at, tz), moment(expires_at, timezone.utc)
|
||||
facts = {"inviter": inviter, "role": ROLE_WORDS.get(role, role), "link": url,
|
||||
"expires": local, "expires_utc": utc if utc != local else ""}
|
||||
text, html = _render("invite", i=facts)
|
||||
return f"[Steward] {inviter} invited you to Steward", text, html
|
||||
|
||||
Reference in New Issue
Block a user