From 2b6e12859a57cb1005338a5dbdf5878316c094c2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 16:54:44 -0400 Subject: [PATCH] =?UTF-8?q?feat(users):=20invite=20by=20email=20=E2=80=94?= =?UTF-8?q?=20a=20one-time=20link=20where=20the=20invitee=20makes=20their?= =?UTF-8?q?=20own=20account=20(milestone=20496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/, 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 --- steward/audit/service.py | 4 + steward/auth/routes.py | 111 ++++++++++ steward/core/invites.py | 114 ++++++++++ steward/core/mail.py | 19 ++ .../migrations/versions/0037_user_invites.py | 40 ++++ steward/models/__init__.py | 3 +- steward/models/invites.py | 33 +++ steward/settings/users.py | 116 ++++++++++- steward/static/steward.css | 8 + steward/templates/auth/invite.html | 43 ++++ steward/templates/email/invite.html | 19 ++ steward/templates/email/invite.txt | 14 ++ steward/templates/settings/user_invite.html | 43 ++++ .../templates/settings/user_invite_link.html | 24 +++ steward/templates/settings/users.html | 27 ++- tests/core/test_invites.py | 64 ++++++ tests/integration/test_invites.py | 195 ++++++++++++++++++ tests/test_breadcrumbs.py | 1 + 18 files changed, 874 insertions(+), 4 deletions(-) create mode 100644 steward/core/invites.py create mode 100644 steward/migrations/versions/0037_user_invites.py create mode 100644 steward/models/invites.py create mode 100644 steward/templates/auth/invite.html create mode 100644 steward/templates/email/invite.html create mode 100644 steward/templates/email/invite.txt create mode 100644 steward/templates/settings/user_invite.html create mode 100644 steward/templates/settings/user_invite_link.html create mode 100644 tests/core/test_invites.py create mode 100644 tests/integration/test_invites.py diff --git a/steward/audit/service.py b/steward/audit/service.py index f6dfa5c..ceaf61a 100644 --- a/steward/audit/service.py +++ b/steward/audit/service.py @@ -128,6 +128,10 @@ ACTIONS = { "user.disabled": "disabled user", "user.enabled": "enabled user", "user.deleted": "deleted user", + "user.invited": "invited", + "user.invite_resent": "resent the invite to", + "user.invite_revoked": "withdrew the invite to", + "user.joined": "joined from an invite as", "host.created": "added host", "host.renamed": "renamed host", "host.deleted": "deleted host", diff --git a/steward/auth/routes.py b/steward/auth/routes.py index a3d9e5e..44ae5ed 100644 --- a/steward/auth/routes.py +++ b/steward/auth/routes.py @@ -1,4 +1,6 @@ from __future__ import annotations +from datetime import datetime, timezone + import bcrypt from quart import Blueprint, render_template, request, redirect, url_for, session from sqlalchemy import select, func @@ -291,3 +293,112 @@ async def setup_post(): await log_audit(current_app, user.id, user.username, "auth.setup", detail={"username": username}) return redirect(url_for("dashboard.index")) + + +# ── Invites (milestone 496) ────────────────────────────────────────────────── + +_INVITE_GONE = ("This invite link has expired, been used or been withdrawn. " + "Ask whoever sent it for a new one.") + + +async def _invite_page(key: str, invite=None, inviter: str = "", error: str | None = None, + form=None, status: int = 200): + """The invite page: the form while `invite` works, else one plain message + that doesn't say whether the key was unknown, used, revoked or expired.""" + if invite is None and error is None: + error = _INVITE_GONE + return await render_template( + "auth/invite.html", key=key, invite=invite, inviter=inviter, error=error, + form=form or {}, min_password=_min_password()), status + + +def _min_password() -> int: + from steward.settings.users import MIN_PASSWORD + return MIN_PASSWORD + + +async def _inviter_name(db, invite) -> str: + if invite.invited_by is None: + return "An admin" + who = await db.get(User, invite.invited_by) + return who.username if who else "An admin" + + +def _invite_throttled() -> str | None: + """Why this address may not try an invite key now (too many bad ones), or None.""" + from quart import current_app + wait_s = login_throttle.retry_after( + client_ip(), current_app.config.get("LOGIN_MAX_FAILURES", 5), + current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15) * 60) + if not wait_s: + return None + minutes = max(1, round(wait_s / 60)) + return f"Too many tries from this address. Try again in {minutes} minute{'s' if minutes != 1 else ''}." + + +@auth_bp.get("/invite/") +@public_route("an invite link: the key is the credential") +async def invite(key: str): + from quart import current_app + from steward.core.invites import find_pending + if wait := _invite_throttled(): + return await _invite_page(key, error=wait, status=429) + async with current_app.db_sessionmaker() as db: + found = await find_pending(db, key) + inviter = await _inviter_name(db, found) if found else "" + if found is None: + login_throttle.failed(client_ip()) + return await _invite_page(key, status=404) + return await _invite_page(key, found, inviter, form={"email": found.email}) + + +@auth_bp.post("/invite/") +@public_route("an invite link: the key is the credential") +async def invite_accept(key: str): + """Make the invitee's local account from their own username, email and + password, with the role the admin chose; use the key up and sign them in, + in one transaction, so a key makes one account.""" + from quart import current_app + from sqlalchemy.exc import IntegrityError + from steward.core.audit import log_audit + from steward.core.invites import find_pending, signup_problem, taken + from steward.settings.users import _hash + if wait := _invite_throttled(): + return await _invite_page(key, error=wait, status=429) + form = await request.form + username = form.get("username") or "" + email = (form.get("email") or "").strip() + password, confirm = form.get("password") or "", form.get("confirm") or "" + shown = {"username": username, "email": email} + try: + async with current_app.db_sessionmaker() as db: + async with db.begin(): + found = await find_pending(db, key, lock=True) + if found is None: + login_throttle.failed(client_ip()) + return await _invite_page(key, status=404) + inviter = await _inviter_name(db, found) + problem = signup_problem(username, email, password, confirm) or await taken(db, username, email) + if problem: + return await _invite_page(key, found, inviter, problem, shown, 400) + user = User(username=username, email=email, role=found.role, auth_source="local", + password_hash=_hash(password)) + db.add(user) + await db.flush() + found.used_at = datetime.now(timezone.utc) + found.used_by = user.id + invite_id, role = found.id, found.role.value + db.expunge(user) + except IntegrityError: + async with current_app.db_sessionmaker() as db: + found = await find_pending(db, key) + inviter = await _inviter_name(db, found) if found else "" + if found is None: + return await _invite_page(key, status=404) + return await _invite_page(key, found, inviter, + "That username or email was just taken. Pick another.", shown, 400) + login_user(user) + await log_audit(current_app, user.id, user.username, "user.joined", "user", user.id, + detail={"username": user.username, "role": role, "invite": invite_id, + "invited_by": inviter}) + return redirect(url_for("dashboard.index")) diff --git a/steward/core/invites.py b/steward/core/invites.py new file mode 100644 index 0000000..60cdde9 --- /dev/null +++ b/steward/core/invites.py @@ -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 diff --git a/steward/core/mail.py b/steward/core/mail.py index f8dcb51..6fb5bbf 100644 --- a/steward/core/mail.py +++ b/steward/core/mail.py @@ -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 diff --git a/steward/migrations/versions/0037_user_invites.py b/steward/migrations/versions/0037_user_invites.py new file mode 100644 index 0000000..abb30b7 --- /dev/null +++ b/steward/migrations/versions/0037_user_invites.py @@ -0,0 +1,40 @@ +"""Emailed invitations to make a local account (milestone 496): a one-time +key, stored as its sha256, with the role and expiry the admin chose. + +Revision ID: 0037_user_invites +Revises: 0036_user_email_choices +Create Date: 2026-10-08 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "0037_user_invites" +down_revision: Union[str, None] = "0036_user_email_choices" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "user_invites", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("key_hash", sa.String(64), nullable=False, unique=True), + sa.Column("email", sa.String(255), nullable=False), + # The users table's enum type, already created. + sa.Column("role", postgresql.ENUM("admin", "operator", "viewer", name="userrole", create_type=False), + nullable=False), + sa.Column("days", sa.Integer, nullable=False), + sa.Column("invited_by", sa.String(36), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("sent_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("used_by", sa.String(36), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table("user_invites") diff --git a/steward/models/__init__.py b/steward/models/__init__.py index 8d390fd..65591b6 100644 --- a/steward/models/__init__.py +++ b/steward/models/__init__.py @@ -1,6 +1,7 @@ from .base import Base from .users import User from .api_tokens import ApiToken +from .invites import UserInvite from .hosts import Host from .monitors import Monitor, MonitorResult, MonitorType from .metrics import PluginMetric @@ -12,7 +13,7 @@ from .settings import AppSetting from .dashboard import Dashboard, DashboardWidget, DashboardShareToken __all__ = [ - "Base", "User", "ApiToken", + "Base", "User", "ApiToken", "UserInvite", "Host", "Monitor", "MonitorResult", "MonitorType", "PluginMetric", diff --git a/steward/models/invites.py b/steward/models/invites.py new file mode 100644 index 0000000..e812861 --- /dev/null +++ b/steward/models/invites.py @@ -0,0 +1,33 @@ +from __future__ import annotations +from datetime import datetime, timezone +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column +from .base import Base +from .users import UserRole + + +class UserInvite(Base): + """An emailed invitation to make a local account (milestone 496). The link + carries a one-time key; only its sha256 is stored, as for API tokens. The + admin chose the role and how many days the link works; the invitee + chooses their own username, email and password.""" + __tablename__ = "user_invites" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + # Where the invite was sent; the invitee may sign up with another address. + email: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[UserRole] = mapped_column(Enum(UserRole), nullable=False, default=UserRole.viewer) + days: Mapped[int] = mapped_column(Integer, nullable=False, default=7) + invited_by: Mapped[str | None] = mapped_column( + String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) + # When the current key was sent; a resend replaces the key and restarts the clock. + sent_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + used_by: Mapped[str | None] = mapped_column( + String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/steward/settings/users.py b/steward/settings/users.py index e91af09..e16f122 100644 --- a/steward/settings/users.py +++ b/steward/settings/users.py @@ -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//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//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.", diff --git a/steward/static/steward.css b/steward/static/steward.css index 7f6143f..4040fa4 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -451,6 +451,14 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat .form-section > .section-title { margin-bottom: 0.9rem; } .form-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 0 1rem; } .form-pair[hidden] { display: none; } +/* The invite page (milestone 496): the login page's centred card, with labelled fields. */ +.invite-page { max-width: 420px; margin: 4rem auto; } +.invite-title { font-family: var(--font-serif); font-size: 1.5rem; font-weight: 500; margin: 0 0 0.5rem; } +.invite-lede { color: var(--text-muted); margin: 0 0 1.25rem; } +.invite-submit { width: 100%; margin-top: 0.25rem; } +/* A link shown once to copy (an invite without mail): the value and its Copy button. */ +.copy-once { display: flex; gap: 0.5rem; align-items: center; } +.copy-once .input { flex: 1; font-family: ui-monospace, monospace; font-size: 0.8rem; } /* A column of checkboxes: a target's groups, a group's members. */ .check-list { display: grid; gap: 0.4rem; } .ansible-fields { padding: 0.9rem 1rem 0.25rem; margin-bottom: 0.75rem; border: 1px solid var(--border); border-radius: var(--fs-radius-md); background: var(--bg-elevated); } diff --git a/steward/templates/auth/invite.html b/steward/templates/auth/invite.html new file mode 100644 index 0000000..1316a04 --- /dev/null +++ b/steward/templates/auth/invite.html @@ -0,0 +1,43 @@ +{# auth/invite.html — where an invitee makes their own account (milestone 496). + `invite` is the working invite, or none: then only `error` shows. #} +{% extends "base.html" %} +{% block title %}Join Steward{% endblock %} +{% block content %} +
+
+

Join Steward

+ {% if invite %} +

{{ inviter }} invited you as {{ "an" if invite.role.value in ("admin", "operator") else "a" }} {{ invite.role.value }}. Choose your username, email and password.

+ {% if session.user_id %}

You're signed in as {{ session.username }}. Making this account signs you in as it instead.

{% endif %} + {% endif %} + {% if error %}{% endif %} + {% if invite %} +
{{ csrf_field() }} +
+ + +
+
+ + + Steward sends alerts and the weekly report here; you can turn them off later. +
+
+ + + At least {{ min_password }} characters. +
+
+ + +
+ +
+ {% else %} + Go to sign in + {% endif %} +
+
+{% endblock %} diff --git a/steward/templates/email/invite.html b/steward/templates/email/invite.html new file mode 100644 index 0000000..ce2b697 --- /dev/null +++ b/steward/templates/email/invite.html @@ -0,0 +1,19 @@ +{# An invite email's HTML part (milestone 496): i = the facts mail.invite_email builds. #} +{% extends "_layout.html" %} +{% from "_parts.html" import button, fact with context %} +{% block title %}{{ i.inviter }} invited you to Steward{% endblock %} +{% block body %} +{{ i.inviter }} invited you to Steward. +Open the link to make your account: you choose your username, email and password. + + +{{ fact("You'll be", i.role) }} +{% call fact("Link works until") %}{{ i.expires }}{% if i.expires_utc %}
{{ i.expires_utc }}{% endif %}{% endcall %} +
+ +{{ button(i.link, "Make your account") }} +Or paste this into your browser: {{ i.link }} +{% endblock %} +{% block footer %} +The link works once. If you weren't expecting this, ignore it and nothing happens. +{% endblock %} diff --git a/steward/templates/email/invite.txt b/steward/templates/email/invite.txt new file mode 100644 index 0000000..7e2b78c --- /dev/null +++ b/steward/templates/email/invite.txt @@ -0,0 +1,14 @@ +{# An invite email's plain-text part; the same facts as invite.html. #} +{{ i.inviter }} invited you to Steward. + +Open the link to make your account: you choose your username, email and +password. + +You'll be {{ i.role }}. +Link works until: {{ i.expires }}{% if i.expires_utc %} ({{ i.expires_utc }}){% endif %} + +Make your account: {{ i.link }} + +-- +The link works once. If you weren't expecting this, ignore it and nothing +happens. diff --git a/steward/templates/settings/user_invite.html b/steward/templates/settings/user_invite.html new file mode 100644 index 0000000..93a2ab1 --- /dev/null +++ b/steward/templates/settings/user_invite.html @@ -0,0 +1,43 @@ +{# settings/user_invite.html — invite someone by email (milestone 496): they + make their own account from the link, with the role chosen here. #} +{% extends "base.html" %} +{% from "_macros.html" import crumbs, page_header %} +{% from "settings/_tabs.html" import settings_tabs %} +{% block title %}Invite by email — Settings — Steward{% endblock %} +{% block section_tabs %}{{ settings_tabs("users") }}{% endblock %} +{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "/settings/users/"), ("Invite by email", "")]) }}{% endblock %} +{% block content %} +{{ page_header("Invite by email", "They choose their own username, email and password from the link.") }} +{% if error %}{% endif %} + +
{{ csrf_field() }} +
+

Invite

+
+ + + {% if not mail_ready %}No mail server is set, so you'll get the link to pass on yourself. Set one in Settings › Notifications.{% endif %} +
+
+
+ + +
+
+ + +
+
+

A viewer reads; an operator also runs playbooks and changes monitors, hosts and alerts; an admin also changes Settings and users. They can't change the role you pick. The link works once.

+
+
+ + Cancel +
+
+{% endblock %} diff --git a/steward/templates/settings/user_invite_link.html b/steward/templates/settings/user_invite_link.html new file mode 100644 index 0000000..1ab00ca --- /dev/null +++ b/steward/templates/settings/user_invite_link.html @@ -0,0 +1,24 @@ +{# settings/user_invite_link.html — an invite's link, shown once (milestone + 496), when mail didn't carry it: no mail server, or sending failed. #} +{% extends "base.html" %} +{% from "_macros.html" import crumbs, page_header %} +{% from "settings/_tabs.html" import settings_tabs %} +{% block title %}Invite link — Settings — Steward{% endblock %} +{% block section_tabs %}{{ settings_tabs("users") }}{% endblock %} +{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "/settings/users/"), ("Invite link", "")]) }}{% endblock %} +{% block content %} +{{ page_header("Invite link for " ~ invite.email) }} +
+

{{ why }}

+
+ +
+ + +
+

Send it to {{ invite.email }} yourself. It makes one {{ invite.role.value }} account and works until {{ invite.expires_at|when }}. If you lose it, resend the invite from Users for a new link.

+
+ +
+{% endblock %} diff --git a/steward/templates/settings/users.html b/steward/templates/settings/users.html index 7127d6a..36b4ab7 100644 --- a/steward/templates/settings/users.html +++ b/steward/templates/settings/users.html @@ -7,7 +7,9 @@ {% block section_tabs %}{{ settings_tabs("users") }}{% endblock %} {% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "")]) }}{% endblock %} {% block content %} -{% call page_header("Users") %}Add user{% endcall %} +{% call page_header("Users") %}Invite by emailAdd user{% endcall %} + +{% if done %}
{{ done }}
{% endif %}

