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
+2 -39
View File
@@ -1,4 +1,5 @@
"""M1 identity: 2FA (TOTP), session list/revoke, password reset (SPEC §6.1/§9.1)."""
"""M1 identity: 2FA (TOTP), session list/revoke (SPEC §6.1/§9.1). The password reset
flow is in test_password_reset.py."""
from __future__ import annotations
@@ -19,7 +20,6 @@ from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User
from app.redis_client import get_redis
CSRF = "csrf-token"
PASSWORD = "hunter2pass"
@@ -145,43 +145,6 @@ async def test_sessions_list_and_revoke(ctx: Ctx) -> None:
assert me.status_code == 401
async def test_password_reset_flow(ctx: Ctx) -> None:
# request a reset; token is stored in redis (not returned)
req = await ctx.client.post("/api/auth/password/reset-request", json={"handle": ctx.handle})
assert req.status_code == 200
redis = get_redis()
keys = await redis.keys("pwreset:*")
# Pick the token bound to THIS user (redis DB may hold others from prior runs).
token = None
for key in keys:
if await redis.get(key) == str(ctx.user_id):
token = key.split(":", 1)[1]
break
assert token is not None, "a reset token should have been issued for this user"
new_password = "brandNewPass9"
reset = await ctx.client.post(
"/api/auth/password/reset", json={"token": token, "password": new_password}
)
assert reset.status_code == 200
# token is single-use
again = await ctx.client.post(
"/api/auth/password/reset", json={"token": token, "password": new_password}
)
assert again.status_code == 400
# new password works, old one does not
async with _fresh_client(ctx.app) as anon:
good = await anon.post(
"/api/auth/login", json={"handle": ctx.handle, "password": new_password}
)
assert good.status_code == 200
bad = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
assert bad.status_code == 401
async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None:
# must not leak whether a handle exists
resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"})