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:
+154
-7
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user