{{ users|length }} account{{ 's' if users|length != 1 }} @@ -37,5 +39,28 @@ +{% if invites %} +

+ + + + + {% for i, state, by in invites %} + + + + + + + + {% endfor %} + +
Invites not yet used
Sent toRoleSent byLink works untilActions
{{ i.email }}{{ i.role.value|capitalize }}{{ by }}{% if state == "expired" %}{{ status_badge("inactive", "Expired") }}{% else %}{{ i.expires_at|when }}{% endif %} +
{{ csrf_field() }}
+
{{ csrf_field() }}
+
+
+{% endif %}

Accounts that sign in with LDAP or single sign-on are added at their first sign-in, with the role their provider's groups give them (Settings › Authentication).

{% endblock %} diff --git a/tests/core/test_invites.py b/tests/core/test_invites.py new file mode 100644 index 0000000..3563b9a --- /dev/null +++ b/tests/core/test_invites.py @@ -0,0 +1,64 @@ +"""Invite a user by email (milestone 496): an invite's states, what the +invitee's form must hold, and the email that carries the link.""" +import re +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +from steward.core import invites, mail +from steward.core.api_tokens import hash_token +from steward.models.invites import UserInvite +from steward.models.users import UserRole + +NOW = datetime(2026, 10, 8, 20, 0, tzinfo=timezone.utc) + + +def _invite(**kw): + base = dict(email="new@example.com", role=UserRole.viewer, days=7, key_hash="x", + created_at=NOW, sent_at=NOW, expires_at=NOW + timedelta(days=7)) + return UserInvite(**(base | kw)) + + +def test_an_invite_is_pending_until_used_revoked_or_past_its_day(): + assert invites.state(_invite(), NOW) == "pending" + assert invites.state(_invite(), NOW + timedelta(days=7)) == "expired" + assert invites.state(_invite(used_at=NOW), NOW) == "used" + assert invites.state(_invite(revoked_at=NOW), NOW) == "revoked" + # Used wins over expired: the account was made in time. + assert invites.state(_invite(used_at=NOW), NOW + timedelta(days=30)) == "used" + + +def test_a_resend_replaces_the_key_and_restarts_the_clock(): + invite = _invite(days=1, sent_at=NOW - timedelta(days=3), expires_at=NOW - timedelta(days=2)) + key = invites.renew(invite) + assert invite.key_hash == hash_token(key) and len(key) >= 40 + assert invites.state(invite) == "pending" + assert invite.expires_at - invite.sent_at == timedelta(days=1) + assert invites.renew(invite) != key + + +def test_the_link_carries_the_key_under_steward_s_address(): + assert invites.link("https://steward.example/", "abc") == "https://steward.example/invite/abc" + + +def test_the_invitee_s_form_needs_a_name_a_real_address_and_a_matching_password(): + ok = ("ann", "ann@example.com", "longenough", "longenough") + assert invites.signup_problem(*ok) is None + for bad in (("", *ok[1:]), (" ann", *ok[1:]), ("a" * 65, *ok[1:]), + ("ann", "ann@oidc", *ok[2:]), ("ann", "ann@example.com", "short", "short"), + ("ann", "ann@example.com", "longenough", "different1")): + assert invites.signup_problem(*bad), bad + + +def test_the_invite_email_says_who_what_until_when_and_carries_the_link(): + url = "https://steward.example/invite/KEY123" + subject, text, html = mail.invite_email("bvan ", "operator", NOW + timedelta(days=7), url, + ZoneInfo("America/New_York")) + assert subject == "[Steward] bvan invited you to Steward" + for part in (text, html): + assert url in part + assert "an operator: you can also run playbooks" in part + assert "Thu 15 Oct 2026 16:00 EDT" in part and "Thu 15 Oct 2026 20:00 UTC" in part + assert "bvan <b>" in html and "" not in html + # Nothing in the HTML reaches anywhere but Steward, or loads at all. + assert all(u.startswith("https://steward.example") for u in re.findall(r"""https?://[^\s"'<>)]+""", html)) + assert not re.search(r"""\ssrc\s*=|url\(|