Admin user management, password reset by email, and the Ultimo Email name

Branding: the page titles and app name are now "Ultimo Email".

Admin panel (now two columns with a divider):
- Users: search by login or by a mail account's address or name (results
  only, nothing listed until something is typed). A user opens a window to
  change their quota, block/unblock them, or delete them; block and delete ask
  for confirmation, and an admin cannot do either to their own account.
- Blocking (users.blocked_at, migration 0019) refuses sign-in with a clear
  message and revokes every session; a new user.signed_out event sends the
  user's open tabs to the sign-in screen at once. Any "not signed in" answer
  now does the same, not only /auth/me. Deleting removes mail accounts (sync
  stops), plugin rows (cleared inside the user's RLS context, since plugin
  tables have no FK to users), blobs, the search index, sessions and the user.
- Outgoing system mail (migration 0020): SMTP server, port, security, login,
  password (vault-encrypted, never sent back), From address and the public
  address reset links point to, plus a "Send test" that reports the server's
  error.

Password reset:
- "Forgot password?" on the sign-in page asks for the user name and always
  answers "If this account exists you will receive an email to your recovery
  email address"; the mail goes out after the response, so neither the reply
  nor its timing reveals an account, and it is capped at 3 per hour.
- The emailed link (token in the URL fragment, single use, 30 minutes) opens a
  page to set a new password. With 2-step verification on, the code is checked
  before anything changes; five wrong codes burn the link.
- Settings > Profile: the recovery email can be added, changed or removed,
  confirmed with the current password, which must be typed — autofill and
  paste are refused (TypedPasswordInput).

