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.disabled": "disabled user",
|
||||||
"user.enabled": "enabled user",
|
"user.enabled": "enabled user",
|
||||||
"user.deleted": "deleted 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.created": "added host",
|
||||||
"host.renamed": "renamed host",
|
"host.renamed": "renamed host",
|
||||||
"host.deleted": "deleted host",
|
"host.deleted": "deleted host",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from quart import Blueprint, render_template, request, redirect, url_for, session
|
from quart import Blueprint, render_template, request, redirect, url_for, session
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
@@ -291,3 +293,112 @@ async def setup_post():
|
|||||||
await log_audit(current_app, user.id, user.username, "auth.setup",
|
await log_audit(current_app, user.id, user.username, "auth.setup",
|
||||||
detail={"username": username})
|
detail={"username": username})
|
||||||
return redirect(url_for("dashboard.index"))
|
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,
|
text, html = _render("report", d=data, alerts=alerts, period=period,
|
||||||
sent=moment(data["generated_at"], tz), base=base)
|
sent=moment(data["generated_at"], tz), base=base)
|
||||||
return f"[Steward] Weekly report, {period}", text, html
|
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 .base import Base
|
||||||
from .users import User
|
from .users import User
|
||||||
from .api_tokens import ApiToken
|
from .api_tokens import ApiToken
|
||||||
|
from .invites import UserInvite
|
||||||
from .hosts import Host
|
from .hosts import Host
|
||||||
from .monitors import Monitor, MonitorResult, MonitorType
|
from .monitors import Monitor, MonitorResult, MonitorType
|
||||||
from .metrics import PluginMetric
|
from .metrics import PluginMetric
|
||||||
@@ -12,7 +13,7 @@ from .settings import AppSetting
|
|||||||
from .dashboard import Dashboard, DashboardWidget, DashboardShareToken
|
from .dashboard import Dashboard, DashboardWidget, DashboardShareToken
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base", "User", "ApiToken",
|
"Base", "User", "ApiToken", "UserInvite",
|
||||||
"Host",
|
"Host",
|
||||||
"Monitor", "MonitorResult", "MonitorType",
|
"Monitor", "MonitorResult", "MonitorType",
|
||||||
"PluginMetric",
|
"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 sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from steward.auth.middleware import require_role
|
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.audit import audit_request
|
||||||
from steward.core.settings import get_all_settings
|
from steward.core.settings import get_all_settings
|
||||||
from steward.models.alerts import AlertRule, MaintenanceWindow
|
from steward.models.alerts import AlertRule, MaintenanceWindow
|
||||||
from steward.models.ansible import AnsibleRun
|
from steward.models.ansible import AnsibleRun
|
||||||
from steward.models.api_tokens import ApiToken
|
from steward.models.api_tokens import ApiToken
|
||||||
from steward.models.audit import AuditEvent
|
from steward.models.audit import AuditEvent
|
||||||
|
from steward.models.invites import UserInvite
|
||||||
from steward.models.users import User, UserRole
|
from steward.models.users import User, UserRole
|
||||||
|
|
||||||
users_bp = Blueprint("users", __name__, url_prefix="/settings/users")
|
users_bp = Blueprint("users", __name__, url_prefix="/settings/users")
|
||||||
@@ -105,14 +106,22 @@ async def users_list():
|
|||||||
last = await _last_sign_ins(db)
|
last = await _last_sign_ins(db)
|
||||||
tokens = await _token_counts(db)
|
tokens = await _token_counts(db)
|
||||||
settings = await get_all_settings(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()))
|
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"))
|
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}
|
if u.is_active and mail.wants(u, kind, settings)] for u in users}
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"settings/users.html", users=users, last=last, tokens=tokens, sources=SOURCES,
|
"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)},
|
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),
|
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):
|
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")
|
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.",
|
_DONE = {"created": "Account added.", "saved": "Saved.", "role": "Role changed.",
|
||||||
"password": "Password set. It works from their next sign-in.",
|
"password": "Password set. It works from their next sign-in.",
|
||||||
"disabled": "Account disabled. They are signed out at their next click.",
|
"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-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 { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 0 1rem; }
|
||||||
.form-pair[hidden] { display: none; }
|
.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. */
|
/* A column of checkboxes: a target's groups, a group's members. */
|
||||||
.check-list { display: grid; gap: 0.4rem; }
|
.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); }
|
.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 section_tabs %}{{ settings_tabs("users") }}{% endblock %}
|
||||||
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "")]) }}{% endblock %}
|
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "")]) }}{% endblock %}
|
||||||
{% block content %}
|
{% 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">
|
<p class="page-verdict">
|
||||||
{{ users|length }} account{{ 's' if users|length != 1 }}
|
{{ users|length }} account{{ 's' if users|length != 1 }}
|
||||||
@@ -37,5 +39,28 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
<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 %}
|
{% 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.
|
# Logged out, or no page to go back up from.
|
||||||
"steward/templates/auth/login.html",
|
"steward/templates/auth/login.html",
|
||||||
"steward/templates/auth/setup.html",
|
"steward/templates/auth/setup.html",
|
||||||
|
"steward/templates/auth/invite.html",
|
||||||
"steward/templates/errors/404.html",
|
"steward/templates/errors/404.html",
|
||||||
"steward/templates/errors/500.html",
|
"steward/templates/errors/500.html",
|
||||||
"steward/templates/errors/database_unavailable.html",
|
"steward/templates/errors/database_unavailable.html",
|
||||||
|
|||||||
Reference in New Issue
Block a user