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:
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 <b>", "operator", NOW + timedelta(days=7), url,
|
||||
ZoneInfo("America/New_York"))
|
||||
assert subject == "[Steward] bvan <b> 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 "<b>" 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\(|<link\b|@import""", html, re.I)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Integration: invite a user by email (milestone 496), from the Users page
|
||||
to the invitee's own account. The routes run for real against Postgres;
|
||||
only mail delivery is replaced."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
CSRF = {"X-CSRF-Token": "test-token"}
|
||||
_KEY = re.compile(r"/invite/([A-Za-z0-9_-]{20,})")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
if not os.environ.get("STEWARD_DATABASE_URL"):
|
||||
pytest.skip("needs Postgres")
|
||||
from steward.app import create_app
|
||||
from steward.auth.throttle import login_throttle
|
||||
login_throttle._failures.clear() # bad keys below count against this address
|
||||
app = create_app(testing=False)
|
||||
yield app
|
||||
login_throttle._failures.clear()
|
||||
|
||||
|
||||
async def _admin(app, role="admin"):
|
||||
from steward.models.users import User, UserRole
|
||||
uid = str(uuid.uuid4())
|
||||
name = f"inv-{uid[:8]}"
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add(User(id=uid, username=name, email=f"{uid}@test", password_hash="x",
|
||||
role=UserRole(role), is_active=True, time_zone="America/New_York"))
|
||||
client = app.test_client()
|
||||
async with client.session_transaction() as sess:
|
||||
sess.update(user_id=uid, user_role=role, username=name, _csrf="test-token", login_at=time.time())
|
||||
return client, uid, name
|
||||
|
||||
|
||||
async def _stranger(app):
|
||||
"""A browser with no session, holding only the link."""
|
||||
client = app.test_client()
|
||||
await client.get("/login") # a session with a CSRF token, as a real visit has
|
||||
async with client.session_transaction() as sess:
|
||||
sess["_csrf"] = "test-token"
|
||||
return client
|
||||
|
||||
|
||||
async def _audits(app, uid):
|
||||
from sqlalchemy import select
|
||||
from steward.models.audit import AuditEvent
|
||||
async with app.db_sessionmaker() as s:
|
||||
rows = (await s.execute(select(AuditEvent).where(AuditEvent.user_id == uid)
|
||||
.order_by(AuditEvent.timestamp))).scalars().all()
|
||||
return [(r.action, json.loads(r.detail_json or "{}")) for r in rows]
|
||||
|
||||
|
||||
def _mail(app, monkeypatch):
|
||||
from steward.core import mail
|
||||
sent = []
|
||||
|
||||
async def fake_send(cfg, messages):
|
||||
sent.extend(messages)
|
||||
|
||||
app.config["SMTP"] = {"host": "mx.example", "port": 587, "from": "steward@example.com"}
|
||||
monkeypatch.setattr(mail, "send", fake_send)
|
||||
return sent
|
||||
|
||||
|
||||
def test_an_invite_is_emailed_and_the_invitee_makes_their_own_account_once(app, monkeypatch):
|
||||
sent = _mail(app, monkeypatch)
|
||||
|
||||
async def _go():
|
||||
from steward.models.users import User
|
||||
admin, aid, aname = await _admin(app)
|
||||
page = await (await admin.get("/settings/users/")).get_data(as_text=True)
|
||||
assert 'href="/settings/users/invite"' in page # findable from the list
|
||||
|
||||
to = f"new-{aid[:8]}@example.com"
|
||||
resp = await admin.post("/settings/users/invite", headers=CSRF,
|
||||
form={"email": to, "role": "operator", "days": "1"})
|
||||
assert resp.status_code == 302 and resp.headers["Location"].endswith("?done=invited")
|
||||
assert len(sent) == 1 and sent[0]["To"] == to
|
||||
text = sent[0].get_body(("plain",)).get_content()
|
||||
assert f"{aname} invited you to Steward" in text and "EDT" in text and "UTC" in text
|
||||
key = _KEY.search(text).group(1)
|
||||
|
||||
page = await (await admin.get(resp.headers["Location"])).get_data(as_text=True)
|
||||
assert "Invites not yet used" in page and to in page and "Invite sent." in page
|
||||
assert [a for a, _ in await _audits(app, aid)] == ["user.invited"]
|
||||
|
||||
guest = await _stranger(app)
|
||||
page = await (await guest.get(f"/invite/{key}")).get_data(as_text=True)
|
||||
assert "Join Steward" in page and f"{aname} invited you as an operator" in page
|
||||
assert f'value="{to}"' in page # prefilled, and theirs to change
|
||||
|
||||
mine = f"me-{aid[:8]}@example.com"
|
||||
form = {"username": f"joined-{aid[:8]}", "email": mine, "password": "longenough", "confirm": "nope"}
|
||||
resp = await guest.post(f"/invite/{key}", headers=CSRF, form=form)
|
||||
assert resp.status_code == 400 and "don't match" in await resp.get_data(as_text=True)
|
||||
resp = await guest.post(f"/invite/{key}", headers=CSRF, form=form | {"username": aname, "confirm": "longenough"})
|
||||
assert resp.status_code == 400 and f"username {aname} is taken" in await resp.get_data(as_text=True)
|
||||
|
||||
resp = await guest.post(f"/invite/{key}", headers=CSRF, form=form | {"confirm": "longenough"})
|
||||
assert resp.status_code == 302
|
||||
async with guest.session_transaction() as sess:
|
||||
new_id = sess["user_id"]
|
||||
async with app.db_sessionmaker() as s:
|
||||
user = await s.get(User, new_id)
|
||||
assert (user.username, user.email, user.role.value, user.auth_source) == (
|
||||
f"joined-{aid[:8]}", mine, "operator", "local")
|
||||
assert [a for a, _ in await _audits(app, new_id)] == ["user.joined"]
|
||||
|
||||
# The key made one account; it opens nothing now.
|
||||
again = await _stranger(app)
|
||||
resp = await again.get(f"/invite/{key}")
|
||||
assert resp.status_code == 404 and "expired, been used or been withdrawn" in await resp.get_data(as_text=True)
|
||||
resp = await again.post(f"/invite/{key}", headers=CSRF,
|
||||
form=form | {"username": "second", "email": "second@example.com", "confirm": "longenough"})
|
||||
assert resp.status_code == 404
|
||||
page = await (await admin.get("/settings/users/")).get_data(as_text=True)
|
||||
assert to not in page # a used invite leaves the list
|
||||
|
||||
asyncio.run(_go())
|
||||
|
||||
|
||||
def test_without_mail_the_admin_gets_the_link_once(app):
|
||||
async def _go():
|
||||
app.config["SMTP"] = {}
|
||||
admin, aid, _ = await _admin(app)
|
||||
page = await (await admin.get("/settings/users/invite")).get_data(as_text=True)
|
||||
assert "No mail server is set" in page and "Make invite link" in page
|
||||
resp = await admin.post("/settings/users/invite", headers=CSRF,
|
||||
form={"email": f"nomail-{aid[:8]}@example.com", "role": "viewer", "days": "7"})
|
||||
page = await resp.get_data(as_text=True)
|
||||
assert resp.status_code == 200 and "It can't be shown again" in page
|
||||
key = _KEY.search(page).group(1)
|
||||
assert (await (await _stranger(app)).get(f"/invite/{key}")).status_code == 200
|
||||
|
||||
asyncio.run(_go())
|
||||
|
||||
|
||||
def test_resend_replaces_the_link_and_withdraw_ends_it(app, monkeypatch):
|
||||
sent = _mail(app, monkeypatch)
|
||||
|
||||
async def _go():
|
||||
from sqlalchemy import select
|
||||
from steward.models.invites import UserInvite
|
||||
admin, aid, _ = await _admin(app)
|
||||
to = f"again-{aid[:8]}@example.com"
|
||||
await admin.post("/settings/users/invite", headers=CSRF, form={"email": to, "role": "viewer", "days": "7"})
|
||||
first = _KEY.search(sent[-1].get_body(("plain",)).get_content()).group(1)
|
||||
async with app.db_sessionmaker() as s:
|
||||
invite_id = (await s.execute(select(UserInvite.id).where(UserInvite.email == to))).scalar_one()
|
||||
|
||||
resp = await admin.post(f"/settings/users/invites/{invite_id}/resend", headers=CSRF)
|
||||
assert resp.status_code == 302 and len(sent) == 2
|
||||
second = _KEY.search(sent[-1].get_body(("plain",)).get_content()).group(1)
|
||||
guest = await _stranger(app)
|
||||
assert (await guest.get(f"/invite/{first}")).status_code == 404
|
||||
assert (await guest.get(f"/invite/{second}")).status_code == 200
|
||||
|
||||
resp = await admin.post(f"/settings/users/invites/{invite_id}/revoke", headers=CSRF)
|
||||
assert resp.status_code == 302
|
||||
assert (await guest.get(f"/invite/{second}")).status_code == 404
|
||||
assert (await admin.post(f"/settings/users/invites/{invite_id}/resend", headers=CSRF)).status_code == 404
|
||||
assert [a for a, _ in await _audits(app, aid)] == ["user.invited", "user.invite_resent", "user.invite_revoked"]
|
||||
|
||||
asyncio.run(_go())
|
||||
|
||||
|
||||
def test_an_address_with_an_account_is_not_invited_and_only_an_admin_invites(app, monkeypatch):
|
||||
_mail(app, monkeypatch)
|
||||
|
||||
async def _go():
|
||||
admin, aid, _ = await _admin(app)
|
||||
resp = await admin.post("/settings/users/invite", headers=CSRF,
|
||||
form={"email": f"{aid}@test", "role": "viewer", "days": "7"})
|
||||
assert resp.status_code == 400 and "already has an account" in await resp.get_data(as_text=True)
|
||||
resp = await admin.post("/settings/users/invite", headers=CSRF,
|
||||
form={"email": "x@example.com", "role": "viewer", "days": "3"})
|
||||
assert resp.status_code == 400
|
||||
operator, _, _ = await _admin(app, role="operator")
|
||||
assert (await operator.get("/settings/users/invite")).status_code == 403
|
||||
assert (await operator.post("/settings/users/invite", headers=CSRF,
|
||||
form={"email": "y@example.com"})).status_code == 403
|
||||
|
||||
asyncio.run(_go())
|
||||
@@ -26,6 +26,7 @@ TOP_LEVEL = {
|
||||
# Logged out, or no page to go back up from.
|
||||
"steward/templates/auth/login.html",
|
||||
"steward/templates/auth/setup.html",
|
||||
"steward/templates/auth/invite.html",
|
||||
"steward/templates/errors/404.html",
|
||||
"steward/templates/errors/500.html",
|
||||
"steward/templates/errors/database_unavailable.html",
|
||||
|
||||
Reference in New Issue
Block a user