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
+4
View File
@@ -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",
+111
View File
@@ -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/<key>")
@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/<key>")
@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"))
+114
View File
@@ -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
+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
@@ -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")
+2 -1
View File
@@ -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",
+33
View File
@@ -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)
+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.",
+8
View File
@@ -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); }
+43
View File
@@ -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 %}
<div class="invite-page">
<div class="card">
<h1 class="invite-title">Join Steward</h1>
{% if invite %}
<p class="invite-lede">{{ inviter }} invited you as {{ "an" if invite.role.value in ("admin", "operator") else "a" }} {{ invite.role.value }}. Choose your username, email and password.</p>
{% if session.user_id %}<p class="field-hint">You're signed in as {{ session.username }}. Making this account signs you in as it instead.</p>{% endif %}
{% endif %}
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
{% if invite %}
<form method="post" action="/invite/{{ key }}">{{ csrf_field() }}
<div class="field">
<label class="field-label" for="inv-username">Username</label>
<input class="input" id="inv-username" type="text" name="username" maxlength="64" required autofocus
autocomplete="username" value="{{ form.get('username', '') }}">
</div>
<div class="field">
<label class="field-label" for="inv-email">Email</label>
<input class="input" id="inv-email" type="email" name="email" maxlength="255" required
autocomplete="email" value="{{ form.get('email', '') }}">
<span class="field-hint">Steward sends alerts and the weekly report here; you can turn them off later.</span>
</div>
<div class="field">
<label class="field-label" for="inv-pw">Password</label>
<input class="input" id="inv-pw" type="password" name="password" minlength="{{ min_password }}" required autocomplete="new-password">
<span class="field-hint">At least {{ min_password }} characters.</span>
</div>
<div class="field">
<label class="field-label" for="inv-pw2">Password again</label>
<input class="input" id="inv-pw2" type="password" name="confirm" required autocomplete="new-password">
</div>
<button type="submit" class="btn invite-submit">Make my account</button>
</form>
{% else %}
<a href="/login" class="btn btn-ghost">Go to sign in</a>
{% endif %}
</div>
</div>
{% endblock %}
+19
View File
@@ -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 %}
<tr><td style="padding:10px 24px 4px 24px;font:600 20px/1.35 {{ c.font }};color:{{ c.text }};">{{ i.inviter }} invited you to Steward.</td></tr>
<tr><td style="padding:6px 24px 4px 24px;font:14px/1.55 {{ c.font }};color:{{ c.text }};">Open the link to make your account: you choose your username, email and password.</td></tr>
<tr><td style="padding:10px 24px 4px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{{ fact("You'll be", i.role) }}
{% call fact("Link works until") %}{{ i.expires }}{% if i.expires_utc %}<br><span style="color:{{ c.muted }};">{{ i.expires_utc }}</span>{% endif %}{% endcall %}
</table>
</td></tr>
<tr><td style="padding:16px 24px 8px 24px;">{{ button(i.link, "Make your account") }}</td></tr>
<tr><td style="padding:4px 24px 20px 24px;font:12px/1.5 {{ c.font }};color:{{ c.muted }};word-break:break-all;">Or paste this into your browser: {{ i.link }}</td></tr>
{% endblock %}
{% block footer %}
The link works once. If you weren't expecting this, ignore it and nothing happens.
{% endblock %}
+14
View File
@@ -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.
@@ -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 %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<form method="post" action="/settings/users/invite" class="form-page">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="inv-who">
<h2 id="inv-who" class="section-title">Invite</h2>
<div class="field">
<label class="field-label" for="inv-email">Send to</label>
<input class="input" id="inv-email" type="email" name="email" maxlength="255" required autofocus
value="{{ form.get('email', '') }}" autocomplete="off">
{% if not mail_ready %}<span class="field-hint tone-warn">No mail server is set, so you'll get the link to pass on yourself. Set one in Settings › <a href="/settings/notifications/">Notifications</a>.</span>{% endif %}
</div>
<div class="form-pair">
<div class="field">
<label class="field-label" for="inv-role">Role</label>
<select class="input" id="inv-role" name="role">
{% for r in roles %}<option value="{{ r.value }}" {% if form.get('role', 'viewer') == r.value %}selected{% endif %}>{{ r.value|capitalize }}</option>{% endfor %}
</select>
</div>
<div class="field">
<label class="field-label" for="inv-days">Link works for</label>
<select class="input" id="inv-days" name="days">
{% for d in days %}<option value="{{ d }}" {% if form.get('days', default_days|string) == d|string %}selected{% endif %}>{{ d }} day{{ 's' if d != 1 }}</option>{% endfor %}
</select>
</div>
</div>
<p class="field-hint">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.</p>
</section>
<div class="form-actions">
<button type="submit" class="btn">{{ "Send invite" if mail_ready else "Make invite link" }}</button>
<a href="/settings/users/" class="btn btn-ghost">Cancel</a>
</div>
</form>
{% endblock %}
@@ -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) }}
<div class="form-page">
<p class="page-verdict" role="status">{{ why }}</p>
<section class="card form-section" aria-labelledby="link-title">
<h2 id="link-title" class="section-title">Copy this link now. It can't be shown again.</h2>
<div class="copy-once">
<input class="input" type="text" id="invite-url" value="{{ url }}" readonly aria-label="Invite link">
<button type="button" class="btn btn-ghost btn-sm"
onclick="navigator.clipboard.writeText(document.getElementById('invite-url').value).then(()=>this.textContent='Copied')">Copy</button>
</div>
<p class="field-hint">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.</p>
</section>
<div class="form-actions"><a href="/settings/users/" class="btn">Back to Users</a></div>
</div>
{% endblock %}
+26 -1
View File
@@ -7,7 +7,9 @@
{% block section_tabs %}{{ settings_tabs("users") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "")]) }}{% endblock %}
{% block content %}
{% call page_header("Users") %}<a href="/settings/users/new" class="btn">Add user</a>{% endcall %}
{% call page_header("Users") %}<a href="/settings/users/invite" class="btn">Invite by email</a><a href="/settings/users/new" class="btn btn-ghost">Add user</a>{% endcall %}
{% if done %}<div class="alert alert-success" role="status">{{ done }}</div>{% endif %}
<p class="page-verdict">
{{ users|length }} account{{ 's' if users|length != 1 }}
@@ -37,5 +39,28 @@
</tbody>
</table>
</div>
{% if invites %}
<div class="card-flush">
<table class="table">
<caption class="table-caption">Invites not yet used</caption>
<thead><tr><th>Sent to</th><th>Role</th><th>Sent by</th><th>Link works until</th><th class="num">Actions</th></tr></thead>
<tbody>
{% for i, state, by in invites %}
<tr>
<td>{{ i.email }}</td>
<td>{{ i.role.value|capitalize }}</td>
<td>{{ by }}</td>
<td class="nowrap">{% if state == "expired" %}{{ status_badge("inactive", "Expired") }}{% else %}{{ i.expires_at|when }}{% endif %}</td>
<td class="num nowrap">
<form method="post" action="/settings/users/invites/{{ i.id }}/resend" class="inline-form">{{ csrf_field() }}<button type="submit" class="btn btn-ghost btn-sm">Resend</button></form>
<form method="post" action="/settings/users/invites/{{ i.id }}/revoke" class="inline-form"
data-confirm="Withdraw the invite to {{ i.email }}? Its link stops working.">{{ csrf_field() }}<button type="submit" class="btn btn-ghost btn-sm">Withdraw</button></form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<p class="field-hint">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 › <a href="/settings/auth/">Authentication</a>).</p>
{% endblock %}