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
+19
View File
@@ -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