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:
+114
-2
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user