feat(users): invite by email — a one-time link where the invitee makes their own account (milestone 496)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m17s
CI / unit (push) Successful in 1m43s
CI / publish (push) Skipped

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:
2026-10-08 16:54:44 -04:00
co-authored by Claude Opus 5.5
parent ac38a04642
commit 2b6e12859a
18 changed files with 874 additions and 4 deletions
+114 -2
View File
@@ -16,13 +16,14 @@ from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from steward.auth.middleware import require_role
from steward.core import mail
from steward.core import invites, mail
from steward.core.audit import audit_request
from steward.core.settings import get_all_settings
from steward.models.alerts import AlertRule, MaintenanceWindow
from steward.models.ansible import AnsibleRun
from steward.models.api_tokens import ApiToken
from steward.models.audit import AuditEvent
from steward.models.invites import UserInvite
from steward.models.users import User, UserRole
users_bp = Blueprint("users", __name__, url_prefix="/settings/users")
@@ -105,14 +106,22 @@ async def users_list():
last = await _last_sign_ins(db)
tokens = await _token_counts(db)
settings = await get_all_settings(db)
pending = await invites.open_invites(db)
users = sorted(users, key=lambda u: (not u.is_active, u.username.lower()))
names = {u.id: u.username for u in users}
gets = {u.id: [label for kind, label in (("alerts", "alerts"), ("report", "report"))
if u.is_active and mail.wants(u, kind, settings)] for u in users}
return await render_template(
"settings/users.html", users=users, last=last, tokens=tokens, sources=SOURCES,
gets=gets, reachable={u.id for u in users if mail.is_real_address(u.email)},
admins=sum(1 for u in users if u.role == UserRole.admin and u.is_active),
disabled=sum(1 for u in users if not u.is_active))
disabled=sum(1 for u in users if not u.is_active),
invites=[(i, invites.state(i), names.get(i.invited_by, "—")) for i in pending],
done=_LIST_DONE.get(request.args.get("done", "")))
_LIST_DONE = {"invited": "Invite sent.", "resent": "Invite sent again; the earlier link no longer works.",
"revoked": "Invite withdrawn; its link no longer works."}
async def _new_page(error: str | None = None, form=None, status: int = 200):
@@ -155,6 +164,109 @@ async def user_create():
return redirect(f"/settings/users/{user.id}?done=created")
# ── Invites (milestone 496) ──────────────────────────────────────────────────
async def _invite_form(error: str | None = None, form=None, status: int = 200):
return await render_template("settings/user_invite.html", error=error, form=form or {},
roles=list(UserRole), days=invites.DAYS,
default_days=invites.DEFAULT_DAYS,
mail_ready=bool(current_app.config.get("SMTP", {}).get("host"))), status
async def _deliver(invite: UserInvite, key: str) -> str | None:
"""Email the invite's link to its address, from the signed-in admin. None
when it was sent; otherwise why not, and the caller shows the link."""
from steward.core.formatting import zone
from steward.core.settings import public_base_url
cfg = current_app.config.get("SMTP", {})
if not cfg.get("host"):
return "No mail server is set (Settings › Notifications), so nothing was sent."
url = invites.link(public_base_url(request), key)
subject, text, html = mail.invite_email(session.get("username", "An admin"), invite.role.value,
invite.expires_at, url, zone(session.get("tz")))
try:
await mail.send(cfg, [mail.message(cfg, invite.email, subject, text, html)])
except Exception as exc:
current_app.logger.warning("Invite email to %s failed: %s", invite.email, exc)
return f"Sending to {invite.email} failed ({exc})."
return None
async def _link_page(invite: UserInvite, key: str, why: str):
"""The link shown once, to pass on by hand, when mail didn't take it."""
from steward.core.settings import public_base_url
return await render_template("settings/user_invite_link.html", invite=invite, why=why,
url=invites.link(public_base_url(request), key))
@users_bp.get("/invite")
@require_role(UserRole.admin)
async def user_invite():
return await _invite_form()
@users_bp.post("/invite")
@require_role(UserRole.admin)
async def user_invite_send():
form = await request.form
email = (form.get("email") or "").strip()
try:
role = UserRole(form.get("role") or "viewer")
days = int(form.get("days") or invites.DEFAULT_DAYS)
except ValueError:
return await _invite_form("Pick a role and how long the link works.", form, 400)
if days not in invites.DAYS:
return await _invite_form("Pick how long the link works.", form, 400)
if not mail.is_real_address(email) or len(email) > 255:
return await _invite_form("Enter an email address mail can reach.", form, 400)
async with current_app.db_sessionmaker() as db:
async with db.begin():
holder = (await db.execute(select(User.username).where(User.email == email))).scalar()
if holder is None:
invite, key = await invites.create(db, email, role, days, session.get("user_id"))
if holder is not None:
return await _invite_form(f"{holder} already has an account with that address.", form, 400)
await audit_request("user.invited", "invite", str(invite.id),
{"name": email, "role": role.value, "days": days})
why = await _deliver(invite, key)
if why:
return await _link_page(invite, key, why)
return redirect("/settings/users/?done=invited")
async def _open_invite(db, invite_id: int) -> UserInvite:
invite = await db.get(UserInvite, invite_id)
if invite is None or invites.state(invite) in ("used", "revoked"):
abort(404)
return invite
@users_bp.post("/invites/<int:invite_id>/resend")
@require_role(UserRole.admin)
async def user_invite_resend(invite_id: int):
"""A new link with a new expiry; the old link stops working."""
async with current_app.db_sessionmaker() as db:
async with db.begin():
invite = await _open_invite(db, invite_id)
key = invites.renew(invite)
await audit_request("user.invite_resent", "invite", str(invite.id), {"name": invite.email})
why = await _deliver(invite, key)
if why:
return await _link_page(invite, key, why)
return redirect("/settings/users/?done=resent")
@users_bp.post("/invites/<int:invite_id>/revoke")
@require_role(UserRole.admin)
async def user_invite_revoke(invite_id: int):
async with current_app.db_sessionmaker() as db:
async with db.begin():
invite = await _open_invite(db, invite_id)
invite.revoked_at = datetime.now(timezone.utc)
await audit_request("user.invite_revoked", "invite", str(invite.id), {"name": invite.email})
return redirect("/settings/users/?done=revoked")
_DONE = {"created": "Account added.", "saved": "Saved.", "role": "Role changed.",
"password": "Password set. It works from their next sign-in.",
"disabled": "Account disabled. They are signed out at their next click.",