Also adds frontend/icons/ultimo-logo-bimi.svg, an SVG Tiny PS version of the
logo for a BIMI record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:16:27 +02:00
co-authored by Claude Opus 5.5
parent 8dc60dbf5c
commit 0ddc0cb001
32 changed files with 2267 additions and 87 deletions
+154 -7
View File
@@ -1,24 +1,40 @@
"""Instance administration (SPEC §6.1).
The backend seam for the admin panel. Today it carries only the registration
gate — the first account on a fresh install becomes the administrator and closes
signup behind itself, and this is how that gets reopened. Further instance-wide
options land here as the panel grows.
The backend seam for the admin panel: the registration gate — the first account
on a fresh install becomes the administrator and closes signup behind itself,
and this is how that gets reopened — the default quota, and managing users
(search, per-user quota, block, delete). An administrator cannot block or delete
their own account, so the instance can never lock out its last admin that way.
"""
from __future__ import annotations
import datetime as dt
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.db import get_session
from app.identity.deps import CurrentAdmin, require_csrf
from app.identity.schemas import AdminSettingsIn, AdminSettingsOut
from app.identity.mailer import MailerError, mailer_config, send_mail
from app.identity.schemas import (
AdminMailIn,
AdminMailOut,
AdminMailTestIn,
AdminSettingsIn,
AdminSettingsOut,
AdminUserOut,
AdminUserPatch,
MessageOut,
)
from app.identity.service import app_settings
from app.models import AppSettings
from app.identity.user_admin import delete_user, search_users, set_blocked
from app.models import Account, AppSettings, Message, User
from app.security.crypto import encrypt_secret
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -54,3 +70,134 @@ async def write_settings(
row.updated_at = dt.datetime.now(dt.UTC)
await db.commit()
return _out(row)
# --- Outgoing system mail -------------------------------------------------------
def _mail_out(row: AppSettings) -> AdminMailOut:
return AdminMailOut(
smtp_host=row.smtp_host,
smtp_port=row.smtp_port,
smtp_security=row.smtp_security,
smtp_username=row.smtp_username,
has_password=row.smtp_secret_ct is not None,
smtp_from=row.smtp_from,
public_url=row.public_url,
configured=bool(row.smtp_host and row.smtp_from and row.public_url),
)
@router.get("/mail", response_model=AdminMailOut)
async def read_mail(admin: CurrentAdmin, db: DbSession) -> AdminMailOut:
return _mail_out(await app_settings(db))
@router.put("/mail", response_model=AdminMailOut, dependencies=[Depends(require_csrf)])
async def write_mail(body: AdminMailIn, admin: CurrentAdmin, db: DbSession) -> AdminMailOut:
"""Merge the supplied SMTP fields. The password is stored vault-encrypted."""
row = await app_settings(db, for_update=True)
for name in ("smtp_host", "smtp_username", "smtp_from"):
value = getattr(body, name)
if value is not None:
setattr(row, name, value.strip())
if body.smtp_port is not None:
row.smtp_port = body.smtp_port
if body.smtp_security is not None:
row.smtp_security = body.smtp_security
if body.smtp_password is not None:
row.smtp_secret_ct = (
encrypt_secret(body.smtp_password.encode()) if body.smtp_password else None
)
if body.public_url is not None:
url = body.public_url.strip().rstrip("/")
if url and not url.startswith(("http://", "https://")):
raise_api_error(400, "invalid_url", "the address must start with http:// or https://")
row.public_url = url
row.updated_at = dt.datetime.now(dt.UTC)
await db.commit()
return _mail_out(row)
@router.post("/mail/test", response_model=MessageOut, dependencies=[Depends(require_csrf)])
async def test_mail(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -> MessageOut:
"""Send a test message with the saved settings, reporting the server's error."""
cfg = mailer_config(await app_settings(db))
if cfg is None:
raise_api_error(
409, "mail_not_configured", "set the SMTP server, From address and public address first"
)
text = (
"This is a test message from Ultimo Email.\n\n"
"Outgoing system mail works: password-reset links will be sent from this account.\n"
)
html = f"<p>{text.replace(chr(10) * 2, '</p><p>').strip()}</p>"
try:
await send_mail(cfg, body.to.strip(), "Ultimo Email test message", text, html)
except MailerError as exc:
raise_api_error(502, "mail_failed", f"sending failed: {exc}")
return MessageOut()
# --- Users --------------------------------------------------------------------
def _user_out(user: User, used_bytes: int, accounts: list[str]) -> AdminUserOut:
return AdminUserOut(
id=user.id,
handle=user.handle,
is_admin=user.is_admin,
blocked_at=user.blocked_at.isoformat() if user.blocked_at else None,
quota_bytes=user.quota_bytes,
used_bytes=used_bytes,
created_at=user.created_at.isoformat(),
accounts=accounts,
)
async def _target(db: AsyncSession, admin: User, user_id: uuid.UUID) -> User:
"""The user an admin action is aimed at; never the admin themselves."""
user = await db.scalar(select(User).where(User.id == user_id))
if user is None:
raise_api_error(404, "not_found", "user not found")
if user.id == admin.id:
raise_api_error(409, "cannot_modify_self", "you cannot do this to your own account")
return user
@router.get("/users", response_model=list[AdminUserOut])
async def list_users(
admin: CurrentAdmin, db: DbSession, q: str = Query("", max_length=200)
) -> list[AdminUserOut]:
"""Search users by login, or by a mail account's address or display name."""
return [_user_out(u, used, emails) for u, used, emails in await search_users(db, q)]
@router.patch("/users/{user_id}", response_model=AdminUserOut, dependencies=[Depends(require_csrf)])
async def patch_user(
user_id: uuid.UUID, body: AdminUserPatch, admin: CurrentAdmin, db: DbSession
) -> AdminUserOut:
"""Change a user's quota and/or block or unblock them."""
user = await _target(db, admin, user_id)
if body.quota_bytes is not None:
user.quota_bytes = body.quota_bytes
if body.blocked is not None:
await set_blocked(db, user, body.blocked)
await db.commit()
used = await db.scalar(
select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id)
)
emails = list(
await db.scalars(
select(Account.email).where(Account.user_id == user.id).order_by(Account.email)
)
)
return _user_out(user, int(used or 0), emails)
@router.delete("/users/{user_id}", dependencies=[Depends(require_csrf)])
async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) -> dict[str, str]:
"""Delete a user and all of their data. Cannot be undone."""
user = await _target(db, admin, user_id)
await delete_user(db, user)
return {"status": "deleted"}