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). """Instance administration (SPEC §6.1).
The backend seam for the admin panel. Today it carries only the registration The backend seam for the admin panel: the registration gate — the first account
gate — the first account on a fresh install becomes the administrator and closes on a fresh install becomes the administrator and closes signup behind itself,
signup behind itself, and this is how that gets reopened. Further instance-wide and this is how that gets reopened — the default quota, and managing users
options land here as the panel grows. (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 from __future__ import annotations
import datetime as dt import datetime as dt
import uuid
from typing import Annotated 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 sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.db import get_session from app.db import get_session
from app.identity.deps import CurrentAdmin, require_csrf 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.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"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -54,3 +70,134 @@ async def write_settings(
row.updated_at = dt.datetime.now(dt.UTC) row.updated_at = dt.datetime.now(dt.UTC)
await db.commit() await db.commit()
return _out(row) 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"}
+3
View File
@@ -41,6 +41,9 @@ ALLOWED_EVENTS: frozenset[str] = frozenset(
# Sync v2 §3: published on login and every WS connect; drives tier # Sync v2 §3: published on login and every WS connect; drives tier
# promotion in the sync workers (§5). Payload: {user_id}. # promotion in the sync workers (§5). Payload: {user_id}.
"user.active", "user.active",
# The user's sessions were revoked by an administrator (blocked or
# deleted): open tabs drop to the sign-in screen. Payload: {user_id}.
"user.signed_out",
} }
) )
+3 -2
View File
@@ -30,8 +30,9 @@ async def current_user(
) -> User: ) -> User:
"""Load the authenticated :class:`User` (I-7: scoped by the session's user_id).""" """Load the authenticated :class:`User` (I-7: scoped by the session's user_id)."""
user = await db.scalar(select(User).where(User.id == session.user_id)) user = await db.scalar(select(User).where(User.id == session.user_id))
if user is None: if user is None or user.blocked_at is not None:
# Session references a deleted user; treat as unauthenticated. # Session references a deleted or blocked user; treat as unauthenticated.
# (Blocking also revokes the sessions; this covers any request in flight.)
raise_api_error(401, "not_authenticated", "authentication required") raise_api_error(401, "not_authenticated", "authentication required")
return user return user
+110
View File
@@ -0,0 +1,110 @@
"""Outgoing system mail (SPEC §6.1): mail the server sends on its own behalf.
Today that is only the password-reset link. It goes out through the SMTP
account the administrator configured in the admin panel (``app_settings``),
never through a user's own mail account — the whole point is that the user
cannot sign in. The administrator's server is trusted, so unlike user mail
accounts this connection is not held to the SSRF guard (it may well be a relay
on the LAN).
"""
from __future__ import annotations
import html as html_lib
import logging
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr, make_msgid
import aiosmtplib
from app.models import AppSettings
from app.security.crypto import decrypt_secret
logger = logging.getLogger(__name__)
SENDER_NAME = "Ultimo Email"
_TIMEOUT = 20.0
class MailerError(Exception):
"""System mail is not configured, or the SMTP server refused the message."""
@dataclass(frozen=True)
class MailerConfig:
host: str
port: int
security: str # starttls | ssl | none
username: str
password: str
sender: str
public_url: str
def mailer_config(row: AppSettings) -> MailerConfig | None:
"""The usable configuration, or None while the admin has not set one up."""
if not (row.smtp_host and row.smtp_from and row.public_url):
return None
password = decrypt_secret(row.smtp_secret_ct).decode() if row.smtp_secret_ct else ""
return MailerConfig(
host=row.smtp_host,
port=row.smtp_port,
security=row.smtp_security,
username=row.smtp_username,
password=password,
sender=row.smtp_from,
public_url=row.public_url.rstrip("/"),
)
async def send_mail(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None:
msg = EmailMessage()
msg["From"] = formataddr((SENDER_NAME, cfg.sender))
msg["To"] = to
msg["Subject"] = subject
msg["Message-ID"] = make_msgid(domain=cfg.sender.rsplit("@", 1)[-1])
msg["Auto-Submitted"] = "auto-generated" # RFC 3834: do not auto-reply to this
msg.set_content(text)
msg.add_alternative(html, subtype="html")
try:
await aiosmtplib.send(
msg,
hostname=cfg.host,
port=cfg.port,
username=cfg.username or None,
password=cfg.password or None,
use_tls=cfg.security == "ssl",
start_tls=cfg.security == "starttls",
timeout=_TIMEOUT,
)
except (aiosmtplib.SMTPException, OSError) as exc:
raise MailerError(str(exc) or type(exc).__name__) from exc
def reset_mail(link: str, handle: str) -> tuple[str, str, str]:
"""(subject, text, html) of the password-reset message."""
subject = "Reset your Ultimo Email password"
text = (
f"Someone asked to change the password of the Ultimo Email account {handle}.\n\n"
f"To choose a new password, open this link within 30 minutes:\n\n{link}\n\n"
"The link works once. If you did not ask for this, ignore this email — "
"your password stays as it is.\n"
)
h, u = html_lib.escape(handle), html_lib.escape(link, quote=True)
muted = "font-size:13px;color:#6b7280"
button = (
"display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;"
"padding:10px 18px;border-radius:8px;font-weight:bold"
)
body = "font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#1f2937;line-height:1.5"
html = (
f'<!doctype html>\n<html><body style="{body}">\n'
f"<p>Someone asked to change the password of the Ultimo Email account <b>{h}</b>.</p>\n"
"<p>To choose a new password, open this link within 30 minutes:</p>\n"
f'<p><a href="{u}" style="{button}">Change password</a></p>\n'
f'<p style="{muted}">Or paste this address into your browser:<br>{u}</p>\n'
f'<p style="{muted}">The link works once. If you did not ask for this, ignore this '
"email — your password stays as it is.</p>\n</body></html>\n"
)
return subject, text, html
+49 -16
View File
@@ -1,43 +1,76 @@
"""Password reset tokens (SPEC §6.1). """Password reset tokens (SPEC §6.1).
Single-use, 30-minute, Redis-backed tokens. Only issued when the account has a Single-use, 30-minute, Redis-backed tokens, emailed as a link to the account's
recovery email on file. Actual delivery of the token to the recovery address recovery address through the system mailer (:mod:`app.identity.mailer`).
requires an app-level mailer (not part of M0/M1); until then the token is logged
server-side. See docs/open-questions.md. A token survives a wrong 2FA code (the page asks again) but not five: after
that it is burned, so the link cannot be used to guess codes. Requests are
capped per account so the reset form cannot be used to flood someone's inbox.
""" """
from __future__ import annotations from __future__ import annotations
import logging
import secrets import secrets
import uuid import uuid
from app.redis_client import aw, get_redis from app.redis_client import aw, get_redis
logger = logging.getLogger(__name__)
_PREFIX = "pwreset:" _PREFIX = "pwreset:"
_FAILS = "pwreset-fail:"
_RATE = "pwreset-rate:"
_TTL = 30 * 60 # 30 minutes _TTL = 30 * 60 # 30 minutes
MAX_TOTP_FAILURES = 5
MAX_REQUESTS_PER_HOUR = 3
async def issue_token(user_id: uuid.UUID, recovery_email: str) -> str: async def issue_token(user_id: uuid.UUID) -> str:
"""Create a single-use reset token bound to ``user_id`` and return it.""" """Create a single-use reset token bound to ``user_id`` and return it."""
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
await get_redis().set(_PREFIX + token, str(user_id), ex=_TTL) await get_redis().set(_PREFIX + token, str(user_id), ex=_TTL)
# TODO(spec): deliver via the recovery address once an app mailer exists.
logger.info("password reset token issued for user=%s recovery=%s", user_id, recovery_email)
return token return token
async def request_allowed(user_id: uuid.UUID) -> bool:
"""Count a reset request; False once the account has had its hourly share."""
redis = get_redis()
key = _RATE + str(user_id)
count = int(await redis.incr(key))
if count == 1:
await redis.expire(key, 3600)
return count <= MAX_REQUESTS_PER_HOUR
def _user_id(raw: str | None) -> uuid.UUID | None:
if raw is None:
return None
try:
return uuid.UUID(raw)
except ValueError:
return None
async def peek_token(token: str) -> uuid.UUID | None:
"""The user a still-valid token belongs to, without using it up."""
if not token:
return None
return _user_id(await get_redis().get(_PREFIX + token))
async def consume_token(token: str) -> uuid.UUID | None: async def consume_token(token: str) -> uuid.UUID | None:
"""Atomically consume a reset token, returning the user_id (or None).""" """Atomically consume a reset token, returning the user_id (or None)."""
if not token: if not token:
return None return None
redis = get_redis() redis = get_redis()
raw = await aw(redis.getdel(_PREFIX + token)) # single-use: fetch and delete atomically raw = await aw(redis.getdel(_PREFIX + token)) # single-use: fetch and delete atomically
if raw is None: await redis.delete(_FAILS + token)
return None return _user_id(raw)
try:
return uuid.UUID(raw)
except ValueError: async def record_totp_failure(token: str) -> None:
return None """Count a wrong 2FA code against the token; burn it at the limit."""
redis = get_redis()
fails = await redis.incr(_FAILS + token)
if fails == 1:
await redis.expire(_FAILS + token, _TTL)
if fails >= MAX_TOTP_FAILURES:
await redis.delete(_PREFIX + token, _FAILS + token)
+97 -12
View File
@@ -3,9 +3,11 @@ TOTP setup/enable, session list/revoke, password reset."""
from __future__ import annotations from __future__ import annotations
import logging
import re
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,13 +22,23 @@ from app.identity.cookies import (
set_auth_cookies, set_auth_cookies,
) )
from app.identity.deps import CurrentSession, CurrentUser, require_csrf from app.identity.deps import CurrentSession, CurrentUser, require_csrf
from app.identity.mailer import MailerConfig, MailerError, mailer_config, reset_mail, send_mail
from app.identity.passwords import hash_password, verify_password from app.identity.passwords import hash_password, verify_password
from app.identity.reset import consume_token, issue_token from app.identity.reset import (
consume_token,
issue_token,
peek_token,
record_totp_failure,
request_allowed,
)
from app.identity.schemas import ( from app.identity.schemas import (
LoginRequest, LoginRequest,
MessageOut, MessageOut,
PasswordResetCheckIn,
PasswordResetCheckOut,
PasswordResetIn, PasswordResetIn,
PasswordResetRequestIn, PasswordResetRequestIn,
RecoveryEmailIn,
RegisterRequest, RegisterRequest,
RegistrationStatusOut, RegistrationStatusOut,
SessionOut, SessionOut,
@@ -36,10 +48,12 @@ from app.identity.schemas import (
UserOut, UserOut,
) )
from app.identity.service import ( from app.identity.service import (
AccountBlocked,
RateLimited, RateLimited,
RegistrationError, RegistrationError,
TotpInvalid, TotpInvalid,
TotpRequired, TotpRequired,
app_settings,
authenticate, authenticate,
register_user, register_user,
registration_status, registration_status,
@@ -56,8 +70,14 @@ from app.models import User
from app.redis_client import get_redis from app.redis_client import get_redis
from app.sync import leases from app.sync import leases
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
# Deliberately loose: the recovery address only has to be something mail can be
# sent to; the mail server is the real judge.
_EMAIL_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")
DbSession = Annotated[AsyncSession, Depends(get_session)] DbSession = Annotated[AsyncSession, Depends(get_session)]
@@ -128,6 +148,10 @@ async def login(body: LoginRequest, request: Request, response: Response, db: Db
raise_api_error(401, "totp_required", "a 2FA code is required") raise_api_error(401, "totp_required", "a 2FA code is required")
except TotpInvalid: except TotpInvalid:
raise_api_error(401, "totp_invalid", "incorrect 2FA code") raise_api_error(401, "totp_invalid", "incorrect 2FA code")
except AccountBlocked:
raise_api_error(
403, "account_blocked", "this account has been blocked by the administrator"
)
if user is None: if user is None:
raise_api_error(401, "invalid_credentials", "incorrect handle or password") raise_api_error(401, "invalid_credentials", "incorrect handle or password")
await _establish_session(request, response, user) await _establish_session(request, response, user)
@@ -209,25 +233,86 @@ async def revoke(sid: str, user: CurrentUser) -> MessageOut:
# --- Password reset (SPEC §6.1) --------------------------------------------- # --- Password reset (SPEC §6.1) ---------------------------------------------
async def _send_reset_mail(cfg: MailerConfig, to: str, handle: str, token: str) -> None:
"""Runs after the response has gone out (see password_reset_request)."""
# The token rides in the fragment, which browsers never send to a server, so
# it stays out of access logs and Referer headers.
subject, text, html = reset_mail(f"{cfg.public_url}/reset-password#token={token}", handle)
try:
await send_mail(cfg, to, subject, text, html)
except MailerError as exc:
logger.warning("password reset mail for %s could not be sent: %s", handle, exc)
@router.post("/password/reset-request", response_model=MessageOut) @router.post("/password/reset-request", response_model=MessageOut)
async def password_reset_request(body: PasswordResetRequestIn, db: DbSession) -> MessageOut: async def password_reset_request(
# Always return ok (do not leak whether the handle exists / has recovery mail). body: PasswordResetRequestIn, background: BackgroundTasks, db: DbSession
) -> MessageOut:
"""Email a reset link to the account's recovery address.
The answer is the same whether or not the account exists, has a recovery
address, or hit the hourly cap, and the mail is sent after the response —
so neither the reply nor its timing tells a stranger anything.
"""
handle = resolve_handle(body.handle) handle = resolve_handle(body.handle)
user = await db.scalar(select(User).where(User.handle == handle)) user = await db.scalar(select(User).where(User.handle == handle))
if user is not None and user.recovery_email: if user is None or not user.recovery_email:
await issue_token(user.id, user.recovery_email)
return MessageOut() return MessageOut()
cfg = mailer_config(await app_settings(db))
if cfg is None:
logger.warning("password reset requested for %s, but system mail is not set up", handle)
return MessageOut()
if not await request_allowed(user.id):
return MessageOut()
token = await issue_token(user.id)
background.add_task(_send_reset_mail, cfg, user.recovery_email, user.handle, token)
return MessageOut()
@router.post("/password/reset/check", response_model=PasswordResetCheckOut)
async def password_reset_check(body: PasswordResetCheckIn, db: DbSession) -> PasswordResetCheckOut:
"""Whether a reset link still works, so the page can say so before asking
for a new password. Does not use the link up."""
user_id = await peek_token(body.token)
user = await db.get(User, user_id) if user_id else None
if user is None:
return PasswordResetCheckOut(valid=False)
return PasswordResetCheckOut(valid=True, totp_required=user.totp_secret is not None)
@router.post("/password/reset", response_model=MessageOut) @router.post("/password/reset", response_model=MessageOut)
async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut: async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
user_id = await consume_token(body.token) """Set a new password with a reset link. With 2FA on, the code is checked
if user_id is None: before anything changes; a missing or wrong code leaves the link usable
raise_api_error(400, "invalid_token", "reset token is invalid or expired") (up to MAX_TOTP_FAILURES wrong codes)."""
user = await db.get(User, user_id) user_id = await peek_token(body.token)
user = await db.get(User, user_id) if user_id else None
if user is None: if user is None:
raise_api_error(400, "invalid_token", "reset token is invalid or expired") raise_api_error(400, "invalid_token", "this link is invalid or has expired")
if user.totp_secret is not None:
if not body.totp:
raise_api_error(401, "totp_required", "a 2FA code is required")
if not totp_mod.verify(totp_mod.decrypt_totp_secret(user.totp_secret), body.totp):
await record_totp_failure(body.token)
raise_api_error(401, "totp_invalid", "incorrect 2FA code")
# Only now is the link spent — and only if nobody spent it in the meantime.
if await consume_token(body.token) != user.id:
raise_api_error(400, "invalid_token", "this link is invalid or has expired")
user.password_hash = hash_password(body.password) user.password_hash = hash_password(body.password)
await db.commit() await db.commit()
await destroy_all_sessions(user_id) # force re-login everywhere after reset await destroy_all_sessions(user.id) # force re-login everywhere after reset
return MessageOut() return MessageOut()
@router.put("/recovery-email", response_model=UserOut, dependencies=[Depends(require_csrf)])
async def set_recovery_email(body: RecoveryEmailIn, user: CurrentUser, db: DbSession) -> UserOut:
"""Add, change or remove the recovery address. Takes the password: whoever
controls this address can take the account over with a reset link."""
if not verify_password(user.password_hash, body.password):
raise_api_error(403, "invalid_password", "incorrect password")
email = (body.recovery_email or "").strip().lower() or None
if email is not None and not _EMAIL_RE.fullmatch(email):
raise_api_error(400, "invalid_email", "enter a valid email address")
user.recovery_email = email
await db.commit()
return _user_out(user)
+68
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from typing import Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -47,6 +48,23 @@ class PasswordResetRequestIn(BaseModel):
class PasswordResetIn(BaseModel): class PasswordResetIn(BaseModel):
token: str = Field(min_length=1) token: str = Field(min_length=1)
password: str = Field(min_length=8, max_length=1024) password: str = Field(min_length=8, max_length=1024)
totp: str | None = None # required when the account has 2FA enabled
class PasswordResetCheckIn(BaseModel):
token: str = Field(min_length=1)
class PasswordResetCheckOut(BaseModel):
valid: bool
# Tells the page to expect the 2FA step; only the link's holder learns it.
totp_required: bool = False
class RecoveryEmailIn(BaseModel):
password: str = Field(min_length=1, max_length=1024)
# None (or blank) removes the recovery address.
recovery_email: str | None = Field(default=None, max_length=320)
class UserOut(BaseModel): class UserOut(BaseModel):
@@ -79,5 +97,55 @@ class AdminSettingsIn(BaseModel):
default_quota_bytes: int | None = Field(default=None, gt=0) default_quota_bytes: int | None = Field(default=None, gt=0)
class AdminMailOut(BaseModel):
smtp_host: str
smtp_port: int
smtp_security: str
smtp_username: str
# The password itself never leaves the server.
has_password: bool
smtp_from: str
public_url: str
# Host, From address and public address are all set: reset mail can go out.
configured: bool
class AdminMailIn(BaseModel):
"""Merge semantics: omitted fields are left as they are."""
smtp_host: str | None = Field(default=None, max_length=255)
smtp_port: int | None = Field(default=None, ge=1, le=65535)
smtp_security: Literal["starttls", "ssl", "none"] | None = None
smtp_username: str | None = Field(default=None, max_length=320)
# Omitted keeps the stored password; an empty string removes it.
smtp_password: str | None = Field(default=None, max_length=1024)
smtp_from: str | None = Field(default=None, max_length=320)
public_url: str | None = Field(default=None, max_length=500)
class AdminMailTestIn(BaseModel):
to: str = Field(min_length=3, max_length=320)
class AdminUserOut(BaseModel):
id: uuid.UUID
handle: str
is_admin: bool
# ISO timestamp while blocked; null otherwise.
blocked_at: str | None
quota_bytes: int
used_bytes: int
created_at: str
# The user's connected mail accounts, so the admin can tell who this is.
accounts: list[str]
class AdminUserPatch(BaseModel):
"""Merge semantics: omitted fields are left as they are."""
quota_bytes: int | None = Field(default=None, gt=0)
blocked: bool | None = None
class MessageOut(BaseModel): class MessageOut(BaseModel):
status: str = "ok" status: str = "ok"
+7
View File
@@ -53,6 +53,10 @@ class TotpInvalid(Exception):
"""Password verified but the supplied TOTP code was wrong.""" """Password verified but the supplied TOTP code was wrong."""
class AccountBlocked(Exception):
"""Password verified but an administrator has blocked the account."""
def resolve_handle(handle_input: str) -> str: def resolve_handle(handle_input: str) -> str:
"""Normalize a login identifier to the full ``localpart@APP_DOMAIN`` form.""" """Normalize a login identifier to the full ``localpart@APP_DOMAIN`` form."""
text = handle_input.strip().lower() text = handle_input.strip().lower()
@@ -164,6 +168,9 @@ async def authenticate(
ok = verify_password(stored_hash, password) ok = verify_password(stored_hash, password)
if user is None or not ok: if user is None or not ok:
return None return None
# Only after the password: a stranger must not learn that a handle is blocked.
if user.blocked_at is not None:
raise AccountBlocked
if user.totp_secret is not None: if user.totp_secret is not None:
from app.identity.totp import decrypt_totp_secret from app.identity.totp import decrypt_totp_secret
+125
View File
@@ -0,0 +1,125 @@
"""Administrator actions on user accounts (SPEC §6.1 admin panel).
Search, per-user quota, blocking and deletion. Blocking keeps every byte of the
user's data (and keeps their mail syncing) so unblocking is instant; it only
refuses sign-in and revokes the sessions they hold. Deletion is total: mail
accounts (through the same path the user's own "remove account" takes, so the
sync runners stop), plugin rows, blobs on disk, the search index and the user
row, whose foreign keys cascade to everything else core owns.
"""
from __future__ import annotations
import datetime as dt
import shutil
import uuid
from pathlib import Path
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.accounts.service import delete_account
from app.config import get_settings
from app.events import get_event_bus
from app.identity.sessions import destroy_all_sessions
from app.models import Account, Message, User
from app.plugins.db import plugin_session
from app.search.factory import get_search_backend
SEARCH_LIMIT = 25
async def search_users(db: AsyncSession, q: str) -> list[tuple[User, int, list[str]]]:
"""Users whose login, or one of whose mail accounts' address or display name,
contains ``q`` (case-insensitive). An empty ``q`` lists the first users by
login. Each row comes with its storage used and its mail account addresses."""
stmt = select(User).order_by(User.handle).limit(SEARCH_LIMIT)
q = q.strip()
if q:
# Typed % and _ are literal characters, not wildcards.
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
like = f"%{escaped}%"
by_account = select(Account.user_id).where(
or_(Account.email.ilike(like), Account.display_name.ilike(like))
)
stmt = stmt.where(or_(User.handle.ilike(like), User.id.in_(by_account)))
users = list(await db.scalars(stmt))
if not users:
return []
ids = [u.id for u in users]
used: dict[uuid.UUID, int] = {
user_id: int(total)
for user_id, total in await db.execute(
select(Message.user_id, func.coalesce(func.sum(Message.size_bytes), 0))
.where(Message.user_id.in_(ids))
.group_by(Message.user_id)
)
}
emails: dict[uuid.UUID, list[str]] = {}
for user_id, email in await db.execute(
select(Account.user_id, Account.email)
.where(Account.user_id.in_(ids))
.order_by(Account.email)
):
emails.setdefault(user_id, []).append(email)
return [(u, int(used.get(u.id, 0)), emails.get(u.id, [])) for u in users]
async def set_blocked(db: AsyncSession, user: User, blocked: bool) -> None:
"""Block or unblock. Blocking also signs the user out everywhere."""
if blocked and user.blocked_at is None:
user.blocked_at = dt.datetime.now(dt.UTC)
await db.flush()
await _sign_out_everywhere(user.id)
elif not blocked:
user.blocked_at = None
async def _sign_out_everywhere(user_id: uuid.UUID) -> None:
"""Revoke every session, and tell the user's open tabs so they leave at once
instead of showing already-loaded mail until their next request."""
await destroy_all_sessions(user_id)
await get_event_bus().publish("user.signed_out", {"user_id": str(user_id)})
async def _plugin_tables(db: AsyncSession) -> list[str]:
"""Every plugin table holding per-user rows (``plg_*`` with a ``user_id``)."""
rows = await db.execute(
text(
"SELECT table_name FROM information_schema.columns "
"WHERE table_schema = current_schema() AND column_name = 'user_id' "
"AND table_name LIKE 'plg\\_%' ORDER BY table_name"
)
)
return [r[0] for r in rows]
async def delete_user(db: AsyncSession, user: User) -> None:
"""Delete ``user`` and everything they own. Commits."""
user_id = user.id
# Mail accounts first, one by one: this is what stops their sync runners and
# tidies threads, exactly as when the user removes an account themselves.
for account_id in list(await db.scalars(select(Account.id).where(Account.user_id == user_id))):
await delete_account(db, user_id=user_id, account_id=account_id)
await db.commit()
# Plugin tables carry no foreign key to users and sit behind row-level
# security, so they are cleared from inside the user's own RLS context.
tables = await _plugin_tables(db)
if tables:
async with plugin_session(user_id) as ps:
for table in tables:
await ps.execute(
text(f'DELETE FROM "{table}" WHERE user_id = :uid'), {"uid": user_id}
)
await _sign_out_everywhere(user_id)
await get_search_backend().delete_user(user_id)
# The user row: its foreign keys cascade to the rest of core's tables.
await db.delete(user)
await db.commit()
# Whatever blobs are left (drafts' attachments and the like) go with the folder.
shutil.rmtree(Path(get_settings().blob_root) / str(user_id), ignore_errors=True)
+16
View File
@@ -69,6 +69,19 @@ class AppSettings(Base):
default_quota_bytes: Mapped[int] = mapped_column( default_quota_bytes: Mapped[int] = mapped_column(
BigInteger, nullable=False, server_default=text("5368709120") BigInteger, nullable=False, server_default=text("5368709120")
) )
# Outgoing system mail (password-reset links): the SMTP account the server
# sends from. The password is vault-encrypted (like Account.secret_ct).
smtp_host: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("587"))
smtp_security: Mapped[str] = mapped_column(
Text, nullable=False, server_default=text("'starttls'")
) # starttls | ssl | none
smtp_username: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
smtp_secret_ct: Mapped[bytes | None] = mapped_column(BYTEA)
smtp_from: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
# Where users reach the app, e.g. https://mail.example.com — reset links are
# built from this, never from the request's Host header.
public_url: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
updated_at: Mapped[dt.datetime] = _now() updated_at: Mapped[dt.datetime] = _now()
@@ -91,6 +104,9 @@ class User(Base):
# The first account created on a fresh install bootstraps as administrator; # The first account created on a fresh install bootstraps as administrator;
# the admin panel can grant it to others later. # the admin panel can grant it to others later.
is_admin: Mapped[bool] = mapped_column(nullable=False, server_default=text("false")) is_admin: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
# Set while an administrator has blocked the account (no sign-in, sessions
# refused); NULL when not blocked.
blocked_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
created_at: Mapped[dt.datetime] = _now() created_at: Mapped[dt.datetime] = _now()
+4
View File
@@ -55,6 +55,10 @@ async def ws_endpoint(websocket: WebSocket) -> None:
payload = data.get("payload", {}) payload = data.get("payload", {})
if str(payload.get("user_id")) == user_id: if str(payload.get("user_id")) == user_id:
await websocket.send_json(data) await websocket.send_json(data)
if data.get("event") == "user.signed_out":
# The session this socket was opened with no longer exists.
await websocket.close(code=1008)
return
async def receive() -> None: async def receive() -> None:
while True: while True:
@@ -0,0 +1,29 @@
"""administrator can block a user
``users.blocked_at`` is set while an administrator has blocked the account: it
cannot sign in and its sessions stop working. NULL means not blocked. A
timestamp rather than a flag so the admin panel can say since when.
Revision ID: 0019
Revises: 0018
Create Date: 2026-09-30
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0019"
down_revision: str | None = "0018"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE users ADD COLUMN blocked_at timestamptz")
def downgrade() -> None:
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS blocked_at")
@@ -0,0 +1,48 @@
"""outgoing system mail (password reset) configured in the admin panel
The server sends its own mail — today only password-reset links — through an
SMTP account the administrator sets up in the admin panel. The password is
envelope-encrypted with MASTER_KEY like every mail-account secret. ``public_url``
is where users reach the app; reset links are built from it, never from the
request's Host header, which an attacker controls.
Revision ID: 0020
Revises: 0019
Create Date: 2026-09-30
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0020"
down_revision: str | None = "0019"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_COLUMNS = (
("smtp_host", "text NOT NULL DEFAULT ''"),
("smtp_port", "integer NOT NULL DEFAULT 587"),
("smtp_security", "text NOT NULL DEFAULT 'starttls'"),
("smtp_username", "text NOT NULL DEFAULT ''"),
("smtp_secret_ct", "bytea"),
("smtp_from", "text NOT NULL DEFAULT ''"),
("public_url", "text NOT NULL DEFAULT ''"),
)
def upgrade() -> None:
for name, ddl in _COLUMNS:
op.execute(f"ALTER TABLE app_settings ADD COLUMN {name} {ddl}")
op.execute(
"ALTER TABLE app_settings ADD CONSTRAINT ck_app_settings_smtp_security "
"CHECK (smtp_security IN ('starttls', 'ssl', 'none'))"
)
def downgrade() -> None:
op.execute("ALTER TABLE app_settings DROP CONSTRAINT IF EXISTS ck_app_settings_smtp_security")
for name, _ in reversed(_COLUMNS):
op.execute(f"ALTER TABLE app_settings DROP COLUMN IF EXISTS {name}")
+196
View File
@@ -0,0 +1,196 @@
"""Admin user management (SPEC §6.1): search, quota, block, delete.
Runs against the dev database with real sessions, so every user it creates is
removed again on the way out.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
import httpx
import pytest
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, select, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity import user_admin
from app.identity.passwords import hash_password
from app.identity.sessions import create_session, resolve_session
from app.main import create_app
from app.models import Account, PluginKv, User
from app.plugins.db import plugin_session
CSRF = "csrf-token"
PASSWORD = "hunter2pass"
def _handle() -> str:
return f"adm{uuid.uuid4().hex[:8]}@test.local"
class World:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.created: list[uuid.UUID] = []
async def user(self, *, admin: bool = False) -> User:
user = User(handle=_handle(), password_hash=hash_password(PASSWORD), is_admin=admin)
self.session.add(user)
await self.session.commit()
self.created.append(user.id)
return user
async def client(self, user: User) -> httpx.AsyncClient:
token = await create_session(user.id, "pytest")
return httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
headers={"X-CSRF": CSRF},
)
@pytest_asyncio.fixture
async def world() -> AsyncIterator[World]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
session = async_sessionmaker(engine, expire_on_commit=False)()
w = World(session)
try:
yield w
finally:
await session.rollback()
await session.execute(delete(User).where(User.id.in_(w.created)))
await session.commit()
await session.close()
await engine.dispose()
def _account(user_id: uuid.UUID, email: str, name: str = "") -> Account:
return Account(
user_id=user_id,
email=email,
display_name=name,
imap_config={},
smtp_config={},
secret_ct=b"x",
discovery_src="manual",
state="live",
)
async def test_only_an_admin_can_manage_users(world: World) -> None:
plain = await world.user()
async with await world.client(plain) as c:
assert (await c.get("/api/admin/users")).status_code == 403
async def test_search_by_login_and_by_mail_account(world: World) -> None:
admin = await world.user(admin=True)
target = await world.user()
tag = uuid.uuid4().hex[:10]
world.session.add(_account(target.id, f"x{tag}@example.com", f"Zed {tag}"))
await world.session.commit()
async with await world.client(admin) as c:
by_login = (await c.get("/api/admin/users", params={"q": target.handle[:11]})).json()
assert [u["id"] for u in by_login] == [str(target.id)]
by_email = (await c.get("/api/admin/users", params={"q": f"x{tag}@"})).json()
assert [u["id"] for u in by_email] == [str(target.id)]
assert by_email[0]["accounts"] == [f"x{tag}@example.com"]
by_name = (await c.get("/api/admin/users", params={"q": f"zed {tag}"})).json()
assert [u["id"] for u in by_name] == [str(target.id)]
# A typed % is a character, not "match anything".
assert (await c.get("/api/admin/users", params={"q": f"{tag}%"})).json() == []
async def test_change_quota(world: World) -> None:
admin = await world.user(admin=True)
target = await world.user()
async with await world.client(admin) as c:
resp = await c.patch(f"/api/admin/users/{target.id}", json={"quota_bytes": 123456789})
assert resp.status_code == 200
assert resp.json()["quota_bytes"] == 123456789
bad = await c.patch(f"/api/admin/users/{target.id}", json={"quota_bytes": 0})
assert bad.status_code == 422
class _Bus:
def __init__(self) -> None:
self.published: list[tuple[str, dict[str, object]]] = []
async def publish(self, event: str, payload: dict[str, object]) -> None:
self.published.append((event, payload))
@pytest.fixture
def bus(monkeypatch: pytest.MonkeyPatch) -> _Bus:
fake = _Bus()
monkeypatch.setattr(user_admin, "get_event_bus", lambda: fake)
return fake
async def test_block_refuses_login_and_ends_sessions_then_unblock(world: World, bus: _Bus) -> None:
admin = await world.user(admin=True)
target = await world.user()
token = await create_session(target.id, "pytest")
async with await world.client(admin) as c:
resp = await c.patch(f"/api/admin/users/{target.id}", json={"blocked": True})
assert resp.status_code == 200 and resp.json()["blocked_at"]
# The session the user held is gone, and their open tabs are told to leave.
assert await resolve_session(token) is None
assert ("user.signed_out", {"user_id": str(target.id)}) in bus.published
anon = httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t")
async with anon:
# ...and they cannot sign back in, told why only after a correct password.
login = {"handle": target.handle, "password": PASSWORD}
blocked = await anon.post("/api/auth/login", json=login)
assert blocked.status_code == 403
assert blocked.json()["error"]["code"] == "account_blocked"
wrong = await anon.post("/api/auth/login", json={**login, "password": "nope-nope"})
assert wrong.json()["error"]["code"] == "invalid_credentials"
async with await world.client(admin) as c:
resp = await c.patch(f"/api/admin/users/{target.id}", json={"blocked": False})
assert resp.json()["blocked_at"] is None
assert (await anon.post("/api/auth/login", json=login)).status_code == 200
async def test_admin_cannot_block_or_delete_themselves(world: World) -> None:
admin = await world.user(admin=True)
async with await world.client(admin) as c:
assert (
await c.patch(f"/api/admin/users/{admin.id}", json={"blocked": True})
).status_code == 409
assert (await c.delete(f"/api/admin/users/{admin.id}")).status_code == 409
async def test_delete_removes_the_user_and_their_data(world: World, bus: _Bus) -> None:
admin = await world.user(admin=True)
target = await world.user()
world.session.add(_account(target.id, f"d{uuid.uuid4().hex[:8]}@example.com"))
world.session.add(PluginKv(plugin="labels", user_id=target.id, key="k", value={"a": 1}))
await world.session.commit()
# A plugin row: no foreign key to users, behind row-level security.
async with plugin_session(target.id) as ps:
await ps.execute(
text("INSERT INTO plg_contacts_books (user_id, name) VALUES (:u, 'Book')"),
{"u": target.id},
)
token = await create_session(target.id, "pytest")
async with await world.client(admin) as c:
assert (await c.delete(f"/api/admin/users/{target.id}")).status_code == 200
s = world.session
assert await s.scalar(select(User).where(User.id == target.id)) is None
assert await s.scalar(select(Account).where(Account.user_id == target.id)) is None
assert await s.scalar(select(PluginKv).where(PluginKv.user_id == target.id)) is None
async with plugin_session(target.id) as ps:
left = await ps.scalar(text("SELECT count(*) FROM plg_contacts_books"))
assert left == 0
assert await resolve_session(token) is None
assert ("user.signed_out", {"user_id": str(target.id)}) in bus.published
+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 from __future__ import annotations
@@ -19,7 +20,6 @@ from app.identity.passwords import hash_password
from app.identity.sessions import create_session from app.identity.sessions import create_session
from app.main import create_app from app.main import create_app
from app.models import User from app.models import User
from app.redis_client import get_redis
CSRF = "csrf-token" CSRF = "csrf-token"
PASSWORD = "hunter2pass" PASSWORD = "hunter2pass"
@@ -145,43 +145,6 @@ async def test_sessions_list_and_revoke(ctx: Ctx) -> None:
assert me.status_code == 401 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: async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None:
# must not leak whether a handle exists # must not leak whether a handle exists
resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"}) resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"})
@@ -0,0 +1,309 @@
"""Password reset by email, recovery address, and the system mailer (SPEC §6.1).
The SMTP send is replaced with a recorder, so nothing leaves the machine; the
reset link it would have sent is read back from what was recorded.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
import httpx
import pyotp
import pytest
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api import admin as admin_api
from app.config import get_settings
from app.identity import routes
from app.identity.mailer import MailerConfig
from app.identity.passwords import hash_password
from app.identity.reset import MAX_TOTP_FAILURES
from app.identity.service import app_settings
from app.identity.sessions import create_session
from app.identity.totp import encrypt_totp_secret
from app.main import create_app
from app.models import AppSettings, User
from app.redis_client import get_redis
CSRF = "csrf-token"
PASSWORD = "hunter2pass"
NEW_PASSWORD = "brandNewPass9"
CFG = MailerConfig(
host="smtp.example.com",
port=587,
security="starttls",
username="postmaster@example.com",
password="pw",
sender="postmaster@example.com",
public_url="https://mail.example.com",
)
@dataclass
class Sent:
mails: list[tuple[str, str, str]] = field(default_factory=list) # (to, subject, text)
def token(self) -> str:
text = self.mails[-1][2]
link = next(w for w in text.split() if w.startswith("https://mail.example.com/"))
assert link.startswith("https://mail.example.com/reset-password#token=")
return link.split("#token=", 1)[1]
@dataclass
class Ctx:
session: AsyncSession
anon: httpx.AsyncClient
sent: Sent
created: list[uuid.UUID] = field(default_factory=list)
async def user(
self, *, recovery: str | None = "me@elsewhere.example", admin: bool = False
) -> User:
user = User(
handle=f"pr{uuid.uuid4().hex[:8]}@test.local",
password_hash=hash_password(PASSWORD),
recovery_email=recovery,
is_admin=admin,
)
self.session.add(user)
await self.session.commit()
self.created.append(user.id)
await get_redis().delete(f"pwreset-rate:{user.id}")
return user
async def signed_in(self, user: User) -> httpx.AsyncClient:
token = await create_session(user.id, "pytest")
return httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
headers={"X-CSRF": CSRF},
)
@pytest_asyncio.fixture
async def ctx(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[Ctx]:
sent = Sent()
async def fake_send(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None:
sent.mails.append((to, subject, text))
monkeypatch.setattr(routes, "send_mail", fake_send)
monkeypatch.setattr(admin_api, "send_mail", fake_send)
monkeypatch.setattr(routes, "mailer_config", lambda row: CFG)
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
session = async_sessionmaker(engine, expire_on_commit=False)()
anon = httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t")
c = Ctx(session=session, anon=anon, sent=sent)
try:
yield c
finally:
await anon.aclose()
await session.execute(delete(User).where(User.id.in_(c.created)))
await session.commit()
await session.close()
await engine.dispose()
async def _request(ctx: Ctx, handle: str) -> httpx.Response:
return await ctx.anon.post("/api/auth/password/reset-request", json={"handle": handle})
async def _login(ctx: Ctx, handle: str, password: str) -> int:
resp = await ctx.anon.post("/api/auth/login", json={"handle": handle, "password": password})
return resp.status_code
# --- requesting a link -----------------------------------------------------------
async def test_request_mails_a_one_time_link_to_the_recovery_address(ctx: Ctx) -> None:
user = await ctx.user()
assert (await _request(ctx, user.handle)).status_code == 200
assert [m[0] for m in ctx.sent.mails] == ["me@elsewhere.example"]
token = ctx.sent.token()
check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
assert check.json() == {"valid": True, "totp_required": False}
reset = await ctx.anon.post(
"/api/auth/password/reset", json={"token": token, "password": NEW_PASSWORD}
)
assert reset.status_code == 200
# Used up: a second go fails, and the page is told so up front.
again = await ctx.anon.post(
"/api/auth/password/reset", json={"token": token, "password": "anotherPass12"}
)
assert again.json()["error"]["code"] == "invalid_token"
check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
assert check.json()["valid"] is False
assert await _login(ctx, user.handle, NEW_PASSWORD) == 200
assert await _login(ctx, user.handle, PASSWORD) == 401
async def test_the_answer_never_says_whether_the_account_exists(ctx: Ctx) -> None:
no_recovery = await ctx.user(recovery=None)
for handle in ("nobody-here-at-all", no_recovery.handle):
resp = await _request(ctx, handle)
assert resp.status_code == 200 and resp.json() == {"status": "ok"}
assert ctx.sent.mails == []
async def test_no_mail_while_system_mail_is_not_set_up(
ctx: Ctx, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(routes, "mailer_config", lambda row: None)
user = await ctx.user()
assert (await _request(ctx, user.handle)).status_code == 200
assert ctx.sent.mails == []
async def test_requests_are_capped_per_hour(ctx: Ctx) -> None:
user = await ctx.user()
for _ in range(5):
assert (await _request(ctx, user.handle)).status_code == 200
assert len(ctx.sent.mails) == 3
# --- two-step verification ---------------------------------------------------------
async def test_2fa_code_is_required_before_the_password_changes(ctx: Ctx) -> None:
user = await ctx.user()
secret = pyotp.random_base32()
await ctx.session.execute(
update(User).where(User.id == user.id).values(totp_secret=encrypt_totp_secret(secret))
)
await ctx.session.commit()
await _request(ctx, user.handle)
token = ctx.sent.token()
body = {"token": token, "password": NEW_PASSWORD}
check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
assert check.json()["totp_required"] is True
missing = await ctx.anon.post("/api/auth/password/reset", json=body)
assert missing.json()["error"]["code"] == "totp_required"
wrong = await ctx.anon.post("/api/auth/password/reset", json={**body, "totp": "000000"})
assert wrong.json()["error"]["code"] == "totp_invalid"
# Nothing changed yet, and the link still works.
assert await _login(ctx, user.handle, PASSWORD) == 401 # 2FA account: needs a code
good = await ctx.anon.post(
"/api/auth/password/reset", json={**body, "totp": pyotp.TOTP(secret).now()}
)
assert good.status_code == 200
login = await ctx.anon.post(
"/api/auth/login",
json={"handle": user.handle, "password": NEW_PASSWORD, "totp": pyotp.TOTP(secret).now()},
)
assert login.status_code == 200
async def test_too_many_wrong_codes_burn_the_link(ctx: Ctx) -> None:
user = await ctx.user()
secret = pyotp.random_base32()
await ctx.session.execute(
update(User).where(User.id == user.id).values(totp_secret=encrypt_totp_secret(secret))
)
await ctx.session.commit()
await _request(ctx, user.handle)
token = ctx.sent.token()
body = {"token": token, "password": NEW_PASSWORD, "totp": "000000"}
for _ in range(MAX_TOTP_FAILURES):
await ctx.anon.post("/api/auth/password/reset", json=body)
late = await ctx.anon.post(
"/api/auth/password/reset", json={**body, "totp": pyotp.TOTP(secret).now()}
)
assert late.json()["error"]["code"] == "invalid_token"
# --- the recovery address ----------------------------------------------------------
async def test_changing_the_recovery_email_takes_the_password(ctx: Ctx) -> None:
user = await ctx.user(recovery=None)
async with await ctx.signed_in(user) as c:
wrong = await c.put(
"/api/auth/recovery-email",
json={"password": "not-it", "recovery_email": "new@example.com"},
)
assert wrong.status_code == 403
bad = await c.put(
"/api/auth/recovery-email", json={"password": PASSWORD, "recovery_email": "nope"}
)
assert bad.json()["error"]["code"] == "invalid_email"
ok = await c.put(
"/api/auth/recovery-email",
json={"password": PASSWORD, "recovery_email": " New@Example.com "},
)
assert ok.json()["recovery_email"] == "new@example.com"
removed = await c.put(
"/api/auth/recovery-email", json={"password": PASSWORD, "recovery_email": ""}
)
assert removed.json()["recovery_email"] is None
# --- admin: system mail settings -----------------------------------------------------
@pytest_asyncio.fixture
async def restore_settings(ctx: Ctx) -> AsyncIterator[None]:
row = await app_settings(ctx.session)
saved = {
k: getattr(row, k)
for k in (
"smtp_host",
"smtp_port",
"smtp_security",
"smtp_username",
"smtp_secret_ct",
"smtp_from",
"public_url",
)
}
await ctx.session.commit()
yield
await ctx.session.execute(update(AppSettings).values(**saved))
await ctx.session.commit()
async def test_admin_configures_system_mail_without_ever_seeing_the_password(
ctx: Ctx, restore_settings: None
) -> None:
admin = await ctx.user(admin=True)
async with await ctx.signed_in(admin) as c:
resp = await c.put(
"/api/admin/mail",
json={
"smtp_host": "smtp.example.com",
"smtp_port": 465,
"smtp_security": "ssl",
"smtp_username": "postmaster@example.com",
"smtp_password": "s3cret",
"smtp_from": "postmaster@example.com",
"public_url": "https://mail.example.com/",
},
)
out = resp.json()
assert out["has_password"] is True and out["configured"] is True
assert out["public_url"] == "https://mail.example.com"
assert "s3cret" not in resp.text
# Omitting the password keeps it; an empty one removes it.
kept = (await c.put("/api/admin/mail", json={"smtp_port": 587})).json()
assert kept["has_password"] is True
gone = (await c.put("/api/admin/mail", json={"smtp_password": ""})).json()
assert gone["has_password"] is False
bad = await c.put("/api/admin/mail", json={"public_url": "mail.example.com"})
assert bad.json()["error"]["code"] == "invalid_url"
test = await c.post("/api/admin/mail/test", json={"to": "me@example.com"})
assert test.status_code == 200
assert ctx.sent.mails[-1][:2] == ("me@example.com", "Ultimo Email test message")
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" /> <link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
<title>Webmail admin</title> <title>Ultimo Email admin</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 512 512">
<title>Ultimo Email</title>
<desc>Ultimo Email logo, for BIMI (SVG Tiny Portable/Secure)</desc>
<defs>
<linearGradient id="g" gradientUnits="userSpaceOnUse" x1="44.1352" y1="192.6008" x2="44.1352" y2="224.5123">
<stop offset="0.202" stop-color="#f89629"/>
<stop offset="1" stop-color="#f74176"/>
</linearGradient>
</defs>
<rect width="512" height="512" fill="#ffffff"/>
<g transform="translate(97.2800,104.2299) scale(7.063024) translate(-21.70051,-187.41349)">
<path fill="url(#g)" d="m 58.875622,191.58757 c -2.152336,-0.00074 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-0.00007 -0.0835,0.00059 -0.125155,0.002 v -2.78918 c -0.000166,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,0.00018 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -0.000039,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 0.0007,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 0.000782,0.001 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 0.000782,-0.00082 z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" /> <link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
<title>Webmail</title> <title>Ultimo Email</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+4
View File
@@ -6,9 +6,11 @@ import { useMe } from "./lib/auth";
import { connectWs } from "./lib/ws"; import { connectWs } from "./lib/ws";
import { PluginPage } from "./plugins/PluginPage"; import { PluginPage } from "./plugins/PluginPage";
import { ComposeWindow } from "./routes/ComposeWindow"; import { ComposeWindow } from "./routes/ComposeWindow";
import { ForgotPassword } from "./routes/ForgotPassword";
import { Login } from "./routes/Login"; import { Login } from "./routes/Login";
import { MailLayout } from "./routes/MailLayout"; import { MailLayout } from "./routes/MailLayout";
import { Register } from "./routes/Register"; import { Register } from "./routes/Register";
import { ResetPassword } from "./routes/ResetPassword";
import { Settings } from "./routes/settings/Settings"; import { Settings } from "./routes/settings/Settings";
function RequireAuth({ children }: { children: JSX.Element }) { function RequireAuth({ children }: { children: JSX.Element }) {
@@ -35,6 +37,8 @@ export function App() {
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route <Route
path="/mail/*" path="/mail/*"
element={ element={
+502 -4
View File
@@ -1,9 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useT } from "../i18n"; import { useT } from "../i18n";
import { api, ApiError } from "../lib/api"; import { api, ApiError } from "../lib/api";
import { useMe } from "../lib/auth"; import { useMe } from "../lib/auth";
import type { AdminMail, AdminUser } from "../lib/types";
const GIB = 1024 ** 3; const GIB = 1024 ** 3;
@@ -28,11 +29,24 @@ export function AdminApp() {
<header className="border-b border-app-border px-6 py-3"> <header className="border-b border-app-border px-6 py-3">
<h1 className="font-semibold">{t("admin.title")}</h1> <h1 className="font-semibold">{t("admin.title")}</h1>
</header> </header>
<main className="mx-auto max-w-2xl px-6 py-10"> {/* Two columns once there is room: instance settings and users on the
left, outgoing system mail on the right. Signed out, it stays narrow. */}
<main className={`mx-auto px-6 py-10 ${me.data?.is_admin ? "max-w-6xl" : "max-w-2xl"}`}>
{me.isPending && <p className="text-sm text-app-muted">{t("common.loading")}</p>} {me.isPending && <p className="text-sm text-app-muted">{t("common.loading")}</p>}
{!me.isPending && !me.data && <SignIn />} {!me.isPending && !me.data && <SignIn />}
{me.data && !me.data.is_admin && <NotAdmin handle={me.data.handle} />} {me.data && !me.data.is_admin && <NotAdmin handle={me.data.handle} />}
{me.data?.is_admin && <Panel handle={me.data.handle} />} {me.data?.is_admin && (
<div className="grid gap-y-10 lg:grid-cols-2">
<div className="min-w-0 lg:pr-12">
<Panel handle={me.data.handle} />
<Users selfId={me.data.id} />
</div>
{/* The divider runs the full height of the taller column. */}
<div className="min-w-0 lg:border-l lg:border-app-border lg:pl-12">
<SystemMail />
</div>
</div>
)}
</main> </main>
</div> </div>
); );
@@ -235,3 +249,487 @@ function Panel({ handle }: { handle: string }) {
</section> </section>
); );
} }
type MailDraft = Omit<AdminMail, "has_password" | "configured">;
/** The SMTP account the server sends password-reset links from. */
function SystemMail() {
const t = useT();
const queryClient = useQueryClient();
const mail = useQuery({ queryKey: ["adminMail"], queryFn: api.adminMail });
// Edits are a draft until Save; null means "show what the server has".
const [draft, setDraft] = useState<MailDraft | null>(null);
const [password, setPassword] = useState("");
const [testTo, setTestTo] = useState("");
const save = useMutation({
mutationFn: (body: Parameters<typeof api.saveAdminMail>[0]) => api.saveAdminMail(body),
onSuccess: (data) => {
queryClient.setQueryData(["adminMail"], data);
setDraft(null);
setPassword("");
},
});
const test = useMutation({ mutationFn: () => api.testAdminMail(testTo.trim()) });
if (!mail.data) {
return mail.error instanceof ApiError ? (
<p className="text-sm text-red-500">{mail.error.message}</p>
) : null;
}
const server = mail.data;
const value: MailDraft = draft ?? {
smtp_host: server.smtp_host,
smtp_port: server.smtp_port,
smtp_security: server.smtp_security,
smtp_username: server.smtp_username,
smtp_from: server.smtp_from,
public_url: server.public_url,
};
const set = (patch: Partial<MailDraft>) => setDraft({ ...value, ...patch });
const dirty = draft !== null || password !== "";
const field =
"w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none";
const label = "block space-y-1 text-sm";
return (
<section className="space-y-3">
<h2 className="text-lg font-semibold">{t("admin.mail")}</h2>
<p className="text-xs text-app-faint">{t("admin.mailHelp")}</p>
<p className={`text-sm ${server.configured ? "text-emerald-600" : "text-amber-600"}`}>
{server.configured ? t("admin.mailOn") : t("admin.mailOff")}
</p>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
save.mutate({ ...value, ...(password ? { smtp_password: password } : {}) });
}}
>
<div className="grid grid-cols-[1fr_6rem_8rem] gap-2">
<label className={label}>
<span className="text-app-muted">{t("admin.smtpHost")}</span>
<input
className={field}
value={value.smtp_host}
placeholder="smtp.example.com"
onChange={(e) => set({ smtp_host: e.target.value })}
/>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.smtpPort")}</span>
<input
className={field}
type="number"
min="1"
max="65535"
value={value.smtp_port}
onChange={(e) => set({ smtp_port: Number(e.target.value) })}
/>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.smtpSecurity")}</span>
<select
className={field}
value={value.smtp_security}
onChange={(e) => {
const security = e.target.value as MailDraft["smtp_security"];
// Follow the usual port for the mode, unless a custom one was typed.
const usual = { starttls: 587, ssl: 465, none: 25 };
const port = Object.values(usual).includes(value.smtp_port) ? usual[security] : value.smtp_port;
set({ smtp_security: security, smtp_port: port });
}}
>
<option value="starttls">{t("admin.smtpStarttls")}</option>
<option value="ssl">{t("admin.smtpSsl")}</option>
<option value="none">{t("admin.smtpNone")}</option>
</select>
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<label className={label}>
<span className="text-app-muted">{t("admin.smtpUsername")}</span>
<input
className={field}
autoComplete="off"
value={value.smtp_username}
placeholder="postmaster@example.com"
onChange={(e) => set({ smtp_username: e.target.value })}
/>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.smtpPassword")}</span>
<input
className={field}
type="password"
autoComplete="new-password"
value={password}
placeholder={server.has_password ? t("admin.smtpPasswordKept") : ""}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
</div>
<label className={label}>
<span className="text-app-muted">{t("admin.smtpFrom")}</span>
<input
className={field}
type="email"
value={value.smtp_from}
placeholder="postmaster@example.com"
onChange={(e) => set({ smtp_from: e.target.value })}
/>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.publicUrl")}</span>
<input
className={field}
value={value.public_url}
placeholder="https://mail.example.com"
onChange={(e) => set({ public_url: e.target.value })}
/>
<span className="block text-xs text-app-faint">{t("admin.publicUrlHint")}</span>
</label>
<div className="flex items-center justify-between gap-2">
{server.has_password ? (
<button
type="button"
className="text-xs text-app-muted underline hover:text-app-fg disabled:opacity-50"
disabled={save.isPending}
onClick={() => save.mutate({ smtp_password: "" })}
>
{t("admin.smtpPasswordRemove")}
</button>
) : (
<span />
)}
<button
type="submit"
className="rounded bg-app-accent px-3 py-1 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!dirty || save.isPending}
>
{save.isPending ? t("admin.saving") : t("admin.save")}
</button>
</div>
{save.error instanceof ApiError && <p className="text-xs text-red-500">{save.error.message}</p>}
</form>
<form
className="flex items-center gap-2 border-t border-app-border pt-3 text-sm"
onSubmit={(e) => {
e.preventDefault();
test.mutate();
}}
>
<span className="shrink-0 text-app-muted">{t("admin.testTo")}</span>
<input
className={field}
type="email"
value={testTo}
placeholder="you@example.com"
onChange={(e) => {
setTestTo(e.target.value);
test.reset();
}}
/>
<button
type="submit"
className="shrink-0 rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
disabled={!server.configured || !testTo.trim() || test.isPending || dirty}
>
{test.isPending ? t("auth.sending") : t("admin.sendTest")}
</button>
</form>
{test.isSuccess && <p className="text-xs text-emerald-600">{t("admin.testSent")}</p>}
{test.error instanceof ApiError && <p className="text-xs text-red-500">{test.error.message}</p>}
</section>
);
}
function formatBytes(bytes: number): string {
if (bytes >= GIB) return `${Number((bytes / GIB).toFixed(2))} GiB`;
return `${Number((bytes / 1024 ** 2).toFixed(1))} MiB`;
}
/** Find a user by login, mail address or name, and open them to manage. */
function Users({ selfId }: { selfId: string }) {
const t = useT();
const [term, setTerm] = useState("");
const [query, setQuery] = useState("");
const [openId, setOpenId] = useState<string | null>(null);
// Search as you type, but not on every keystroke.
useEffect(() => {
const id = window.setTimeout(() => setQuery(term.trim()), 250);
return () => window.clearTimeout(id);
}, [term]);
// Only search results are listed: nothing until something is typed.
const users = useQuery({
queryKey: ["adminUsers", query],
queryFn: () => api.adminUsers(query),
enabled: query !== "",
placeholderData: keepPreviousData,
});
const results = query ? users.data : undefined;
const open = results?.find((u) => u.id === openId) ?? null;
return (
<section className="mt-10 space-y-3">
<h2 className="text-lg font-semibold">{t("admin.users")}</h2>
<input
className={inputClass}
type="search"
placeholder={t("admin.userSearch")}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
{users.error instanceof ApiError && (
<p className="text-sm text-red-500">{users.error.message}</p>
)}
{results && results.length === 0 && (
<p className="text-sm text-app-muted">{t("admin.noUsers")}</p>
)}
{results && results.length > 0 && (
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
{results.map((u) => (
<li key={u.id}>
<button
type="button"
onClick={() => setOpenId(u.id)}
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-app-hover"
>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate font-medium">{u.handle}</span>
<Badges user={u} self={u.id === selfId} />
</span>
<span className="block truncate text-xs text-app-muted">
{u.accounts.length ? u.accounts.join(", ") : t("admin.noMailAccounts")}
</span>
</span>
<span className="shrink-0 text-xs tabular-nums text-app-muted">
{t("admin.usage", {
used: formatBytes(u.used_bytes),
quota: formatBytes(u.quota_bytes),
})}
</span>
</button>
</li>
))}
</ul>
)}
{open && <UserDialog user={open} self={open.id === selfId} onClose={() => setOpenId(null)} />}
</section>
);
}
function Badges({ user, self }: { user: AdminUser; self: boolean }) {
const t = useT();
const badge = "shrink-0 rounded px-1.5 text-[11px] font-medium";
return (
<>
{self && <span className={`${badge} bg-app-hover text-app-muted`}>{t("admin.you")}</span>}
{user.is_admin && (
<span className={`${badge} bg-app-accent/15 text-app-accent`}>{t("admin.adminBadge")}</span>
)}
{user.blocked_at && (
<span className={`${badge} bg-red-500/15 text-red-500`}>{t("admin.blockedBadge")}</span>
)}
</>
);
}
/** One user: change their quota, block or unblock them, or delete them. */
function UserDialog({
user,
self,
onClose,
}: {
user: AdminUser;
self: boolean;
onClose: () => void;
}) {
const t = useT();
const queryClient = useQueryClient();
const [quotaDraft, setQuotaDraft] = useState<string | null>(null);
const [confirm, setConfirm] = useState<null | "block" | "delete">(null);
const refresh = () => queryClient.invalidateQueries({ queryKey: ["adminUsers"] });
const update = useMutation({
mutationFn: (body: { quota_bytes?: number; blocked?: boolean }) =>
api.updateAdminUser(user.id, body),
onSuccess: () => {
setQuotaDraft(null);
setConfirm(null);
void refresh();
},
});
const remove = useMutation({
mutationFn: () => api.deleteAdminUser(user.id),
onSuccess: () => {
onClose();
void refresh();
},
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (confirm) setConfirm(null);
else onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [confirm, onClose]);
const serverGib = user.quota_bytes / GIB;
const quotaValue = quotaDraft ?? String(Number(serverGib.toFixed(3)));
const quotaGib = Number(quotaValue);
const quotaValid = Number.isFinite(quotaGib) && quotaGib > 0;
const quotaDirty = quotaDraft !== null && quotaGib !== serverGib;
const busy = update.isPending || remove.isPending;
const error = [update.error, remove.error].find((e) => e instanceof ApiError) as
| ApiError
| undefined;
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={user.handle}
className="relative w-full max-w-md rounded-xl border border-app-border bg-app-elevated p-5 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-start gap-3">
<div className="min-w-0 flex-1">
<h3 className="flex items-center gap-1.5 font-semibold">
<span className="truncate">{user.handle}</span>
<Badges user={user} self={self} />
</h3>
<p className="mt-0.5 text-xs text-app-muted">
{user.accounts.length ? user.accounts.join(", ") : t("admin.noMailAccounts")}
</p>
</div>
<button
type="button"
onClick={onClose}
title={t("admin.close")}
aria-label={t("admin.close")}
className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
<div className="border-y border-app-border py-3">
<div className="flex items-center justify-between gap-2 text-sm">
<span className="text-app-muted">{t("admin.userQuota")}</span>
<div className="flex items-center gap-2">
<input
className="w-24 rounded border border-app-border bg-app-surface px-2 py-1 text-right focus:border-app-accent focus:outline-none"
type="number"
min="0.1"
step="0.5"
value={quotaValue}
onChange={(e) => setQuotaDraft(e.target.value)}
/>
<span className="text-app-muted">GiB</span>
<button
className="rounded bg-app-accent px-3 py-1 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!quotaDirty || !quotaValid || busy}
onClick={() => update.mutate({ quota_bytes: Math.round(quotaGib * GIB) })}
>
{update.isPending && quotaDirty ? t("admin.saving") : t("admin.save")}
</button>
</div>
</div>
<p className="mt-1 text-xs text-app-faint">
{t("admin.usage", {
used: formatBytes(user.used_bytes),
quota: formatBytes(user.quota_bytes),
})}
</p>
{!quotaValid && <p className="mt-1 text-xs text-red-500">{t("admin.quotaInvalid")}</p>}
</div>
{user.blocked_at && (
<p className="mt-3 text-sm text-red-500">
{t("admin.blockedSince", { date: new Date(user.blocked_at).toLocaleString() })}
</p>
)}
{self ? (
<p className="mt-3 text-xs text-app-muted">{t("admin.selfNote")}</p>
) : (
<div className="mt-4 flex flex-wrap justify-end gap-2">
{user.blocked_at ? (
<button
className="rounded-lg border border-app-border px-3 py-1.5 text-sm hover:bg-app-hover disabled:opacity-50"
disabled={busy}
onClick={() => update.mutate({ blocked: false })}
>
{t("admin.unblock")}
</button>
) : (
<button
className="rounded-lg border border-app-border px-3 py-1.5 text-sm hover:bg-app-hover disabled:opacity-50"
disabled={busy}
onClick={() => setConfirm("block")}
>
{t("admin.block")}
</button>
)}
<button
className="rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
disabled={busy}
onClick={() => setConfirm("delete")}
>
{t("admin.deleteUser")}
</button>
</div>
)}
{error && <p className="mt-2 text-xs text-red-500">{error.message}</p>}
{confirm && (
<div
className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/40 p-4"
onClick={() => setConfirm(null)}
>
<div
role="alertdialog"
aria-modal="true"
className="w-full max-w-sm rounded-lg border border-app-border bg-app-elevated p-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<p className="mb-4 text-sm">
{t(confirm === "block" ? "admin.blockConfirm" : "admin.deleteConfirm", {
handle: user.handle,
})}
</p>
<div className="flex justify-end gap-2">
<button
className="rounded-md px-3 py-1.5 text-sm text-app-muted hover:bg-app-hover"
onClick={() => setConfirm(null)}
autoFocus
>
{t("admin.cancel")}
</button>
<button
className="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
disabled={busy}
onClick={() =>
confirm === "block" ? update.mutate({ blocked: true }) : remove.mutate()
}
>
{t(confirm === "block" ? "admin.block" : "admin.deleteUser")}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,66 @@
import { useId, useState } from "react";
// Keystrokes and IME composition; everything else that changes the field — a
// browser or password-manager autofill (an input event without an inputType, or
// "insertReplacementText"), a paste, a drop — is not the user typing.
const TYPED = /^(insertText|insertCompositionText|insertLineBreak|delete)/;
/**
* A password field that only accepts what is typed into it: for confirmations
* where the point is that the person present knows the password, which an
* autofilled saved password does not prove.
*
* `autoComplete="new-password"` and the password managers' opt-out attributes
* keep most autofill away to begin with; whatever still gets in is thrown out
* here, and the field says to type it.
*/
export function TypedPasswordInput({
value,
onChange,
className,
hint,
}: {
value: string;
onChange: (value: string) => void;
className?: string;
/** Shown when a fill or paste was refused. */
hint: string;
}) {
const id = useId();
const [refused, setRefused] = useState(false);
return (
<>
<input
className={className}
type="password"
// A fresh name per field, so nothing matches it to a saved login form.
name={`typed-${id}`}
autoComplete="new-password"
data-1p-ignore
data-lpignore="true"
data-bwignore
data-form-type="other"
value={value}
onPaste={(e) => {
e.preventDefault();
setRefused(true);
}}
onDrop={(e) => {
e.preventDefault();
setRefused(true);
}}
onChange={(e) => {
const inputType = (e.nativeEvent as InputEvent).inputType ?? "";
if (!TYPED.test(inputType)) {
onChange("");
setRefused(true);
return;
}
setRefused(false);
onChange(e.target.value);
}}
/>
{refused && <span className="block text-xs text-amber-600">{hint}</span>}
</>
);
}
+72 -2
View File
@@ -3,7 +3,7 @@
// Interpolate with {name} placeholders. // Interpolate with {name} placeholders.
export const en = { export const en = {
"common.appName": "Webmail", "common.appName": "Ultimo Email",
"common.loading": "Loading…", "common.loading": "Loading…",
"common.cancel": "Cancel", "common.cancel": "Cancel",
"common.save": "Save", "common.save": "Save",
@@ -34,6 +34,26 @@ export const en = {
"auth.create": "Create account", "auth.create": "Create account",
"auth.creating": "Creating…", "auth.creating": "Creating…",
"auth.noAccount": "No account?", "auth.noAccount": "No account?",
"auth.forgotPassword": "Forgot password?",
"auth.forgotTitle": "Reset your password",
"auth.forgotHint":
"Enter your user name. We'll email a link for choosing a new password to the recovery email address of the account.",
"auth.changePassword": "Change password",
"auth.sending": "Sending…",
"auth.forgotSent": "If this account exists you will receive an email to your recovery email address.",
"auth.backToSignIn": "Back to sign in",
"auth.resetTitle": "Choose a new password",
"auth.newPassword": "New password",
"auth.repeatPassword": "Repeat the new password",
"auth.passwordsDiffer": "The two passwords are different.",
"auth.passwordTooShort": "Use at least 8 characters.",
"auth.resetTotpPrompt":
"This account uses 2-step verification. Enter the 6-digit code from your authenticator app to finish.",
"auth.confirm": "Confirm",
"auth.resetDone": "Your password has been changed. You have been signed out everywhere — sign in with the new password.",
"auth.resetInvalid":
"This link is invalid, was already used, or has expired. Request a new one to change your password.",
"auth.requestNewLink": "Request a new link",
"auth.haveAccount": "Already have an account?", "auth.haveAccount": "Already have an account?",
"auth.register": "Register", "auth.register": "Register",
"auth.checking": "Checking…", "auth.checking": "Checking…",
@@ -327,6 +347,15 @@ export const en = {
"settings.appearance": "Appearance", "settings.appearance": "Appearance",
"settings.handle": "Handle", "settings.handle": "Handle",
"settings.recoveryEmail": "Recovery email", "settings.recoveryEmail": "Recovery email",
"settings.recoveryEmailHint":
"If you forget your password, a link to choose a new one is sent here.",
"settings.recoveryEmailNew": "Recovery email address",
"settings.recoveryEmailRemoveHint": "Leave empty to remove it.",
"settings.currentPassword": "Current password",
"settings.typePassword": "Type your password — for this confirmation it can't be filled in or pasted.",
"settings.change": "Change",
"settings.add": "Add",
"settings.saving": "Saving…",
"settings.twoFactor": "Two-factor authentication", "settings.twoFactor": "Two-factor authentication",
"settings.twoFactorOn": "2FA is enabled.", "settings.twoFactorOn": "2FA is enabled.",
"settings.twoFactorOff": "2FA is not enabled.", "settings.twoFactorOff": "2FA is not enabled.",
@@ -412,7 +441,7 @@ export const en = {
"settings.alsoFile": "Also mark the message as read and file it", "settings.alsoFile": "Also mark the message as read and file it",
"settings.integrations": "Integrations", "settings.integrations": "Integrations",
"admin.title": "Webmail administration", "admin.title": "Ultimo Email administration",
"admin.signInTitle": "Administrator sign-in", "admin.signInTitle": "Administrator sign-in",
"admin.signInHint": "Sign in with an administrator account to manage this server.", "admin.signInHint": "Sign in with an administrator account to manage this server.",
"admin.signOut": "Sign out", "admin.signOut": "Sign out",
@@ -432,6 +461,47 @@ export const en = {
"admin.saving": "Saving…", "admin.saving": "Saving…",
"admin.saved": "Saved", "admin.saved": "Saved",
"admin.quotaInvalid": "Enter a size greater than 0.", "admin.quotaInvalid": "Enter a size greater than 0.",
"admin.mail": "Outgoing system mail",
"admin.mailHelp":
"The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.",
"admin.mailOn": "Set up — password-reset links can be sent.",
"admin.mailOff": "Not set up — password-reset links cannot be sent yet.",
"admin.smtpHost": "SMTP server",
"admin.smtpPort": "Port",
"admin.smtpSecurity": "Security",
"admin.smtpStarttls": "STARTTLS",
"admin.smtpSsl": "SSL/TLS",
"admin.smtpNone": "None",
"admin.smtpUsername": "Login",
"admin.smtpPassword": "Password",
"admin.smtpPasswordKept": "(stored — leave empty to keep)",
"admin.smtpPasswordRemove": "Remove the stored password",
"admin.smtpFrom": "From address",
"admin.publicUrl": "Public address of this server",
"admin.publicUrlHint": "Where users open Ultimo Email, e.g. https://mail.example.com — reset links point here.",
"admin.testTo": "Send a test email to",
"admin.sendTest": "Send test",
"admin.testSent": "Test email sent.",
"admin.users": "Users",
"admin.userSearch": "Search by login, email address or name…",
"admin.noUsers": "No users match.",
"admin.you": "you",
"admin.adminBadge": "admin",
"admin.blockedBadge": "blocked",
"admin.usage": "{used} of {quota}",
"admin.noMailAccounts": "No mail accounts connected",
"admin.userQuota": "Quota",
"admin.blockedSince": "Blocked since {date}. This user cannot sign in.",
"admin.block": "Block account",
"admin.unblock": "Unblock account",
"admin.blockConfirm":
"Block {handle}? They are signed out everywhere and cannot sign in until you unblock them. Their mail and data are kept.",
"admin.deleteUser": "Delete account",
"admin.deleteConfirm":
"Permanently delete {handle} and all of their mail, contacts, calendars and settings? This can't be undone.",
"admin.selfNote": "This is your own account: it cannot be blocked or deleted from here.",
"admin.cancel": "Cancel",
"admin.close": "Close",
"settings.jitsi": "Jitsi Meet", "settings.jitsi": "Jitsi Meet",
"settings.jitsiHint": "settings.jitsiHint":
"Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.", "Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.",
+34
View File
@@ -3,7 +3,9 @@
import type { import type {
AccountDetail, AccountDetail,
AdminMail,
AdminSettings, AdminSettings,
AdminUser,
AccountOut, AccountOut,
AttachmentOut, AttachmentOut,
CalendarOut, CalendarOut,
@@ -83,6 +85,38 @@ export const api = {
// PUT merges: send only the fields you are changing. // PUT merges: send only the fields you are changing.
saveAdminSettings: (body: Partial<AdminSettings>) => saveAdminSettings: (body: Partial<AdminSettings>) =>
apiFetch<AdminSettings>("/admin/settings", { method: "PUT", body: JSON.stringify(body) }), apiFetch<AdminSettings>("/admin/settings", { method: "PUT", body: JSON.stringify(body) }),
adminUsers: (q: string) => apiFetch<AdminUser[]>(`/admin/users?q=${encodeURIComponent(q)}`),
// PATCH merges: send only the fields you are changing.
updateAdminUser: (id: string, body: { quota_bytes?: number; blocked?: boolean }) =>
apiFetch<AdminUser>(`/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteAdminUser: (id: string) =>
apiFetch<{ status: string }>(`/admin/users/${id}`, { method: "DELETE" }),
adminMail: () => apiFetch<AdminMail>("/admin/mail"),
// PUT merges: omit smtp_password to keep the stored one, "" to remove it.
saveAdminMail: (body: Partial<Omit<AdminMail, "has_password" | "configured">> & { smtp_password?: string }) =>
apiFetch<AdminMail>("/admin/mail", { method: "PUT", body: JSON.stringify(body) }),
testAdminMail: (to: string) =>
apiFetch<{ status: string }>("/admin/mail/test", { method: "POST", body: JSON.stringify({ to }) }),
passwordResetRequest: (handle: string) =>
apiFetch<{ status: string }>("/auth/password/reset-request", {
method: "POST",
body: JSON.stringify({ handle }),
}),
passwordResetCheck: (token: string) =>
apiFetch<{ valid: boolean; totp_required: boolean }>("/auth/password/reset/check", {
method: "POST",
body: JSON.stringify({ token }),
}),
passwordReset: (token: string, password: string, totp?: string) =>
apiFetch<{ status: string }>("/auth/password/reset", {
method: "POST",
body: JSON.stringify({ token, password, totp }),
}),
setRecoveryEmail: (password: string, recovery_email: string | null) =>
apiFetch<UserOut>("/auth/recovery-email", {
method: "PUT",
body: JSON.stringify({ password, recovery_email }),
}),
registrationStatus: () => apiFetch<RegistrationStatus>("/auth/registration"), registrationStatus: () => apiFetch<RegistrationStatus>("/auth/registration"),
register: (localpart: string, password: string) => register: (localpart: string, password: string) =>
apiFetch<UserOut>("/auth/register", { apiFetch<UserOut>("/auth/register", {
+19 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { type QueryClient, useQuery } from "@tanstack/react-query";
import { api, ApiError } from "./api"; import { api, ApiError } from "./api";
@@ -13,3 +13,21 @@ export function useMe() {
}, },
}); });
} }
/**
* The server no longer knows this session — signed out elsewhere, or an
* administrator blocked or deleted the account. Drop everything cached and let
* the auth guard send the tab to the sign-in screen.
*/
export function signedOut(queryClient: QueryClient) {
if (!queryClient.getQueryData(["me"])) return; // already signed out
// "me" first, in place: its watchers see it and route to sign-in. Clearing the
// whole cache first would orphan them from the query they watch.
queryClient.setQueryData(["me"], null);
queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" });
}
/** Whether an error means "this session is gone" (vs. a wrong password, etc.). */
export function isSessionGone(error: unknown): boolean {
return error instanceof ApiError && error.status === 401 && error.code === "not_authenticated";
}
+25
View File
@@ -15,6 +15,31 @@ export interface UserOut {
is_admin: boolean; is_admin: boolean;
} }
export interface AdminUser {
id: string;
handle: string;
is_admin: boolean;
/** ISO timestamp while an administrator has blocked the account. */
blocked_at: string | null;
quota_bytes: number;
used_bytes: number;
created_at: string;
/** The user's connected mail accounts. */
accounts: string[];
}
/** Outgoing system mail (password-reset links). The password is never sent back. */
export interface AdminMail {
smtp_host: string;
smtp_port: number;
smtp_security: "starttls" | "ssl" | "none";
smtp_username: string;
has_password: boolean;
smtp_from: string;
public_url: string;
configured: boolean;
}
export interface AdminSettings { export interface AdminSettings {
registration_open: boolean; registration_open: boolean;
/** Quota given to users created from now on; existing mailboxes keep theirs. */ /** Quota given to users created from now on; existing mailboxes keep theirs. */
+9
View File
@@ -4,6 +4,8 @@
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { z } from "zod"; import { z } from "zod";
import { signedOut } from "./auth";
const frameSchema = z.object({ const frameSchema = z.object({
event: z.string(), event: z.string(),
payload: z.record(z.unknown()), payload: z.record(z.unknown()),
@@ -20,6 +22,7 @@ const ALLOWED_EVENTS = new Set([
"account.state_changed", "account.state_changed",
"draft.saved", "draft.saved",
"outbox.state_changed", "outbox.state_changed",
"user.signed_out",
]); ]);
// Lightweight in-process fan-out of WS frames to plugin subscribers (SPEC §11.3). // Lightweight in-process fan-out of WS frames to plugin subscribers (SPEC §11.3).
@@ -82,6 +85,12 @@ export function connectWs(queryClient: QueryClient): () => void {
} }
} }
if (!isCore) return; if (!isCore) return;
if (name === "user.signed_out") {
// An administrator blocked or deleted this account: leave now, rather
// than keep showing mail that was already loaded.
signedOut(queryClient);
return;
}
// Coarse invalidation: refetch affected lists (SPEC §9.2 contract). // Coarse invalidation: refetch affected lists (SPEC §9.2 contract).
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") { if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
+10 -1
View File
@@ -1,15 +1,24 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { App } from "./App"; import { App } from "./App";
import { I18nProvider } from "./i18n"; import { I18nProvider } from "./i18n";
import { isSessionGone, signedOut } from "./lib/auth";
import { ThemeProvider } from "./lib/theme"; import { ThemeProvider } from "./lib/theme";
import "./index.css"; import "./index.css";
import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3) import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3)
// Any request answered "not signed in" means the session is gone (signed out
// elsewhere, or the account was blocked): go to sign-in instead of leaving the
// page up with failing requests behind it.
const onError = (error: unknown) => {
if (isSessionGone(error)) signedOut(queryClient);
};
const queryClient = new QueryClient({ const queryClient = new QueryClient({
queryCache: new QueryCache({ onError }),
mutationCache: new MutationCache({ onError }),
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
}); });
+61
View File
@@ -0,0 +1,61 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Link } from "react-router-dom";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
const inputClass =
"w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none";
// "Forgot password?": ask for the user name and have a reset link mailed to the
// account's recovery address. The answer is the same whether or not the account
// exists — the page never tells a stranger which user names are real.
export function ForgotPassword() {
const t = useT();
const [handle, setHandle] = useState("");
const request = useMutation({ mutationFn: () => api.passwordResetRequest(handle.trim()) });
return (
<div className="mx-auto mt-24 max-w-sm px-4">
<h1 className="mb-2 text-2xl font-semibold">{t("auth.forgotTitle")}</h1>
{request.isSuccess ? (
<p className="text-sm text-app-fg">{t("auth.forgotSent")}</p>
) : (
<>
<p className="mb-6 text-sm text-app-muted">{t("auth.forgotHint")}</p>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
if (handle.trim()) request.mutate();
}}
>
<input
className={inputClass}
placeholder={t("auth.handlePlaceholder")}
value={handle}
onChange={(e) => setHandle(e.target.value)}
autoFocus
/>
<button
className="w-full rounded-lg bg-app-accent py-2 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={request.isPending || !handle.trim()}
type="submit"
>
{request.isPending ? t("auth.sending") : t("auth.changePassword")}
</button>
{request.error instanceof ApiError && (
<p className="text-sm text-red-500">{request.error.message}</p>
)}
</form>
</>
)}
<p className="mt-4 text-sm">
<Link className="text-app-accent" to="/login">
{t("auth.backToSignIn")}
</Link>
</p>
</div>
);
}
+5
View File
@@ -82,6 +82,11 @@ export function Login() {
</button> </button>
{showError && <p className="text-sm text-red-500">{error.message}</p>} {showError && <p className="text-sm text-red-500">{error.message}</p>}
</form> </form>
<p className="mt-4 text-sm">
<Link className="text-app-accent" to="/forgot-password">
{t("auth.forgotPassword")}
</Link>
</p>
{registration.data?.open && ( {registration.data?.open && (
<p className="mt-4 text-sm text-app-muted"> <p className="mt-4 text-sm text-app-muted">
{t("auth.noAccount")}{" "} {t("auth.noAccount")}{" "}
+130
View File
@@ -0,0 +1,130 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Link } from "react-router-dom";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
const inputClass =
"w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none";
// The page a reset link opens. The token rides in the URL fragment (#token=…),
// which the browser never sends to a server. With 2-step verification on, the
// new password is only set once the code has been entered too.
export function ResetPassword() {
const t = useT();
const [token] = useState(() => new URLSearchParams(window.location.hash.slice(1)).get("token") ?? "");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const [totp, setTotp] = useState("");
const [needTotp, setNeedTotp] = useState(false);
const [problem, setProblem] = useState("");
const check = useQuery({
queryKey: ["reset-check", token],
queryFn: () => api.passwordResetCheck(token),
enabled: token !== "",
});
const reset = useMutation({
mutationFn: () => api.passwordReset(token, password, needTotp ? totp.trim() : undefined),
onError: (error) => {
if (error instanceof ApiError && (error.code === "totp_required" || error.code === "totp_invalid")) {
setNeedTotp(true);
}
},
});
const invalid =
token === "" ||
check.data?.valid === false ||
(reset.error instanceof ApiError && reset.error.code === "invalid_token");
const submit = () => {
setProblem("");
if (password.length < 8) return setProblem(t("auth.passwordTooShort"));
if (password !== repeat) return setProblem(t("auth.passwordsDiffer"));
// Known up front: ask for the code before the first submit, not after it fails.
if (check.data?.totp_required && !needTotp) return setNeedTotp(true);
reset.mutate();
};
const error = reset.error instanceof ApiError ? reset.error : null;
const showError =
error && error.code !== "invalid_token" && !(needTotp && error.code === "totp_required");
return (
<div className="mx-auto mt-24 max-w-sm px-4">
<h1 className="mb-6 text-2xl font-semibold">{t("auth.resetTitle")}</h1>
{reset.isSuccess ? (
<p className="text-sm text-app-fg">{t("auth.resetDone")}</p>
) : invalid ? (
<>
<p className="text-sm text-app-fg">{t("auth.resetInvalid")}</p>
<p className="mt-4 text-sm">
<Link className="text-app-accent" to="/forgot-password">
{t("auth.requestNewLink")}
</Link>
</p>
</>
) : check.isPending ? (
<p className="text-sm text-app-muted">{t("common.loading")}</p>
) : (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<input
className={inputClass}
type="password"
autoComplete="new-password"
placeholder={t("auth.newPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={needTotp}
autoFocus
/>
<input
className={inputClass}
type="password"
autoComplete="new-password"
placeholder={t("auth.repeatPassword")}
value={repeat}
onChange={(e) => setRepeat(e.target.value)}
disabled={needTotp}
/>
{needTotp && (
<div className="space-y-1">
<p className="text-xs text-app-muted">{t("auth.resetTotpPrompt")}</p>
<input
className={`${inputClass} tracking-widest`}
placeholder="123456"
inputMode="numeric"
autoComplete="one-time-code"
value={totp}
onChange={(e) => setTotp(e.target.value)}
autoFocus
/>
</div>
)}
<button
className="w-full rounded-lg bg-app-accent py-2 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={reset.isPending || (needTotp && !totp.trim())}
type="submit"
>
{needTotp ? t("auth.confirm") : t("auth.changePassword")}
</button>
{problem && <p className="text-sm text-red-500">{problem}</p>}
{showError && <p className="text-sm text-red-500">{error.message}</p>}
</form>
)}
<p className="mt-4 text-sm">
<Link className="text-app-accent" to="/login">
{t("auth.backToSignIn")}
</Link>
</p>
</div>
);
}
+94 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { RichTextEditor } from "../../components/compose/RichTextEditor"; import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import type { CalendarOut, ReminderPref } from "../../lib/types"; import type { CalendarOut, ReminderPref } from "../../lib/types";
@@ -105,11 +106,103 @@ function ProfileSection() {
<section className="max-w-4xl space-y-3"> <section className="max-w-4xl space-y-3">
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2> <h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
<Row label={t("settings.handle")} value={data?.handle ?? ""} /> <Row label={t("settings.handle")} value={data?.handle ?? ""} />
<Row label={t("settings.recoveryEmail")} value={data?.recovery_email ?? "—"} /> <RecoveryEmailRow current={data?.recovery_email ?? null} />
</section> </section>
); );
} }
// Whoever controls the recovery address can take the account over with a reset
// link, so adding, changing or removing it asks for the current password.
function RecoveryEmailRow({ current }: { current: string | null }) {
const t = useT();
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const save = useMutation({
mutationFn: () => api.setRecoveryEmail(password, email.trim() || null),
onSuccess: (user) => {
queryClient.setQueryData(["me"], user);
setEditing(false);
setPassword("");
},
});
if (!editing) {
return (
<div className="border-b border-app-border py-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-app-muted">{t("settings.recoveryEmail")}</span>
<span className="flex items-center gap-3">
<span>{current ?? "—"}</span>
<button
className="rounded border border-app-border px-2.5 py-1 text-app-fg hover:bg-app-hover"
onClick={() => {
setEmail(current ?? "");
setPassword("");
save.reset();
setEditing(true);
}}
>
{current ? t("settings.change") : t("settings.add")}
</button>
</span>
</div>
<p className="mt-1 text-xs text-app-faint">{t("settings.recoveryEmailHint")}</p>
</div>
);
}
const field =
"w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none";
return (
<form
className="space-y-2 rounded-lg border border-app-border p-3 text-sm"
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
>
<label className="block space-y-1">
<span className="text-app-muted">{t("settings.recoveryEmailNew")}</span>
<input
className={field}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
autoFocus
/>
{current && (
<span className="block text-xs text-app-faint">{t("settings.recoveryEmailRemoveHint")}</span>
)}
</label>
<label className="block space-y-1">
<span className="text-app-muted">{t("settings.currentPassword")}</span>
<TypedPasswordInput
className={field}
value={password}
onChange={setPassword}
hint={t("settings.typePassword")}
/>
</label>
{save.error instanceof ApiError && <p className="text-red-500">{save.error.message}</p>}
<div className="flex justify-end gap-2">
<button type="button" className="px-2 text-app-muted" onClick={() => setEditing(false)}>
{t("common.cancel")}
</button>
<button
type="submit"
className="rounded bg-app-accent px-3 py-1 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={save.isPending || !password}
>
{save.isPending ? t("settings.saving") : t("common.save")}
</button>
</div>
</form>
);
}
function Row({ label, value }: { label: string; value: string }) { function Row({ label, value }: { label: string; value: string }) {
return ( return (
<div className="flex justify-between border-b border-app-border py-2 text-sm"> <div className="flex justify-between border-b border-app-border py-2 text-sm">