Admin user management, password reset by email, and the Ultimo Email name
Branding: the page titles and app name are now "Ultimo Email". Admin panel (now two columns with a divider): - Users: search by login or by a mail account's address or name (results only, nothing listed until something is typed). A user opens a window to change their quota, block/unblock them, or delete them; block and delete ask for confirmation, and an admin cannot do either to their own account. - Blocking (users.blocked_at, migration 0019) refuses sign-in with a clear message and revokes every session; a new user.signed_out event sends the user's open tabs to the sign-in screen at once. Any "not signed in" answer now does the same, not only /auth/me. Deleting removes mail accounts (sync stops), plugin rows (cleared inside the user's RLS context, since plugin tables have no FK to users), blobs, the search index, sessions and the user. - Outgoing system mail (migration 0020): SMTP server, port, security, login, password (vault-encrypted, never sent back), From address and the public address reset links point to, plus a "Send test" that reports the server's error. Password reset: - "Forgot password?" on the sign-in page asks for the user name and always answers "If this account exists you will receive an email to your recovery email address"; the mail goes out after the response, so neither the reply nor its timing reveals an account, and it is capped at 3 per hour. - The emailed link (token in the URL fragment, single use, 30 minutes) opens a page to set a new password. With 2-step verification on, the code is checked before anything changes; five wrong codes burn the link. - Settings > Profile: the recovery email can be added, changed or removed, confirmed with the current password, which must be typed — autofill and paste are refused (TypedPasswordInput). Also adds frontend/icons/ultimo-logo-bimi.svg, an SVG Tiny PS version of the logo for a BIMI record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+154
-7
@@ -1,24 +1,40 @@
|
||||
"""Instance administration (SPEC §6.1).
|
||||
|
||||
The backend seam for the admin panel. Today it carries only the registration
|
||||
gate — the first account on a fresh install becomes the administrator and closes
|
||||
signup behind itself, and this is how that gets reopened. Further instance-wide
|
||||
options land here as the panel grows.
|
||||
The backend seam for the admin panel: the registration gate — the first account
|
||||
on a fresh install becomes the administrator and closes signup behind itself,
|
||||
and this is how that gets reopened — the default quota, and managing users
|
||||
(search, per-user quota, block, delete). An administrator cannot block or delete
|
||||
their own account, so the instance can never lock out its last admin that way.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentAdmin, require_csrf
|
||||
from app.identity.schemas import AdminSettingsIn, AdminSettingsOut
|
||||
from app.identity.mailer import MailerError, mailer_config, send_mail
|
||||
from app.identity.schemas import (
|
||||
AdminMailIn,
|
||||
AdminMailOut,
|
||||
AdminMailTestIn,
|
||||
AdminSettingsIn,
|
||||
AdminSettingsOut,
|
||||
AdminUserOut,
|
||||
AdminUserPatch,
|
||||
MessageOut,
|
||||
)
|
||||
from app.identity.service import app_settings
|
||||
from app.models import AppSettings
|
||||
from app.identity.user_admin import delete_user, search_users, set_blocked
|
||||
from app.models import Account, AppSettings, Message, User
|
||||
from app.security.crypto import encrypt_secret
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
@@ -54,3 +70,134 @@ async def write_settings(
|
||||
row.updated_at = dt.datetime.now(dt.UTC)
|
||||
await db.commit()
|
||||
return _out(row)
|
||||
|
||||
|
||||
# --- Outgoing system mail -------------------------------------------------------
|
||||
|
||||
|
||||
def _mail_out(row: AppSettings) -> AdminMailOut:
|
||||
return AdminMailOut(
|
||||
smtp_host=row.smtp_host,
|
||||
smtp_port=row.smtp_port,
|
||||
smtp_security=row.smtp_security,
|
||||
smtp_username=row.smtp_username,
|
||||
has_password=row.smtp_secret_ct is not None,
|
||||
smtp_from=row.smtp_from,
|
||||
public_url=row.public_url,
|
||||
configured=bool(row.smtp_host and row.smtp_from and row.public_url),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mail", response_model=AdminMailOut)
|
||||
async def read_mail(admin: CurrentAdmin, db: DbSession) -> AdminMailOut:
|
||||
return _mail_out(await app_settings(db))
|
||||
|
||||
|
||||
@router.put("/mail", response_model=AdminMailOut, dependencies=[Depends(require_csrf)])
|
||||
async def write_mail(body: AdminMailIn, admin: CurrentAdmin, db: DbSession) -> AdminMailOut:
|
||||
"""Merge the supplied SMTP fields. The password is stored vault-encrypted."""
|
||||
row = await app_settings(db, for_update=True)
|
||||
for name in ("smtp_host", "smtp_username", "smtp_from"):
|
||||
value = getattr(body, name)
|
||||
if value is not None:
|
||||
setattr(row, name, value.strip())
|
||||
if body.smtp_port is not None:
|
||||
row.smtp_port = body.smtp_port
|
||||
if body.smtp_security is not None:
|
||||
row.smtp_security = body.smtp_security
|
||||
if body.smtp_password is not None:
|
||||
row.smtp_secret_ct = (
|
||||
encrypt_secret(body.smtp_password.encode()) if body.smtp_password else None
|
||||
)
|
||||
if body.public_url is not None:
|
||||
url = body.public_url.strip().rstrip("/")
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
raise_api_error(400, "invalid_url", "the address must start with http:// or https://")
|
||||
row.public_url = url
|
||||
row.updated_at = dt.datetime.now(dt.UTC)
|
||||
await db.commit()
|
||||
return _mail_out(row)
|
||||
|
||||
|
||||
@router.post("/mail/test", response_model=MessageOut, dependencies=[Depends(require_csrf)])
|
||||
async def test_mail(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -> MessageOut:
|
||||
"""Send a test message with the saved settings, reporting the server's error."""
|
||||
cfg = mailer_config(await app_settings(db))
|
||||
if cfg is None:
|
||||
raise_api_error(
|
||||
409, "mail_not_configured", "set the SMTP server, From address and public address first"
|
||||
)
|
||||
text = (
|
||||
"This is a test message from Ultimo Email.\n\n"
|
||||
"Outgoing system mail works: password-reset links will be sent from this account.\n"
|
||||
)
|
||||
html = f"<p>{text.replace(chr(10) * 2, '</p><p>').strip()}</p>"
|
||||
try:
|
||||
await send_mail(cfg, body.to.strip(), "Ultimo Email test message", text, html)
|
||||
except MailerError as exc:
|
||||
raise_api_error(502, "mail_failed", f"sending failed: {exc}")
|
||||
return MessageOut()
|
||||
|
||||
|
||||
# --- Users --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _user_out(user: User, used_bytes: int, accounts: list[str]) -> AdminUserOut:
|
||||
return AdminUserOut(
|
||||
id=user.id,
|
||||
handle=user.handle,
|
||||
is_admin=user.is_admin,
|
||||
blocked_at=user.blocked_at.isoformat() if user.blocked_at else None,
|
||||
quota_bytes=user.quota_bytes,
|
||||
used_bytes=used_bytes,
|
||||
created_at=user.created_at.isoformat(),
|
||||
accounts=accounts,
|
||||
)
|
||||
|
||||
|
||||
async def _target(db: AsyncSession, admin: User, user_id: uuid.UUID) -> User:
|
||||
"""The user an admin action is aimed at; never the admin themselves."""
|
||||
user = await db.scalar(select(User).where(User.id == user_id))
|
||||
if user is None:
|
||||
raise_api_error(404, "not_found", "user not found")
|
||||
if user.id == admin.id:
|
||||
raise_api_error(409, "cannot_modify_self", "you cannot do this to your own account")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserOut])
|
||||
async def list_users(
|
||||
admin: CurrentAdmin, db: DbSession, q: str = Query("", max_length=200)
|
||||
) -> list[AdminUserOut]:
|
||||
"""Search users by login, or by a mail account's address or display name."""
|
||||
return [_user_out(u, used, emails) for u, used, emails in await search_users(db, q)]
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=AdminUserOut, dependencies=[Depends(require_csrf)])
|
||||
async def patch_user(
|
||||
user_id: uuid.UUID, body: AdminUserPatch, admin: CurrentAdmin, db: DbSession
|
||||
) -> AdminUserOut:
|
||||
"""Change a user's quota and/or block or unblock them."""
|
||||
user = await _target(db, admin, user_id)
|
||||
if body.quota_bytes is not None:
|
||||
user.quota_bytes = body.quota_bytes
|
||||
if body.blocked is not None:
|
||||
await set_blocked(db, user, body.blocked)
|
||||
await db.commit()
|
||||
used = await db.scalar(
|
||||
select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id)
|
||||
)
|
||||
emails = list(
|
||||
await db.scalars(
|
||||
select(Account.email).where(Account.user_id == user.id).order_by(Account.email)
|
||||
)
|
||||
)
|
||||
return _user_out(user, int(used or 0), emails)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", dependencies=[Depends(require_csrf)])
|
||||
async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) -> dict[str, str]:
|
||||
"""Delete a user and all of their data. Cannot be undone."""
|
||||
user = await _target(db, admin, user_id)
|
||||
await delete_user(db, user)
|
||||
return {"status": "deleted"}
|
||||
|
||||
@@ -41,6 +41,9 @@ ALLOWED_EVENTS: frozenset[str] = frozenset(
|
||||
# Sync v2 §3: published on login and every WS connect; drives tier
|
||||
# promotion in the sync workers (§5). Payload: {user_id}.
|
||||
"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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -30,8 +30,9 @@ async def current_user(
|
||||
) -> User:
|
||||
"""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))
|
||||
if user is None:
|
||||
# Session references a deleted user; treat as unauthenticated.
|
||||
if user is None or user.blocked_at is not None:
|
||||
# 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")
|
||||
return user
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,43 +1,76 @@
|
||||
"""Password reset tokens (SPEC §6.1).
|
||||
|
||||
Single-use, 30-minute, Redis-backed tokens. Only issued when the account has a
|
||||
recovery email on file. Actual delivery of the token to the recovery address
|
||||
requires an app-level mailer (not part of M0/M1); until then the token is logged
|
||||
server-side. See docs/open-questions.md.
|
||||
Single-use, 30-minute, Redis-backed tokens, emailed as a link to the account's
|
||||
recovery address through the system mailer (:mod:`app.identity.mailer`).
|
||||
|
||||
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
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from app.redis_client import aw, get_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PREFIX = "pwreset:"
|
||||
_FAILS = "pwreset-fail:"
|
||||
_RATE = "pwreset-rate:"
|
||||
_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."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""Atomically consume a reset token, returning the user_id (or None)."""
|
||||
if not token:
|
||||
return None
|
||||
redis = get_redis()
|
||||
raw = await aw(redis.getdel(_PREFIX + token)) # single-use: fetch and delete atomically
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return uuid.UUID(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
await redis.delete(_FAILS + token)
|
||||
return _user_id(raw)
|
||||
|
||||
|
||||
async def record_totp_failure(token: str) -> 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)
|
||||
|
||||
@@ -3,9 +3,11 @@ TOTP setup/enable, session list/revoke, password reset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
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.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -20,13 +22,23 @@ from app.identity.cookies import (
|
||||
set_auth_cookies,
|
||||
)
|
||||
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.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 (
|
||||
LoginRequest,
|
||||
MessageOut,
|
||||
PasswordResetCheckIn,
|
||||
PasswordResetCheckOut,
|
||||
PasswordResetIn,
|
||||
PasswordResetRequestIn,
|
||||
RecoveryEmailIn,
|
||||
RegisterRequest,
|
||||
RegistrationStatusOut,
|
||||
SessionOut,
|
||||
@@ -36,10 +48,12 @@ from app.identity.schemas import (
|
||||
UserOut,
|
||||
)
|
||||
from app.identity.service import (
|
||||
AccountBlocked,
|
||||
RateLimited,
|
||||
RegistrationError,
|
||||
TotpInvalid,
|
||||
TotpRequired,
|
||||
app_settings,
|
||||
authenticate,
|
||||
register_user,
|
||||
registration_status,
|
||||
@@ -56,8 +70,14 @@ from app.models import User
|
||||
from app.redis_client import get_redis
|
||||
from app.sync import leases
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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)]
|
||||
|
||||
|
||||
@@ -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")
|
||||
except TotpInvalid:
|
||||
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:
|
||||
raise_api_error(401, "invalid_credentials", "incorrect handle or password")
|
||||
await _establish_session(request, response, user)
|
||||
@@ -209,25 +233,86 @@ async def revoke(sid: str, user: CurrentUser) -> MessageOut:
|
||||
# --- 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)
|
||||
async def password_reset_request(body: PasswordResetRequestIn, db: DbSession) -> MessageOut:
|
||||
# Always return ok (do not leak whether the handle exists / has recovery mail).
|
||||
async def password_reset_request(
|
||||
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)
|
||||
user = await db.scalar(select(User).where(User.handle == handle))
|
||||
if user is not None and user.recovery_email:
|
||||
await issue_token(user.id, user.recovery_email)
|
||||
if user is None or not user.recovery_email:
|
||||
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)
|
||||
async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
|
||||
user_id = await consume_token(body.token)
|
||||
if user_id is None:
|
||||
raise_api_error(400, "invalid_token", "reset token is invalid or expired")
|
||||
user = await db.get(User, user_id)
|
||||
"""Set a new password with a reset link. With 2FA on, the code is checked
|
||||
before anything changes; a missing or wrong code leaves the link usable
|
||||
(up to MAX_TOTP_FAILURES wrong codes)."""
|
||||
user_id = await peek_token(body.token)
|
||||
user = await db.get(User, user_id) if user_id else 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)
|
||||
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()
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -47,6 +48,23 @@ class PasswordResetRequestIn(BaseModel):
|
||||
class PasswordResetIn(BaseModel):
|
||||
token: str = Field(min_length=1)
|
||||
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):
|
||||
@@ -79,5 +97,55 @@ class AdminSettingsIn(BaseModel):
|
||||
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):
|
||||
status: str = "ok"
|
||||
|
||||
@@ -53,6 +53,10 @@ class TotpInvalid(Exception):
|
||||
"""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:
|
||||
"""Normalize a login identifier to the full ``localpart@APP_DOMAIN`` form."""
|
||||
text = handle_input.strip().lower()
|
||||
@@ -164,6 +168,9 @@ async def authenticate(
|
||||
ok = verify_password(stored_hash, password)
|
||||
if user is None or not ok:
|
||||
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:
|
||||
from app.identity.totp import decrypt_totp_secret
|
||||
|
||||
@@ -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)
|
||||
@@ -69,6 +69,19 @@ class AppSettings(Base):
|
||||
default_quota_bytes: Mapped[int] = mapped_column(
|
||||
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()
|
||||
|
||||
|
||||
@@ -91,6 +104,9 @@ class User(Base):
|
||||
# The first account created on a fresh install bootstraps as administrator;
|
||||
# the admin panel can grant it to others later.
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ async def ws_endpoint(websocket: WebSocket) -> None:
|
||||
payload = data.get("payload", {})
|
||||
if str(payload.get("user_id")) == user_id:
|
||||
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:
|
||||
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}")
|
||||
@@ -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
|
||||
@@ -1,4 +1,5 @@
|
||||
"""M1 identity: 2FA (TOTP), session list/revoke, password reset (SPEC §6.1/§9.1)."""
|
||||
"""M1 identity: 2FA (TOTP), session list/revoke (SPEC §6.1/§9.1). The password reset
|
||||
flow is in test_password_reset.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -19,7 +20,6 @@ from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session
|
||||
from app.main import create_app
|
||||
from app.models import User
|
||||
from app.redis_client import get_redis
|
||||
|
||||
CSRF = "csrf-token"
|
||||
PASSWORD = "hunter2pass"
|
||||
@@ -145,43 +145,6 @@ async def test_sessions_list_and_revoke(ctx: Ctx) -> None:
|
||||
assert me.status_code == 401
|
||||
|
||||
|
||||
async def test_password_reset_flow(ctx: Ctx) -> None:
|
||||
# request a reset; token is stored in redis (not returned)
|
||||
req = await ctx.client.post("/api/auth/password/reset-request", json={"handle": ctx.handle})
|
||||
assert req.status_code == 200
|
||||
|
||||
redis = get_redis()
|
||||
keys = await redis.keys("pwreset:*")
|
||||
# Pick the token bound to THIS user (redis DB may hold others from prior runs).
|
||||
token = None
|
||||
for key in keys:
|
||||
if await redis.get(key) == str(ctx.user_id):
|
||||
token = key.split(":", 1)[1]
|
||||
break
|
||||
assert token is not None, "a reset token should have been issued for this user"
|
||||
|
||||
new_password = "brandNewPass9"
|
||||
reset = await ctx.client.post(
|
||||
"/api/auth/password/reset", json={"token": token, "password": new_password}
|
||||
)
|
||||
assert reset.status_code == 200
|
||||
|
||||
# token is single-use
|
||||
again = await ctx.client.post(
|
||||
"/api/auth/password/reset", json={"token": token, "password": new_password}
|
||||
)
|
||||
assert again.status_code == 400
|
||||
|
||||
# new password works, old one does not
|
||||
async with _fresh_client(ctx.app) as anon:
|
||||
good = await anon.post(
|
||||
"/api/auth/login", json={"handle": ctx.handle, "password": new_password}
|
||||
)
|
||||
assert good.status_code == 200
|
||||
bad = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
|
||||
assert bad.status_code == 401
|
||||
|
||||
|
||||
async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None:
|
||||
# must not leak whether a handle exists
|
||||
resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"})
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
|
||||
<title>Webmail admin</title>
|
||||
<title>Ultimo Email admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
|
||||
<title>Webmail</title>
|
||||
<title>Ultimo Email</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,9 +6,11 @@ import { useMe } from "./lib/auth";
|
||||
import { connectWs } from "./lib/ws";
|
||||
import { PluginPage } from "./plugins/PluginPage";
|
||||
import { ComposeWindow } from "./routes/ComposeWindow";
|
||||
import { ForgotPassword } from "./routes/ForgotPassword";
|
||||
import { Login } from "./routes/Login";
|
||||
import { MailLayout } from "./routes/MailLayout";
|
||||
import { Register } from "./routes/Register";
|
||||
import { ResetPassword } from "./routes/ResetPassword";
|
||||
import { Settings } from "./routes/settings/Settings";
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
@@ -35,6 +37,8 @@ export function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route
|
||||
path="/mail/*"
|
||||
element={
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
import { useMe } from "../lib/auth";
|
||||
import type { AdminMail, AdminUser } from "../lib/types";
|
||||
|
||||
const GIB = 1024 ** 3;
|
||||
|
||||
@@ -28,11 +29,24 @@ export function AdminApp() {
|
||||
<header className="border-b border-app-border px-6 py-3">
|
||||
<h1 className="font-semibold">{t("admin.title")}</h1>
|
||||
</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 && !me.data && <SignIn />}
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
@@ -235,3 +249,487 @@ function Panel({ handle }: { handle: string }) {
|
||||
</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
@@ -3,7 +3,7 @@
|
||||
// Interpolate with {name} placeholders.
|
||||
|
||||
export const en = {
|
||||
"common.appName": "Webmail",
|
||||
"common.appName": "Ultimo Email",
|
||||
"common.loading": "Loading…",
|
||||
"common.cancel": "Cancel",
|
||||
"common.save": "Save",
|
||||
@@ -34,6 +34,26 @@ export const en = {
|
||||
"auth.create": "Create account",
|
||||
"auth.creating": "Creating…",
|
||||
"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.register": "Register",
|
||||
"auth.checking": "Checking…",
|
||||
@@ -327,6 +347,15 @@ export const en = {
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.handle": "Handle",
|
||||
"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.twoFactorOn": "2FA is 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.integrations": "Integrations",
|
||||
|
||||
"admin.title": "Webmail administration",
|
||||
"admin.title": "Ultimo Email administration",
|
||||
"admin.signInTitle": "Administrator sign-in",
|
||||
"admin.signInHint": "Sign in with an administrator account to manage this server.",
|
||||
"admin.signOut": "Sign out",
|
||||
@@ -432,6 +461,47 @@ export const en = {
|
||||
"admin.saving": "Saving…",
|
||||
"admin.saved": "Saved",
|
||||
"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.jitsiHint":
|
||||
"Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.",
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
import type {
|
||||
AccountDetail,
|
||||
AdminMail,
|
||||
AdminSettings,
|
||||
AdminUser,
|
||||
AccountOut,
|
||||
AttachmentOut,
|
||||
CalendarOut,
|
||||
@@ -83,6 +85,38 @@ export const api = {
|
||||
// PUT merges: send only the fields you are changing.
|
||||
saveAdminSettings: (body: Partial<AdminSettings>) =>
|
||||
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"),
|
||||
register: (localpart: string, password: string) =>
|
||||
apiFetch<UserOut>("/auth/register", {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type QueryClient, useQuery } from "@tanstack/react-query";
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -15,6 +15,31 @@ export interface UserOut {
|
||||
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 {
|
||||
registration_open: boolean;
|
||||
/** Quota given to users created from now on; existing mailboxes keep theirs. */
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
|
||||
import { signedOut } from "./auth";
|
||||
|
||||
const frameSchema = z.object({
|
||||
event: z.string(),
|
||||
payload: z.record(z.unknown()),
|
||||
@@ -20,6 +22,7 @@ const ALLOWED_EVENTS = new Set([
|
||||
"account.state_changed",
|
||||
"draft.saved",
|
||||
"outbox.state_changed",
|
||||
"user.signed_out",
|
||||
]);
|
||||
|
||||
// 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 (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).
|
||||
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
|
||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
|
||||
+10
-1
@@ -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 ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import { App } from "./App";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { isSessionGone, signedOut } from "./lib/auth";
|
||||
import { ThemeProvider } from "./lib/theme";
|
||||
import "./index.css";
|
||||
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({
|
||||
queryCache: new QueryCache({ onError }),
|
||||
mutationCache: new MutationCache({ onError }),
|
||||
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,11 @@ export function Login() {
|
||||
</button>
|
||||
{showError && <p className="text-sm text-red-500">{error.message}</p>}
|
||||
</form>
|
||||
<p className="mt-4 text-sm">
|
||||
<Link className="text-app-accent" to="/forgot-password">
|
||||
{t("auth.forgotPassword")}
|
||||
</Link>
|
||||
</p>
|
||||
{registration.data?.open && (
|
||||
<p className="mt-4 text-sm text-app-muted">
|
||||
{t("auth.noAccount")}{" "}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
||||
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
|
||||
import { useT } from "../../i18n";
|
||||
import { api, ApiError } from "../../lib/api";
|
||||
import type { CalendarOut, ReminderPref } from "../../lib/types";
|
||||
@@ -105,11 +106,103 @@ function ProfileSection() {
|
||||
<section className="max-w-4xl space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
|
||||
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
|
||||
<Row label={t("settings.recoveryEmail")} value={data?.recovery_email ?? "—"} />
|
||||
<RecoveryEmailRow current={data?.recovery_email ?? null} />
|
||||
</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 }) {
|
||||
return (
|
||||
<div className="flex justify-between border-b border-app-border py-2 text-sm">
|
||||
|
||||
Reference in New Issue
Block a user