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).
|
"""Instance administration (SPEC §6.1).
|
||||||
|
|
||||||
The backend seam for the admin panel. Today it carries only the registration
|
The backend seam for the admin panel: the registration gate — the first account
|
||||||
gate — the first account on a fresh install becomes the administrator and closes
|
on a fresh install becomes the administrator and closes signup behind itself,
|
||||||
signup behind itself, and this is how that gets reopened. Further instance-wide
|
and this is how that gets reopened — the default quota, and managing users
|
||||||
options land here as the panel grows.
|
(search, per-user quota, block, delete). An administrator cannot block or delete
|
||||||
|
their own account, so the instance can never lock out its last admin that way.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.errors import raise_api_error
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.identity.deps import CurrentAdmin, require_csrf
|
from app.identity.deps import CurrentAdmin, require_csrf
|
||||||
from app.identity.schemas import AdminSettingsIn, AdminSettingsOut
|
from app.identity.mailer import MailerError, mailer_config, send_mail
|
||||||
|
from app.identity.schemas import (
|
||||||
|
AdminMailIn,
|
||||||
|
AdminMailOut,
|
||||||
|
AdminMailTestIn,
|
||||||
|
AdminSettingsIn,
|
||||||
|
AdminSettingsOut,
|
||||||
|
AdminUserOut,
|
||||||
|
AdminUserPatch,
|
||||||
|
MessageOut,
|
||||||
|
)
|
||||||
from app.identity.service import app_settings
|
from app.identity.service import app_settings
|
||||||
from app.models import AppSettings
|
from app.identity.user_admin import delete_user, search_users, set_blocked
|
||||||
|
from app.models import Account, AppSettings, Message, User
|
||||||
|
from app.security.crypto import encrypt_secret
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
@@ -54,3 +70,134 @@ async def write_settings(
|
|||||||
row.updated_at = dt.datetime.now(dt.UTC)
|
row.updated_at = dt.datetime.now(dt.UTC)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return _out(row)
|
return _out(row)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Outgoing system mail -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_out(row: AppSettings) -> AdminMailOut:
|
||||||
|
return AdminMailOut(
|
||||||
|
smtp_host=row.smtp_host,
|
||||||
|
smtp_port=row.smtp_port,
|
||||||
|
smtp_security=row.smtp_security,
|
||||||
|
smtp_username=row.smtp_username,
|
||||||
|
has_password=row.smtp_secret_ct is not None,
|
||||||
|
smtp_from=row.smtp_from,
|
||||||
|
public_url=row.public_url,
|
||||||
|
configured=bool(row.smtp_host and row.smtp_from and row.public_url),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mail", response_model=AdminMailOut)
|
||||||
|
async def read_mail(admin: CurrentAdmin, db: DbSession) -> AdminMailOut:
|
||||||
|
return _mail_out(await app_settings(db))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/mail", response_model=AdminMailOut, dependencies=[Depends(require_csrf)])
|
||||||
|
async def write_mail(body: AdminMailIn, admin: CurrentAdmin, db: DbSession) -> AdminMailOut:
|
||||||
|
"""Merge the supplied SMTP fields. The password is stored vault-encrypted."""
|
||||||
|
row = await app_settings(db, for_update=True)
|
||||||
|
for name in ("smtp_host", "smtp_username", "smtp_from"):
|
||||||
|
value = getattr(body, name)
|
||||||
|
if value is not None:
|
||||||
|
setattr(row, name, value.strip())
|
||||||
|
if body.smtp_port is not None:
|
||||||
|
row.smtp_port = body.smtp_port
|
||||||
|
if body.smtp_security is not None:
|
||||||
|
row.smtp_security = body.smtp_security
|
||||||
|
if body.smtp_password is not None:
|
||||||
|
row.smtp_secret_ct = (
|
||||||
|
encrypt_secret(body.smtp_password.encode()) if body.smtp_password else None
|
||||||
|
)
|
||||||
|
if body.public_url is not None:
|
||||||
|
url = body.public_url.strip().rstrip("/")
|
||||||
|
if url and not url.startswith(("http://", "https://")):
|
||||||
|
raise_api_error(400, "invalid_url", "the address must start with http:// or https://")
|
||||||
|
row.public_url = url
|
||||||
|
row.updated_at = dt.datetime.now(dt.UTC)
|
||||||
|
await db.commit()
|
||||||
|
return _mail_out(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mail/test", response_model=MessageOut, dependencies=[Depends(require_csrf)])
|
||||||
|
async def test_mail(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -> MessageOut:
|
||||||
|
"""Send a test message with the saved settings, reporting the server's error."""
|
||||||
|
cfg = mailer_config(await app_settings(db))
|
||||||
|
if cfg is None:
|
||||||
|
raise_api_error(
|
||||||
|
409, "mail_not_configured", "set the SMTP server, From address and public address first"
|
||||||
|
)
|
||||||
|
text = (
|
||||||
|
"This is a test message from Ultimo Email.\n\n"
|
||||||
|
"Outgoing system mail works: password-reset links will be sent from this account.\n"
|
||||||
|
)
|
||||||
|
html = f"<p>{text.replace(chr(10) * 2, '</p><p>').strip()}</p>"
|
||||||
|
try:
|
||||||
|
await send_mail(cfg, body.to.strip(), "Ultimo Email test message", text, html)
|
||||||
|
except MailerError as exc:
|
||||||
|
raise_api_error(502, "mail_failed", f"sending failed: {exc}")
|
||||||
|
return MessageOut()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Users --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _user_out(user: User, used_bytes: int, accounts: list[str]) -> AdminUserOut:
|
||||||
|
return AdminUserOut(
|
||||||
|
id=user.id,
|
||||||
|
handle=user.handle,
|
||||||
|
is_admin=user.is_admin,
|
||||||
|
blocked_at=user.blocked_at.isoformat() if user.blocked_at else None,
|
||||||
|
quota_bytes=user.quota_bytes,
|
||||||
|
used_bytes=used_bytes,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
accounts=accounts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _target(db: AsyncSession, admin: User, user_id: uuid.UUID) -> User:
|
||||||
|
"""The user an admin action is aimed at; never the admin themselves."""
|
||||||
|
user = await db.scalar(select(User).where(User.id == user_id))
|
||||||
|
if user is None:
|
||||||
|
raise_api_error(404, "not_found", "user not found")
|
||||||
|
if user.id == admin.id:
|
||||||
|
raise_api_error(409, "cannot_modify_self", "you cannot do this to your own account")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=list[AdminUserOut])
|
||||||
|
async def list_users(
|
||||||
|
admin: CurrentAdmin, db: DbSession, q: str = Query("", max_length=200)
|
||||||
|
) -> list[AdminUserOut]:
|
||||||
|
"""Search users by login, or by a mail account's address or display name."""
|
||||||
|
return [_user_out(u, used, emails) for u, used, emails in await search_users(db, q)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/users/{user_id}", response_model=AdminUserOut, dependencies=[Depends(require_csrf)])
|
||||||
|
async def patch_user(
|
||||||
|
user_id: uuid.UUID, body: AdminUserPatch, admin: CurrentAdmin, db: DbSession
|
||||||
|
) -> AdminUserOut:
|
||||||
|
"""Change a user's quota and/or block or unblock them."""
|
||||||
|
user = await _target(db, admin, user_id)
|
||||||
|
if body.quota_bytes is not None:
|
||||||
|
user.quota_bytes = body.quota_bytes
|
||||||
|
if body.blocked is not None:
|
||||||
|
await set_blocked(db, user, body.blocked)
|
||||||
|
await db.commit()
|
||||||
|
used = await db.scalar(
|
||||||
|
select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id)
|
||||||
|
)
|
||||||
|
emails = list(
|
||||||
|
await db.scalars(
|
||||||
|
select(Account.email).where(Account.user_id == user.id).order_by(Account.email)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _user_out(user, int(used or 0), emails)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}", dependencies=[Depends(require_csrf)])
|
||||||
|
async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) -> dict[str, str]:
|
||||||
|
"""Delete a user and all of their data. Cannot be undone."""
|
||||||
|
user = await _target(db, admin, user_id)
|
||||||
|
await delete_user(db, user)
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ ALLOWED_EVENTS: frozenset[str] = frozenset(
|
|||||||
# Sync v2 §3: published on login and every WS connect; drives tier
|
# Sync v2 §3: published on login and every WS connect; drives tier
|
||||||
# promotion in the sync workers (§5). Payload: {user_id}.
|
# promotion in the sync workers (§5). Payload: {user_id}.
|
||||||
"user.active",
|
"user.active",
|
||||||
|
# The user's sessions were revoked by an administrator (blocked or
|
||||||
|
# deleted): open tabs drop to the sign-in screen. Payload: {user_id}.
|
||||||
|
"user.signed_out",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ async def current_user(
|
|||||||
) -> User:
|
) -> User:
|
||||||
"""Load the authenticated :class:`User` (I-7: scoped by the session's user_id)."""
|
"""Load the authenticated :class:`User` (I-7: scoped by the session's user_id)."""
|
||||||
user = await db.scalar(select(User).where(User.id == session.user_id))
|
user = await db.scalar(select(User).where(User.id == session.user_id))
|
||||||
if user is None:
|
if user is None or user.blocked_at is not None:
|
||||||
# Session references a deleted user; treat as unauthenticated.
|
# Session references a deleted or blocked user; treat as unauthenticated.
|
||||||
|
# (Blocking also revokes the sessions; this covers any request in flight.)
|
||||||
raise_api_error(401, "not_authenticated", "authentication required")
|
raise_api_error(401, "not_authenticated", "authentication required")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
"""Password reset tokens (SPEC §6.1).
|
||||||
|
|
||||||
Single-use, 30-minute, Redis-backed tokens. Only issued when the account has a
|
Single-use, 30-minute, Redis-backed tokens, emailed as a link to the account's
|
||||||
recovery email on file. Actual delivery of the token to the recovery address
|
recovery address through the system mailer (:mod:`app.identity.mailer`).
|
||||||
requires an app-level mailer (not part of M0/M1); until then the token is logged
|
|
||||||
server-side. See docs/open-questions.md.
|
A token survives a wrong 2FA code (the page asks again) but not five: after
|
||||||
|
that it is burned, so the link cannot be used to guess codes. Requests are
|
||||||
|
capped per account so the reset form cannot be used to flood someone's inbox.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from app.redis_client import aw, get_redis
|
from app.redis_client import aw, get_redis
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_PREFIX = "pwreset:"
|
_PREFIX = "pwreset:"
|
||||||
|
_FAILS = "pwreset-fail:"
|
||||||
|
_RATE = "pwreset-rate:"
|
||||||
_TTL = 30 * 60 # 30 minutes
|
_TTL = 30 * 60 # 30 minutes
|
||||||
|
MAX_TOTP_FAILURES = 5
|
||||||
|
MAX_REQUESTS_PER_HOUR = 3
|
||||||
|
|
||||||
|
|
||||||
async def issue_token(user_id: uuid.UUID, recovery_email: str) -> str:
|
async def issue_token(user_id: uuid.UUID) -> str:
|
||||||
"""Create a single-use reset token bound to ``user_id`` and return it."""
|
"""Create a single-use reset token bound to ``user_id`` and return it."""
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
await get_redis().set(_PREFIX + token, str(user_id), ex=_TTL)
|
await get_redis().set(_PREFIX + token, str(user_id), ex=_TTL)
|
||||||
# TODO(spec): deliver via the recovery address once an app mailer exists.
|
|
||||||
logger.info("password reset token issued for user=%s recovery=%s", user_id, recovery_email)
|
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def request_allowed(user_id: uuid.UUID) -> bool:
|
||||||
|
"""Count a reset request; False once the account has had its hourly share."""
|
||||||
|
redis = get_redis()
|
||||||
|
key = _RATE + str(user_id)
|
||||||
|
count = int(await redis.incr(key))
|
||||||
|
if count == 1:
|
||||||
|
await redis.expire(key, 3600)
|
||||||
|
return count <= MAX_REQUESTS_PER_HOUR
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id(raw: str | None) -> uuid.UUID | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uuid.UUID(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def peek_token(token: str) -> uuid.UUID | None:
|
||||||
|
"""The user a still-valid token belongs to, without using it up."""
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return _user_id(await get_redis().get(_PREFIX + token))
|
||||||
|
|
||||||
|
|
||||||
async def consume_token(token: str) -> uuid.UUID | None:
|
async def consume_token(token: str) -> uuid.UUID | None:
|
||||||
"""Atomically consume a reset token, returning the user_id (or None)."""
|
"""Atomically consume a reset token, returning the user_id (or None)."""
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
redis = get_redis()
|
redis = get_redis()
|
||||||
raw = await aw(redis.getdel(_PREFIX + token)) # single-use: fetch and delete atomically
|
raw = await aw(redis.getdel(_PREFIX + token)) # single-use: fetch and delete atomically
|
||||||
if raw is None:
|
await redis.delete(_FAILS + token)
|
||||||
return None
|
return _user_id(raw)
|
||||||
try:
|
|
||||||
return uuid.UUID(raw)
|
|
||||||
except ValueError:
|
async def record_totp_failure(token: str) -> None:
|
||||||
return None
|
"""Count a wrong 2FA code against the token; burn it at the limit."""
|
||||||
|
redis = get_redis()
|
||||||
|
fails = await redis.incr(_FAILS + token)
|
||||||
|
if fails == 1:
|
||||||
|
await redis.expire(_FAILS + token, _TTL)
|
||||||
|
if fails >= MAX_TOTP_FAILURES:
|
||||||
|
await redis.delete(_PREFIX + token, _FAILS + token)
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ TOTP setup/enable, session list/revoke, password reset."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request, Response
|
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -20,13 +22,23 @@ from app.identity.cookies import (
|
|||||||
set_auth_cookies,
|
set_auth_cookies,
|
||||||
)
|
)
|
||||||
from app.identity.deps import CurrentSession, CurrentUser, require_csrf
|
from app.identity.deps import CurrentSession, CurrentUser, require_csrf
|
||||||
|
from app.identity.mailer import MailerConfig, MailerError, mailer_config, reset_mail, send_mail
|
||||||
from app.identity.passwords import hash_password, verify_password
|
from app.identity.passwords import hash_password, verify_password
|
||||||
from app.identity.reset import consume_token, issue_token
|
from app.identity.reset import (
|
||||||
|
consume_token,
|
||||||
|
issue_token,
|
||||||
|
peek_token,
|
||||||
|
record_totp_failure,
|
||||||
|
request_allowed,
|
||||||
|
)
|
||||||
from app.identity.schemas import (
|
from app.identity.schemas import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MessageOut,
|
MessageOut,
|
||||||
|
PasswordResetCheckIn,
|
||||||
|
PasswordResetCheckOut,
|
||||||
PasswordResetIn,
|
PasswordResetIn,
|
||||||
PasswordResetRequestIn,
|
PasswordResetRequestIn,
|
||||||
|
RecoveryEmailIn,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
RegistrationStatusOut,
|
RegistrationStatusOut,
|
||||||
SessionOut,
|
SessionOut,
|
||||||
@@ -36,10 +48,12 @@ from app.identity.schemas import (
|
|||||||
UserOut,
|
UserOut,
|
||||||
)
|
)
|
||||||
from app.identity.service import (
|
from app.identity.service import (
|
||||||
|
AccountBlocked,
|
||||||
RateLimited,
|
RateLimited,
|
||||||
RegistrationError,
|
RegistrationError,
|
||||||
TotpInvalid,
|
TotpInvalid,
|
||||||
TotpRequired,
|
TotpRequired,
|
||||||
|
app_settings,
|
||||||
authenticate,
|
authenticate,
|
||||||
register_user,
|
register_user,
|
||||||
registration_status,
|
registration_status,
|
||||||
@@ -56,8 +70,14 @@ from app.models import User
|
|||||||
from app.redis_client import get_redis
|
from app.redis_client import get_redis
|
||||||
from app.sync import leases
|
from app.sync import leases
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
# Deliberately loose: the recovery address only has to be something mail can be
|
||||||
|
# sent to; the mail server is the real judge.
|
||||||
|
_EMAIL_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")
|
||||||
|
|
||||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +148,10 @@ async def login(body: LoginRequest, request: Request, response: Response, db: Db
|
|||||||
raise_api_error(401, "totp_required", "a 2FA code is required")
|
raise_api_error(401, "totp_required", "a 2FA code is required")
|
||||||
except TotpInvalid:
|
except TotpInvalid:
|
||||||
raise_api_error(401, "totp_invalid", "incorrect 2FA code")
|
raise_api_error(401, "totp_invalid", "incorrect 2FA code")
|
||||||
|
except AccountBlocked:
|
||||||
|
raise_api_error(
|
||||||
|
403, "account_blocked", "this account has been blocked by the administrator"
|
||||||
|
)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise_api_error(401, "invalid_credentials", "incorrect handle or password")
|
raise_api_error(401, "invalid_credentials", "incorrect handle or password")
|
||||||
await _establish_session(request, response, user)
|
await _establish_session(request, response, user)
|
||||||
@@ -209,25 +233,86 @@ async def revoke(sid: str, user: CurrentUser) -> MessageOut:
|
|||||||
# --- Password reset (SPEC §6.1) ---------------------------------------------
|
# --- Password reset (SPEC §6.1) ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_reset_mail(cfg: MailerConfig, to: str, handle: str, token: str) -> None:
|
||||||
|
"""Runs after the response has gone out (see password_reset_request)."""
|
||||||
|
# The token rides in the fragment, which browsers never send to a server, so
|
||||||
|
# it stays out of access logs and Referer headers.
|
||||||
|
subject, text, html = reset_mail(f"{cfg.public_url}/reset-password#token={token}", handle)
|
||||||
|
try:
|
||||||
|
await send_mail(cfg, to, subject, text, html)
|
||||||
|
except MailerError as exc:
|
||||||
|
logger.warning("password reset mail for %s could not be sent: %s", handle, exc)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/password/reset-request", response_model=MessageOut)
|
@router.post("/password/reset-request", response_model=MessageOut)
|
||||||
async def password_reset_request(body: PasswordResetRequestIn, db: DbSession) -> MessageOut:
|
async def password_reset_request(
|
||||||
# Always return ok (do not leak whether the handle exists / has recovery mail).
|
body: PasswordResetRequestIn, background: BackgroundTasks, db: DbSession
|
||||||
|
) -> MessageOut:
|
||||||
|
"""Email a reset link to the account's recovery address.
|
||||||
|
|
||||||
|
The answer is the same whether or not the account exists, has a recovery
|
||||||
|
address, or hit the hourly cap, and the mail is sent after the response —
|
||||||
|
so neither the reply nor its timing tells a stranger anything.
|
||||||
|
"""
|
||||||
handle = resolve_handle(body.handle)
|
handle = resolve_handle(body.handle)
|
||||||
user = await db.scalar(select(User).where(User.handle == handle))
|
user = await db.scalar(select(User).where(User.handle == handle))
|
||||||
if user is not None and user.recovery_email:
|
if user is None or not user.recovery_email:
|
||||||
await issue_token(user.id, user.recovery_email)
|
return MessageOut()
|
||||||
|
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()
|
return MessageOut()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password/reset/check", response_model=PasswordResetCheckOut)
|
||||||
|
async def password_reset_check(body: PasswordResetCheckIn, db: DbSession) -> PasswordResetCheckOut:
|
||||||
|
"""Whether a reset link still works, so the page can say so before asking
|
||||||
|
for a new password. Does not use the link up."""
|
||||||
|
user_id = await peek_token(body.token)
|
||||||
|
user = await db.get(User, user_id) if user_id else None
|
||||||
|
if user is None:
|
||||||
|
return PasswordResetCheckOut(valid=False)
|
||||||
|
return PasswordResetCheckOut(valid=True, totp_required=user.totp_secret is not None)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/password/reset", response_model=MessageOut)
|
@router.post("/password/reset", response_model=MessageOut)
|
||||||
async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
|
async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
|
||||||
user_id = await consume_token(body.token)
|
"""Set a new password with a reset link. With 2FA on, the code is checked
|
||||||
if user_id is None:
|
before anything changes; a missing or wrong code leaves the link usable
|
||||||
raise_api_error(400, "invalid_token", "reset token is invalid or expired")
|
(up to MAX_TOTP_FAILURES wrong codes)."""
|
||||||
user = await db.get(User, user_id)
|
user_id = await peek_token(body.token)
|
||||||
|
user = await db.get(User, user_id) if user_id else None
|
||||||
if user is None:
|
if user is None:
|
||||||
raise_api_error(400, "invalid_token", "reset token is invalid or expired")
|
raise_api_error(400, "invalid_token", "this link is invalid or has expired")
|
||||||
|
if user.totp_secret is not None:
|
||||||
|
if not body.totp:
|
||||||
|
raise_api_error(401, "totp_required", "a 2FA code is required")
|
||||||
|
if not totp_mod.verify(totp_mod.decrypt_totp_secret(user.totp_secret), body.totp):
|
||||||
|
await record_totp_failure(body.token)
|
||||||
|
raise_api_error(401, "totp_invalid", "incorrect 2FA code")
|
||||||
|
# Only now is the link spent — and only if nobody spent it in the meantime.
|
||||||
|
if await consume_token(body.token) != user.id:
|
||||||
|
raise_api_error(400, "invalid_token", "this link is invalid or has expired")
|
||||||
user.password_hash = hash_password(body.password)
|
user.password_hash = hash_password(body.password)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await destroy_all_sessions(user_id) # force re-login everywhere after reset
|
await destroy_all_sessions(user.id) # force re-login everywhere after reset
|
||||||
return MessageOut()
|
return MessageOut()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/recovery-email", response_model=UserOut, dependencies=[Depends(require_csrf)])
|
||||||
|
async def set_recovery_email(body: RecoveryEmailIn, user: CurrentUser, db: DbSession) -> UserOut:
|
||||||
|
"""Add, change or remove the recovery address. Takes the password: whoever
|
||||||
|
controls this address can take the account over with a reset link."""
|
||||||
|
if not verify_password(user.password_hash, body.password):
|
||||||
|
raise_api_error(403, "invalid_password", "incorrect password")
|
||||||
|
email = (body.recovery_email or "").strip().lower() or None
|
||||||
|
if email is not None and not _EMAIL_RE.fullmatch(email):
|
||||||
|
raise_api_error(400, "invalid_email", "enter a valid email address")
|
||||||
|
user.recovery_email = email
|
||||||
|
await db.commit()
|
||||||
|
return _user_out(user)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -47,6 +48,23 @@ class PasswordResetRequestIn(BaseModel):
|
|||||||
class PasswordResetIn(BaseModel):
|
class PasswordResetIn(BaseModel):
|
||||||
token: str = Field(min_length=1)
|
token: str = Field(min_length=1)
|
||||||
password: str = Field(min_length=8, max_length=1024)
|
password: str = Field(min_length=8, max_length=1024)
|
||||||
|
totp: str | None = None # required when the account has 2FA enabled
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetCheckIn(BaseModel):
|
||||||
|
token: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetCheckOut(BaseModel):
|
||||||
|
valid: bool
|
||||||
|
# Tells the page to expect the 2FA step; only the link's holder learns it.
|
||||||
|
totp_required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryEmailIn(BaseModel):
|
||||||
|
password: str = Field(min_length=1, max_length=1024)
|
||||||
|
# None (or blank) removes the recovery address.
|
||||||
|
recovery_email: str | None = Field(default=None, max_length=320)
|
||||||
|
|
||||||
|
|
||||||
class UserOut(BaseModel):
|
class UserOut(BaseModel):
|
||||||
@@ -79,5 +97,55 @@ class AdminSettingsIn(BaseModel):
|
|||||||
default_quota_bytes: int | None = Field(default=None, gt=0)
|
default_quota_bytes: int | None = Field(default=None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminMailOut(BaseModel):
|
||||||
|
smtp_host: str
|
||||||
|
smtp_port: int
|
||||||
|
smtp_security: str
|
||||||
|
smtp_username: str
|
||||||
|
# The password itself never leaves the server.
|
||||||
|
has_password: bool
|
||||||
|
smtp_from: str
|
||||||
|
public_url: str
|
||||||
|
# Host, From address and public address are all set: reset mail can go out.
|
||||||
|
configured: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AdminMailIn(BaseModel):
|
||||||
|
"""Merge semantics: omitted fields are left as they are."""
|
||||||
|
|
||||||
|
smtp_host: str | None = Field(default=None, max_length=255)
|
||||||
|
smtp_port: int | None = Field(default=None, ge=1, le=65535)
|
||||||
|
smtp_security: Literal["starttls", "ssl", "none"] | None = None
|
||||||
|
smtp_username: str | None = Field(default=None, max_length=320)
|
||||||
|
# Omitted keeps the stored password; an empty string removes it.
|
||||||
|
smtp_password: str | None = Field(default=None, max_length=1024)
|
||||||
|
smtp_from: str | None = Field(default=None, max_length=320)
|
||||||
|
public_url: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminMailTestIn(BaseModel):
|
||||||
|
to: str = Field(min_length=3, max_length=320)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserOut(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
handle: str
|
||||||
|
is_admin: bool
|
||||||
|
# ISO timestamp while blocked; null otherwise.
|
||||||
|
blocked_at: str | None
|
||||||
|
quota_bytes: int
|
||||||
|
used_bytes: int
|
||||||
|
created_at: str
|
||||||
|
# The user's connected mail accounts, so the admin can tell who this is.
|
||||||
|
accounts: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserPatch(BaseModel):
|
||||||
|
"""Merge semantics: omitted fields are left as they are."""
|
||||||
|
|
||||||
|
quota_bytes: int | None = Field(default=None, gt=0)
|
||||||
|
blocked: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class MessageOut(BaseModel):
|
class MessageOut(BaseModel):
|
||||||
status: str = "ok"
|
status: str = "ok"
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ class TotpInvalid(Exception):
|
|||||||
"""Password verified but the supplied TOTP code was wrong."""
|
"""Password verified but the supplied TOTP code was wrong."""
|
||||||
|
|
||||||
|
|
||||||
|
class AccountBlocked(Exception):
|
||||||
|
"""Password verified but an administrator has blocked the account."""
|
||||||
|
|
||||||
|
|
||||||
def resolve_handle(handle_input: str) -> str:
|
def resolve_handle(handle_input: str) -> str:
|
||||||
"""Normalize a login identifier to the full ``localpart@APP_DOMAIN`` form."""
|
"""Normalize a login identifier to the full ``localpart@APP_DOMAIN`` form."""
|
||||||
text = handle_input.strip().lower()
|
text = handle_input.strip().lower()
|
||||||
@@ -164,6 +168,9 @@ async def authenticate(
|
|||||||
ok = verify_password(stored_hash, password)
|
ok = verify_password(stored_hash, password)
|
||||||
if user is None or not ok:
|
if user is None or not ok:
|
||||||
return None
|
return None
|
||||||
|
# Only after the password: a stranger must not learn that a handle is blocked.
|
||||||
|
if user.blocked_at is not None:
|
||||||
|
raise AccountBlocked
|
||||||
|
|
||||||
if user.totp_secret is not None:
|
if user.totp_secret is not None:
|
||||||
from app.identity.totp import decrypt_totp_secret
|
from app.identity.totp import decrypt_totp_secret
|
||||||
|
|||||||
@@ -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(
|
default_quota_bytes: Mapped[int] = mapped_column(
|
||||||
BigInteger, nullable=False, server_default=text("5368709120")
|
BigInteger, nullable=False, server_default=text("5368709120")
|
||||||
)
|
)
|
||||||
|
# Outgoing system mail (password-reset links): the SMTP account the server
|
||||||
|
# sends from. The password is vault-encrypted (like Account.secret_ct).
|
||||||
|
smtp_host: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||||
|
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("587"))
|
||||||
|
smtp_security: Mapped[str] = mapped_column(
|
||||||
|
Text, nullable=False, server_default=text("'starttls'")
|
||||||
|
) # starttls | ssl | none
|
||||||
|
smtp_username: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||||
|
smtp_secret_ct: Mapped[bytes | None] = mapped_column(BYTEA)
|
||||||
|
smtp_from: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||||
|
# Where users reach the app, e.g. https://mail.example.com — reset links are
|
||||||
|
# built from this, never from the request's Host header.
|
||||||
|
public_url: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||||
updated_at: Mapped[dt.datetime] = _now()
|
updated_at: Mapped[dt.datetime] = _now()
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +104,9 @@ class User(Base):
|
|||||||
# The first account created on a fresh install bootstraps as administrator;
|
# The first account created on a fresh install bootstraps as administrator;
|
||||||
# the admin panel can grant it to others later.
|
# the admin panel can grant it to others later.
|
||||||
is_admin: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
|
is_admin: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
|
||||||
|
# Set while an administrator has blocked the account (no sign-in, sessions
|
||||||
|
# refused); NULL when not blocked.
|
||||||
|
blocked_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||||
created_at: Mapped[dt.datetime] = _now()
|
created_at: Mapped[dt.datetime] = _now()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ async def ws_endpoint(websocket: WebSocket) -> None:
|
|||||||
payload = data.get("payload", {})
|
payload = data.get("payload", {})
|
||||||
if str(payload.get("user_id")) == user_id:
|
if str(payload.get("user_id")) == user_id:
|
||||||
await websocket.send_json(data)
|
await websocket.send_json(data)
|
||||||
|
if data.get("event") == "user.signed_out":
|
||||||
|
# The session this socket was opened with no longer exists.
|
||||||
|
await websocket.close(code=1008)
|
||||||
|
return
|
||||||
|
|
||||||
async def receive() -> None:
|
async def receive() -> None:
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""administrator can block a user
|
||||||
|
|
||||||
|
``users.blocked_at`` is set while an administrator has blocked the account: it
|
||||||
|
cannot sign in and its sessions stop working. NULL means not blocked. A
|
||||||
|
timestamp rather than a flag so the admin panel can say since when.
|
||||||
|
|
||||||
|
Revision ID: 0019
|
||||||
|
Revises: 0018
|
||||||
|
Create Date: 2026-09-30
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0019"
|
||||||
|
down_revision: str | None = "0018"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE users ADD COLUMN blocked_at timestamptz")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS blocked_at")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""outgoing system mail (password reset) configured in the admin panel
|
||||||
|
|
||||||
|
The server sends its own mail — today only password-reset links — through an
|
||||||
|
SMTP account the administrator sets up in the admin panel. The password is
|
||||||
|
envelope-encrypted with MASTER_KEY like every mail-account secret. ``public_url``
|
||||||
|
is where users reach the app; reset links are built from it, never from the
|
||||||
|
request's Host header, which an attacker controls.
|
||||||
|
|
||||||
|
Revision ID: 0020
|
||||||
|
Revises: 0019
|
||||||
|
Create Date: 2026-09-30
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0020"
|
||||||
|
down_revision: str | None = "0019"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_COLUMNS = (
|
||||||
|
("smtp_host", "text NOT NULL DEFAULT ''"),
|
||||||
|
("smtp_port", "integer NOT NULL DEFAULT 587"),
|
||||||
|
("smtp_security", "text NOT NULL DEFAULT 'starttls'"),
|
||||||
|
("smtp_username", "text NOT NULL DEFAULT ''"),
|
||||||
|
("smtp_secret_ct", "bytea"),
|
||||||
|
("smtp_from", "text NOT NULL DEFAULT ''"),
|
||||||
|
("public_url", "text NOT NULL DEFAULT ''"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for name, ddl in _COLUMNS:
|
||||||
|
op.execute(f"ALTER TABLE app_settings ADD COLUMN {name} {ddl}")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE app_settings ADD CONSTRAINT ck_app_settings_smtp_security "
|
||||||
|
"CHECK (smtp_security IN ('starttls', 'ssl', 'none'))"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE app_settings DROP CONSTRAINT IF EXISTS ck_app_settings_smtp_security")
|
||||||
|
for name, _ in reversed(_COLUMNS):
|
||||||
|
op.execute(f"ALTER TABLE app_settings DROP COLUMN IF EXISTS {name}")
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -19,7 +20,6 @@ from app.identity.passwords import hash_password
|
|||||||
from app.identity.sessions import create_session
|
from app.identity.sessions import create_session
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.redis_client import get_redis
|
|
||||||
|
|
||||||
CSRF = "csrf-token"
|
CSRF = "csrf-token"
|
||||||
PASSWORD = "hunter2pass"
|
PASSWORD = "hunter2pass"
|
||||||
@@ -145,43 +145,6 @@ async def test_sessions_list_and_revoke(ctx: Ctx) -> None:
|
|||||||
assert me.status_code == 401
|
assert me.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
async def test_password_reset_flow(ctx: Ctx) -> None:
|
|
||||||
# request a reset; token is stored in redis (not returned)
|
|
||||||
req = await ctx.client.post("/api/auth/password/reset-request", json={"handle": ctx.handle})
|
|
||||||
assert req.status_code == 200
|
|
||||||
|
|
||||||
redis = get_redis()
|
|
||||||
keys = await redis.keys("pwreset:*")
|
|
||||||
# Pick the token bound to THIS user (redis DB may hold others from prior runs).
|
|
||||||
token = None
|
|
||||||
for key in keys:
|
|
||||||
if await redis.get(key) == str(ctx.user_id):
|
|
||||||
token = key.split(":", 1)[1]
|
|
||||||
break
|
|
||||||
assert token is not None, "a reset token should have been issued for this user"
|
|
||||||
|
|
||||||
new_password = "brandNewPass9"
|
|
||||||
reset = await ctx.client.post(
|
|
||||||
"/api/auth/password/reset", json={"token": token, "password": new_password}
|
|
||||||
)
|
|
||||||
assert reset.status_code == 200
|
|
||||||
|
|
||||||
# token is single-use
|
|
||||||
again = await ctx.client.post(
|
|
||||||
"/api/auth/password/reset", json={"token": token, "password": new_password}
|
|
||||||
)
|
|
||||||
assert again.status_code == 400
|
|
||||||
|
|
||||||
# new password works, old one does not
|
|
||||||
async with _fresh_client(ctx.app) as anon:
|
|
||||||
good = await anon.post(
|
|
||||||
"/api/auth/login", json={"handle": ctx.handle, "password": new_password}
|
|
||||||
)
|
|
||||||
assert good.status_code == 200
|
|
||||||
bad = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
|
|
||||||
assert bad.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None:
|
async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None:
|
||||||
# must not leak whether a handle exists
|
# must not leak whether a handle exists
|
||||||
resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"})
|
resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"})
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"""Password reset by email, recovery address, and the system mailer (SPEC §6.1).
|
||||||
|
|
||||||
|
The SMTP send is replaced with a recorder, so nothing leaves the machine; the
|
||||||
|
reset link it would have sent is read back from what was recorded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pyotp
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport
|
||||||
|
from sqlalchemy import delete, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from app.api import admin as admin_api
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.identity import routes
|
||||||
|
from app.identity.mailer import MailerConfig
|
||||||
|
from app.identity.passwords import hash_password
|
||||||
|
from app.identity.reset import MAX_TOTP_FAILURES
|
||||||
|
from app.identity.service import app_settings
|
||||||
|
from app.identity.sessions import create_session
|
||||||
|
from app.identity.totp import encrypt_totp_secret
|
||||||
|
from app.main import create_app
|
||||||
|
from app.models import AppSettings, User
|
||||||
|
from app.redis_client import get_redis
|
||||||
|
|
||||||
|
CSRF = "csrf-token"
|
||||||
|
PASSWORD = "hunter2pass"
|
||||||
|
NEW_PASSWORD = "brandNewPass9"
|
||||||
|
CFG = MailerConfig(
|
||||||
|
host="smtp.example.com",
|
||||||
|
port=587,
|
||||||
|
security="starttls",
|
||||||
|
username="postmaster@example.com",
|
||||||
|
password="pw",
|
||||||
|
sender="postmaster@example.com",
|
||||||
|
public_url="https://mail.example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Sent:
|
||||||
|
mails: list[tuple[str, str, str]] = field(default_factory=list) # (to, subject, text)
|
||||||
|
|
||||||
|
def token(self) -> str:
|
||||||
|
text = self.mails[-1][2]
|
||||||
|
link = next(w for w in text.split() if w.startswith("https://mail.example.com/"))
|
||||||
|
assert link.startswith("https://mail.example.com/reset-password#token=")
|
||||||
|
return link.split("#token=", 1)[1]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Ctx:
|
||||||
|
session: AsyncSession
|
||||||
|
anon: httpx.AsyncClient
|
||||||
|
sent: Sent
|
||||||
|
created: list[uuid.UUID] = field(default_factory=list)
|
||||||
|
|
||||||
|
async def user(
|
||||||
|
self, *, recovery: str | None = "me@elsewhere.example", admin: bool = False
|
||||||
|
) -> User:
|
||||||
|
user = User(
|
||||||
|
handle=f"pr{uuid.uuid4().hex[:8]}@test.local",
|
||||||
|
password_hash=hash_password(PASSWORD),
|
||||||
|
recovery_email=recovery,
|
||||||
|
is_admin=admin,
|
||||||
|
)
|
||||||
|
self.session.add(user)
|
||||||
|
await self.session.commit()
|
||||||
|
self.created.append(user.id)
|
||||||
|
await get_redis().delete(f"pwreset-rate:{user.id}")
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def signed_in(self, user: User) -> httpx.AsyncClient:
|
||||||
|
token = await create_session(user.id, "pytest")
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
transport=ASGITransport(app=create_app()),
|
||||||
|
base_url="http://testserver",
|
||||||
|
cookies={"session": token, "csrf": CSRF},
|
||||||
|
headers={"X-CSRF": CSRF},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def ctx(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[Ctx]:
|
||||||
|
sent = Sent()
|
||||||
|
|
||||||
|
async def fake_send(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None:
|
||||||
|
sent.mails.append((to, subject, text))
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes, "send_mail", fake_send)
|
||||||
|
monkeypatch.setattr(admin_api, "send_mail", fake_send)
|
||||||
|
monkeypatch.setattr(routes, "mailer_config", lambda row: CFG)
|
||||||
|
|
||||||
|
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||||
|
session = async_sessionmaker(engine, expire_on_commit=False)()
|
||||||
|
anon = httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t")
|
||||||
|
c = Ctx(session=session, anon=anon, sent=sent)
|
||||||
|
try:
|
||||||
|
yield c
|
||||||
|
finally:
|
||||||
|
await anon.aclose()
|
||||||
|
await session.execute(delete(User).where(User.id.in_(c.created)))
|
||||||
|
await session.commit()
|
||||||
|
await session.close()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def _request(ctx: Ctx, handle: str) -> httpx.Response:
|
||||||
|
return await ctx.anon.post("/api/auth/password/reset-request", json={"handle": handle})
|
||||||
|
|
||||||
|
|
||||||
|
async def _login(ctx: Ctx, handle: str, password: str) -> int:
|
||||||
|
resp = await ctx.anon.post("/api/auth/login", json={"handle": handle, "password": password})
|
||||||
|
return resp.status_code
|
||||||
|
|
||||||
|
|
||||||
|
# --- requesting a link -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_mails_a_one_time_link_to_the_recovery_address(ctx: Ctx) -> None:
|
||||||
|
user = await ctx.user()
|
||||||
|
assert (await _request(ctx, user.handle)).status_code == 200
|
||||||
|
assert [m[0] for m in ctx.sent.mails] == ["me@elsewhere.example"]
|
||||||
|
token = ctx.sent.token()
|
||||||
|
|
||||||
|
check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
|
||||||
|
assert check.json() == {"valid": True, "totp_required": False}
|
||||||
|
|
||||||
|
reset = await ctx.anon.post(
|
||||||
|
"/api/auth/password/reset", json={"token": token, "password": NEW_PASSWORD}
|
||||||
|
)
|
||||||
|
assert reset.status_code == 200
|
||||||
|
# Used up: a second go fails, and the page is told so up front.
|
||||||
|
again = await ctx.anon.post(
|
||||||
|
"/api/auth/password/reset", json={"token": token, "password": "anotherPass12"}
|
||||||
|
)
|
||||||
|
assert again.json()["error"]["code"] == "invalid_token"
|
||||||
|
check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
|
||||||
|
assert check.json()["valid"] is False
|
||||||
|
|
||||||
|
assert await _login(ctx, user.handle, NEW_PASSWORD) == 200
|
||||||
|
assert await _login(ctx, user.handle, PASSWORD) == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_answer_never_says_whether_the_account_exists(ctx: Ctx) -> None:
|
||||||
|
no_recovery = await ctx.user(recovery=None)
|
||||||
|
for handle in ("nobody-here-at-all", no_recovery.handle):
|
||||||
|
resp = await _request(ctx, handle)
|
||||||
|
assert resp.status_code == 200 and resp.json() == {"status": "ok"}
|
||||||
|
assert ctx.sent.mails == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_mail_while_system_mail_is_not_set_up(
|
||||||
|
ctx: Ctx, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(routes, "mailer_config", lambda row: None)
|
||||||
|
user = await ctx.user()
|
||||||
|
assert (await _request(ctx, user.handle)).status_code == 200
|
||||||
|
assert ctx.sent.mails == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_requests_are_capped_per_hour(ctx: Ctx) -> None:
|
||||||
|
user = await ctx.user()
|
||||||
|
for _ in range(5):
|
||||||
|
assert (await _request(ctx, user.handle)).status_code == 200
|
||||||
|
assert len(ctx.sent.mails) == 3
|
||||||
|
|
||||||
|
|
||||||
|
# --- two-step verification ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_2fa_code_is_required_before_the_password_changes(ctx: Ctx) -> None:
|
||||||
|
user = await ctx.user()
|
||||||
|
secret = pyotp.random_base32()
|
||||||
|
await ctx.session.execute(
|
||||||
|
update(User).where(User.id == user.id).values(totp_secret=encrypt_totp_secret(secret))
|
||||||
|
)
|
||||||
|
await ctx.session.commit()
|
||||||
|
await _request(ctx, user.handle)
|
||||||
|
token = ctx.sent.token()
|
||||||
|
body = {"token": token, "password": NEW_PASSWORD}
|
||||||
|
|
||||||
|
check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
|
||||||
|
assert check.json()["totp_required"] is True
|
||||||
|
missing = await ctx.anon.post("/api/auth/password/reset", json=body)
|
||||||
|
assert missing.json()["error"]["code"] == "totp_required"
|
||||||
|
wrong = await ctx.anon.post("/api/auth/password/reset", json={**body, "totp": "000000"})
|
||||||
|
assert wrong.json()["error"]["code"] == "totp_invalid"
|
||||||
|
# Nothing changed yet, and the link still works.
|
||||||
|
assert await _login(ctx, user.handle, PASSWORD) == 401 # 2FA account: needs a code
|
||||||
|
good = await ctx.anon.post(
|
||||||
|
"/api/auth/password/reset", json={**body, "totp": pyotp.TOTP(secret).now()}
|
||||||
|
)
|
||||||
|
assert good.status_code == 200
|
||||||
|
login = await ctx.anon.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"handle": user.handle, "password": NEW_PASSWORD, "totp": pyotp.TOTP(secret).now()},
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_too_many_wrong_codes_burn_the_link(ctx: Ctx) -> None:
|
||||||
|
user = await ctx.user()
|
||||||
|
secret = pyotp.random_base32()
|
||||||
|
await ctx.session.execute(
|
||||||
|
update(User).where(User.id == user.id).values(totp_secret=encrypt_totp_secret(secret))
|
||||||
|
)
|
||||||
|
await ctx.session.commit()
|
||||||
|
await _request(ctx, user.handle)
|
||||||
|
token = ctx.sent.token()
|
||||||
|
body = {"token": token, "password": NEW_PASSWORD, "totp": "000000"}
|
||||||
|
for _ in range(MAX_TOTP_FAILURES):
|
||||||
|
await ctx.anon.post("/api/auth/password/reset", json=body)
|
||||||
|
late = await ctx.anon.post(
|
||||||
|
"/api/auth/password/reset", json={**body, "totp": pyotp.TOTP(secret).now()}
|
||||||
|
)
|
||||||
|
assert late.json()["error"]["code"] == "invalid_token"
|
||||||
|
|
||||||
|
|
||||||
|
# --- the recovery address ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_changing_the_recovery_email_takes_the_password(ctx: Ctx) -> None:
|
||||||
|
user = await ctx.user(recovery=None)
|
||||||
|
async with await ctx.signed_in(user) as c:
|
||||||
|
wrong = await c.put(
|
||||||
|
"/api/auth/recovery-email",
|
||||||
|
json={"password": "not-it", "recovery_email": "new@example.com"},
|
||||||
|
)
|
||||||
|
assert wrong.status_code == 403
|
||||||
|
bad = await c.put(
|
||||||
|
"/api/auth/recovery-email", json={"password": PASSWORD, "recovery_email": "nope"}
|
||||||
|
)
|
||||||
|
assert bad.json()["error"]["code"] == "invalid_email"
|
||||||
|
ok = await c.put(
|
||||||
|
"/api/auth/recovery-email",
|
||||||
|
json={"password": PASSWORD, "recovery_email": " New@Example.com "},
|
||||||
|
)
|
||||||
|
assert ok.json()["recovery_email"] == "new@example.com"
|
||||||
|
removed = await c.put(
|
||||||
|
"/api/auth/recovery-email", json={"password": PASSWORD, "recovery_email": ""}
|
||||||
|
)
|
||||||
|
assert removed.json()["recovery_email"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- admin: system mail settings -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def restore_settings(ctx: Ctx) -> AsyncIterator[None]:
|
||||||
|
row = await app_settings(ctx.session)
|
||||||
|
saved = {
|
||||||
|
k: getattr(row, k)
|
||||||
|
for k in (
|
||||||
|
"smtp_host",
|
||||||
|
"smtp_port",
|
||||||
|
"smtp_security",
|
||||||
|
"smtp_username",
|
||||||
|
"smtp_secret_ct",
|
||||||
|
"smtp_from",
|
||||||
|
"public_url",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await ctx.session.commit()
|
||||||
|
yield
|
||||||
|
await ctx.session.execute(update(AppSettings).values(**saved))
|
||||||
|
await ctx.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_configures_system_mail_without_ever_seeing_the_password(
|
||||||
|
ctx: Ctx, restore_settings: None
|
||||||
|
) -> None:
|
||||||
|
admin = await ctx.user(admin=True)
|
||||||
|
async with await ctx.signed_in(admin) as c:
|
||||||
|
resp = await c.put(
|
||||||
|
"/api/admin/mail",
|
||||||
|
json={
|
||||||
|
"smtp_host": "smtp.example.com",
|
||||||
|
"smtp_port": 465,
|
||||||
|
"smtp_security": "ssl",
|
||||||
|
"smtp_username": "postmaster@example.com",
|
||||||
|
"smtp_password": "s3cret",
|
||||||
|
"smtp_from": "postmaster@example.com",
|
||||||
|
"public_url": "https://mail.example.com/",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
out = resp.json()
|
||||||
|
assert out["has_password"] is True and out["configured"] is True
|
||||||
|
assert out["public_url"] == "https://mail.example.com"
|
||||||
|
assert "s3cret" not in resp.text
|
||||||
|
# Omitting the password keeps it; an empty one removes it.
|
||||||
|
kept = (await c.put("/api/admin/mail", json={"smtp_port": 587})).json()
|
||||||
|
assert kept["has_password"] is True
|
||||||
|
gone = (await c.put("/api/admin/mail", json={"smtp_password": ""})).json()
|
||||||
|
assert gone["has_password"] is False
|
||||||
|
bad = await c.put("/api/admin/mail", json={"public_url": "mail.example.com"})
|
||||||
|
assert bad.json()["error"]["code"] == "invalid_url"
|
||||||
|
test = await c.post("/api/admin/mail/test", json={"to": "me@example.com"})
|
||||||
|
assert test.status_code == 200
|
||||||
|
assert ctx.sent.mails[-1][:2] == ("me@example.com", "Ultimo Email test message")
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
|
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
|
||||||
<title>Webmail admin</title>
|
<title>Ultimo Email admin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
|
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
|
||||||
<title>Webmail</title>
|
<title>Ultimo Email</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { useMe } from "./lib/auth";
|
|||||||
import { connectWs } from "./lib/ws";
|
import { connectWs } from "./lib/ws";
|
||||||
import { PluginPage } from "./plugins/PluginPage";
|
import { PluginPage } from "./plugins/PluginPage";
|
||||||
import { ComposeWindow } from "./routes/ComposeWindow";
|
import { ComposeWindow } from "./routes/ComposeWindow";
|
||||||
|
import { ForgotPassword } from "./routes/ForgotPassword";
|
||||||
import { Login } from "./routes/Login";
|
import { Login } from "./routes/Login";
|
||||||
import { MailLayout } from "./routes/MailLayout";
|
import { MailLayout } from "./routes/MailLayout";
|
||||||
import { Register } from "./routes/Register";
|
import { Register } from "./routes/Register";
|
||||||
|
import { ResetPassword } from "./routes/ResetPassword";
|
||||||
import { Settings } from "./routes/settings/Settings";
|
import { Settings } from "./routes/settings/Settings";
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||||
@@ -35,6 +37,8 @@ export function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route
|
<Route
|
||||||
path="/mail/*"
|
path="/mail/*"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
import { api, ApiError } from "../lib/api";
|
import { api, ApiError } from "../lib/api";
|
||||||
import { useMe } from "../lib/auth";
|
import { useMe } from "../lib/auth";
|
||||||
|
import type { AdminMail, AdminUser } from "../lib/types";
|
||||||
|
|
||||||
const GIB = 1024 ** 3;
|
const GIB = 1024 ** 3;
|
||||||
|
|
||||||
@@ -28,11 +29,24 @@ export function AdminApp() {
|
|||||||
<header className="border-b border-app-border px-6 py-3">
|
<header className="border-b border-app-border px-6 py-3">
|
||||||
<h1 className="font-semibold">{t("admin.title")}</h1>
|
<h1 className="font-semibold">{t("admin.title")}</h1>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-2xl px-6 py-10">
|
{/* Two columns once there is room: instance settings and users on the
|
||||||
|
left, outgoing system mail on the right. Signed out, it stays narrow. */}
|
||||||
|
<main className={`mx-auto px-6 py-10 ${me.data?.is_admin ? "max-w-6xl" : "max-w-2xl"}`}>
|
||||||
{me.isPending && <p className="text-sm text-app-muted">{t("common.loading")}</p>}
|
{me.isPending && <p className="text-sm text-app-muted">{t("common.loading")}</p>}
|
||||||
{!me.isPending && !me.data && <SignIn />}
|
{!me.isPending && !me.data && <SignIn />}
|
||||||
{me.data && !me.data.is_admin && <NotAdmin handle={me.data.handle} />}
|
{me.data && !me.data.is_admin && <NotAdmin handle={me.data.handle} />}
|
||||||
{me.data?.is_admin && <Panel handle={me.data.handle} />}
|
{me.data?.is_admin && (
|
||||||
|
<div className="grid gap-y-10 lg:grid-cols-2">
|
||||||
|
<div className="min-w-0 lg:pr-12">
|
||||||
|
<Panel handle={me.data.handle} />
|
||||||
|
<Users selfId={me.data.id} />
|
||||||
|
</div>
|
||||||
|
{/* The divider runs the full height of the taller column. */}
|
||||||
|
<div className="min-w-0 lg:border-l lg:border-app-border lg:pl-12">
|
||||||
|
<SystemMail />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -235,3 +249,487 @@ function Panel({ handle }: { handle: string }) {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MailDraft = Omit<AdminMail, "has_password" | "configured">;
|
||||||
|
|
||||||
|
/** The SMTP account the server sends password-reset links from. */
|
||||||
|
function SystemMail() {
|
||||||
|
const t = useT();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const mail = useQuery({ queryKey: ["adminMail"], queryFn: api.adminMail });
|
||||||
|
// Edits are a draft until Save; null means "show what the server has".
|
||||||
|
const [draft, setDraft] = useState<MailDraft | null>(null);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [testTo, setTestTo] = useState("");
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (body: Parameters<typeof api.saveAdminMail>[0]) => api.saveAdminMail(body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(["adminMail"], data);
|
||||||
|
setDraft(null);
|
||||||
|
setPassword("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const test = useMutation({ mutationFn: () => api.testAdminMail(testTo.trim()) });
|
||||||
|
|
||||||
|
if (!mail.data) {
|
||||||
|
return mail.error instanceof ApiError ? (
|
||||||
|
<p className="text-sm text-red-500">{mail.error.message}</p>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
const server = mail.data;
|
||||||
|
const value: MailDraft = draft ?? {
|
||||||
|
smtp_host: server.smtp_host,
|
||||||
|
smtp_port: server.smtp_port,
|
||||||
|
smtp_security: server.smtp_security,
|
||||||
|
smtp_username: server.smtp_username,
|
||||||
|
smtp_from: server.smtp_from,
|
||||||
|
public_url: server.public_url,
|
||||||
|
};
|
||||||
|
const set = (patch: Partial<MailDraft>) => setDraft({ ...value, ...patch });
|
||||||
|
const dirty = draft !== null || password !== "";
|
||||||
|
const field =
|
||||||
|
"w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none";
|
||||||
|
const label = "block space-y-1 text-sm";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold">{t("admin.mail")}</h2>
|
||||||
|
<p className="text-xs text-app-faint">{t("admin.mailHelp")}</p>
|
||||||
|
<p className={`text-sm ${server.configured ? "text-emerald-600" : "text-amber-600"}`}>
|
||||||
|
{server.configured ? t("admin.mailOn") : t("admin.mailOff")}
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
className="space-y-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
save.mutate({ ...value, ...(password ? { smtp_password: password } : {}) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-[1fr_6rem_8rem] gap-2">
|
||||||
|
<label className={label}>
|
||||||
|
<span className="text-app-muted">{t("admin.smtpHost")}</span>
|
||||||
|
<input
|
||||||
|
className={field}
|
||||||
|
value={value.smtp_host}
|
||||||
|
placeholder="smtp.example.com"
|
||||||
|
onChange={(e) => set({ smtp_host: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={label}>
|
||||||
|
<span className="text-app-muted">{t("admin.smtpPort")}</span>
|
||||||
|
<input
|
||||||
|
className={field}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
value={value.smtp_port}
|
||||||
|
onChange={(e) => set({ smtp_port: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={label}>
|
||||||
|
<span className="text-app-muted">{t("admin.smtpSecurity")}</span>
|
||||||
|
<select
|
||||||
|
className={field}
|
||||||
|
value={value.smtp_security}
|
||||||
|
onChange={(e) => {
|
||||||
|
const security = e.target.value as MailDraft["smtp_security"];
|
||||||
|
// Follow the usual port for the mode, unless a custom one was typed.
|
||||||
|
const usual = { starttls: 587, ssl: 465, none: 25 };
|
||||||
|
const port = Object.values(usual).includes(value.smtp_port) ? usual[security] : value.smtp_port;
|
||||||
|
set({ smtp_security: security, smtp_port: port });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="starttls">{t("admin.smtpStarttls")}</option>
|
||||||
|
<option value="ssl">{t("admin.smtpSsl")}</option>
|
||||||
|
<option value="none">{t("admin.smtpNone")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<label className={label}>
|
||||||
|
<span className="text-app-muted">{t("admin.smtpUsername")}</span>
|
||||||
|
<input
|
||||||
|
className={field}
|
||||||
|
autoComplete="off"
|
||||||
|
value={value.smtp_username}
|
||||||
|
placeholder="postmaster@example.com"
|
||||||
|
onChange={(e) => set({ smtp_username: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={label}>
|
||||||
|
<span className="text-app-muted">{t("admin.smtpPassword")}</span>
|
||||||
|
<input
|
||||||
|
className={field}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
placeholder={server.has_password ? t("admin.smtpPasswordKept") : ""}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className={label}>
|
||||||
|
<span className="text-app-muted">{t("admin.smtpFrom")}</span>
|
||||||
|
<input
|
||||||
|
className={field}
|
||||||
|
type="email"
|
||||||
|
value={value.smtp_from}
|
||||||
|
placeholder="postmaster@example.com"
|
||||||
|
onChange={(e) => set({ smtp_from: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={label}>
|
||||||
|
<span className="text-app-muted">{t("admin.publicUrl")}</span>
|
||||||
|
<input
|
||||||
|
className={field}
|
||||||
|
value={value.public_url}
|
||||||
|
placeholder="https://mail.example.com"
|
||||||
|
onChange={(e) => set({ public_url: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="block text-xs text-app-faint">{t("admin.publicUrlHint")}</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{server.has_password ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-app-muted underline hover:text-app-fg disabled:opacity-50"
|
||||||
|
disabled={save.isPending}
|
||||||
|
onClick={() => save.mutate({ smtp_password: "" })}
|
||||||
|
>
|
||||||
|
{t("admin.smtpPasswordRemove")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-app-accent px-3 py-1 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||||
|
disabled={!dirty || save.isPending}
|
||||||
|
>
|
||||||
|
{save.isPending ? t("admin.saving") : t("admin.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{save.error instanceof ApiError && <p className="text-xs text-red-500">{save.error.message}</p>}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="flex items-center gap-2 border-t border-app-border pt-3 text-sm"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
test.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="shrink-0 text-app-muted">{t("admin.testTo")}</span>
|
||||||
|
<input
|
||||||
|
className={field}
|
||||||
|
type="email"
|
||||||
|
value={testTo}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
onChange={(e) => {
|
||||||
|
setTestTo(e.target.value);
|
||||||
|
test.reset();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="shrink-0 rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
|
||||||
|
disabled={!server.configured || !testTo.trim() || test.isPending || dirty}
|
||||||
|
>
|
||||||
|
{test.isPending ? t("auth.sending") : t("admin.sendTest")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{test.isSuccess && <p className="text-xs text-emerald-600">{t("admin.testSent")}</p>}
|
||||||
|
{test.error instanceof ApiError && <p className="text-xs text-red-500">{test.error.message}</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes >= GIB) return `${Number((bytes / GIB).toFixed(2))} GiB`;
|
||||||
|
return `${Number((bytes / 1024 ** 2).toFixed(1))} MiB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a user by login, mail address or name, and open them to manage. */
|
||||||
|
function Users({ selfId }: { selfId: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const [term, setTerm] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Search as you type, but not on every keystroke.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setTimeout(() => setQuery(term.trim()), 250);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [term]);
|
||||||
|
|
||||||
|
// Only search results are listed: nothing until something is typed.
|
||||||
|
const users = useQuery({
|
||||||
|
queryKey: ["adminUsers", query],
|
||||||
|
queryFn: () => api.adminUsers(query),
|
||||||
|
enabled: query !== "",
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
const results = query ? users.data : undefined;
|
||||||
|
const open = results?.find((u) => u.id === openId) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-10 space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold">{t("admin.users")}</h2>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
type="search"
|
||||||
|
placeholder={t("admin.userSearch")}
|
||||||
|
value={term}
|
||||||
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
{users.error instanceof ApiError && (
|
||||||
|
<p className="text-sm text-red-500">{users.error.message}</p>
|
||||||
|
)}
|
||||||
|
{results && results.length === 0 && (
|
||||||
|
<p className="text-sm text-app-muted">{t("admin.noUsers")}</p>
|
||||||
|
)}
|
||||||
|
{results && results.length > 0 && (
|
||||||
|
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
|
||||||
|
{results.map((u) => (
|
||||||
|
<li key={u.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenId(u.id)}
|
||||||
|
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-app-hover"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="truncate font-medium">{u.handle}</span>
|
||||||
|
<Badges user={u} self={u.id === selfId} />
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-app-muted">
|
||||||
|
{u.accounts.length ? u.accounts.join(", ") : t("admin.noMailAccounts")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs tabular-nums text-app-muted">
|
||||||
|
{t("admin.usage", {
|
||||||
|
used: formatBytes(u.used_bytes),
|
||||||
|
quota: formatBytes(u.quota_bytes),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{open && <UserDialog user={open} self={open.id === selfId} onClose={() => setOpenId(null)} />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badges({ user, self }: { user: AdminUser; self: boolean }) {
|
||||||
|
const t = useT();
|
||||||
|
const badge = "shrink-0 rounded px-1.5 text-[11px] font-medium";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{self && <span className={`${badge} bg-app-hover text-app-muted`}>{t("admin.you")}</span>}
|
||||||
|
{user.is_admin && (
|
||||||
|
<span className={`${badge} bg-app-accent/15 text-app-accent`}>{t("admin.adminBadge")}</span>
|
||||||
|
)}
|
||||||
|
{user.blocked_at && (
|
||||||
|
<span className={`${badge} bg-red-500/15 text-red-500`}>{t("admin.blockedBadge")}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One user: change their quota, block or unblock them, or delete them. */
|
||||||
|
function UserDialog({
|
||||||
|
user,
|
||||||
|
self,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
user: AdminUser;
|
||||||
|
self: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [quotaDraft, setQuotaDraft] = useState<string | null>(null);
|
||||||
|
const [confirm, setConfirm] = useState<null | "block" | "delete">(null);
|
||||||
|
|
||||||
|
const refresh = () => queryClient.invalidateQueries({ queryKey: ["adminUsers"] });
|
||||||
|
const update = useMutation({
|
||||||
|
mutationFn: (body: { quota_bytes?: number; blocked?: boolean }) =>
|
||||||
|
api.updateAdminUser(user.id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
setQuotaDraft(null);
|
||||||
|
setConfirm(null);
|
||||||
|
void refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: () => api.deleteAdminUser(user.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
onClose();
|
||||||
|
void refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
if (confirm) setConfirm(null);
|
||||||
|
else onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [confirm, onClose]);
|
||||||
|
|
||||||
|
const serverGib = user.quota_bytes / GIB;
|
||||||
|
const quotaValue = quotaDraft ?? String(Number(serverGib.toFixed(3)));
|
||||||
|
const quotaGib = Number(quotaValue);
|
||||||
|
const quotaValid = Number.isFinite(quotaGib) && quotaGib > 0;
|
||||||
|
const quotaDirty = quotaDraft !== null && quotaGib !== serverGib;
|
||||||
|
const busy = update.isPending || remove.isPending;
|
||||||
|
const error = [update.error, remove.error].find((e) => e instanceof ApiError) as
|
||||||
|
| ApiError
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={user.handle}
|
||||||
|
className="relative w-full max-w-md rounded-xl border border-app-border bg-app-elevated p-5 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="flex items-center gap-1.5 font-semibold">
|
||||||
|
<span className="truncate">{user.handle}</span>
|
||||||
|
<Badges user={user} self={self} />
|
||||||
|
</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-app-muted">
|
||||||
|
{user.accounts.length ? user.accounts.join(", ") : t("admin.noMailAccounts")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
title={t("admin.close")}
|
||||||
|
aria-label={t("admin.close")}
|
||||||
|
className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden>
|
||||||
|
<path d="M4 4l8 8M12 4l-8 8" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-y border-app-border py-3">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="text-app-muted">{t("admin.userQuota")}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="w-24 rounded border border-app-border bg-app-surface px-2 py-1 text-right focus:border-app-accent focus:outline-none"
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
step="0.5"
|
||||||
|
value={quotaValue}
|
||||||
|
onChange={(e) => setQuotaDraft(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-app-muted">GiB</span>
|
||||||
|
<button
|
||||||
|
className="rounded bg-app-accent px-3 py-1 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||||
|
disabled={!quotaDirty || !quotaValid || busy}
|
||||||
|
onClick={() => update.mutate({ quota_bytes: Math.round(quotaGib * GIB) })}
|
||||||
|
>
|
||||||
|
{update.isPending && quotaDirty ? t("admin.saving") : t("admin.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-app-faint">
|
||||||
|
{t("admin.usage", {
|
||||||
|
used: formatBytes(user.used_bytes),
|
||||||
|
quota: formatBytes(user.quota_bytes),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{!quotaValid && <p className="mt-1 text-xs text-red-500">{t("admin.quotaInvalid")}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.blocked_at && (
|
||||||
|
<p className="mt-3 text-sm text-red-500">
|
||||||
|
{t("admin.blockedSince", { date: new Date(user.blocked_at).toLocaleString() })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{self ? (
|
||||||
|
<p className="mt-3 text-xs text-app-muted">{t("admin.selfNote")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 flex flex-wrap justify-end gap-2">
|
||||||
|
{user.blocked_at ? (
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-app-border px-3 py-1.5 text-sm hover:bg-app-hover disabled:opacity-50"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => update.mutate({ blocked: false })}
|
||||||
|
>
|
||||||
|
{t("admin.unblock")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-app-border px-3 py-1.5 text-sm hover:bg-app-hover disabled:opacity-50"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setConfirm("block")}
|
||||||
|
>
|
||||||
|
{t("admin.block")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setConfirm("delete")}
|
||||||
|
>
|
||||||
|
{t("admin.deleteUser")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="mt-2 text-xs text-red-500">{error.message}</p>}
|
||||||
|
|
||||||
|
{confirm && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/40 p-4"
|
||||||
|
onClick={() => setConfirm(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
className="w-full max-w-sm rounded-lg border border-app-border bg-app-elevated p-4 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<p className="mb-4 text-sm">
|
||||||
|
{t(confirm === "block" ? "admin.blockConfirm" : "admin.deleteConfirm", {
|
||||||
|
handle: user.handle,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm text-app-muted hover:bg-app-hover"
|
||||||
|
onClick={() => setConfirm(null)}
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
{t("admin.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
confirm === "block" ? update.mutate({ blocked: true }) : remove.mutate()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(confirm === "block" ? "admin.block" : "admin.deleteUser")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useId, useState } from "react";
|
||||||
|
|
||||||
|
// Keystrokes and IME composition; everything else that changes the field — a
|
||||||
|
// browser or password-manager autofill (an input event without an inputType, or
|
||||||
|
// "insertReplacementText"), a paste, a drop — is not the user typing.
|
||||||
|
const TYPED = /^(insertText|insertCompositionText|insertLineBreak|delete)/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A password field that only accepts what is typed into it: for confirmations
|
||||||
|
* where the point is that the person present knows the password, which an
|
||||||
|
* autofilled saved password does not prove.
|
||||||
|
*
|
||||||
|
* `autoComplete="new-password"` and the password managers' opt-out attributes
|
||||||
|
* keep most autofill away to begin with; whatever still gets in is thrown out
|
||||||
|
* here, and the field says to type it.
|
||||||
|
*/
|
||||||
|
export function TypedPasswordInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
/** Shown when a fill or paste was refused. */
|
||||||
|
hint: string;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
const [refused, setRefused] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className={className}
|
||||||
|
type="password"
|
||||||
|
// A fresh name per field, so nothing matches it to a saved login form.
|
||||||
|
name={`typed-${id}`}
|
||||||
|
autoComplete="new-password"
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore
|
||||||
|
data-form-type="other"
|
||||||
|
value={value}
|
||||||
|
onPaste={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setRefused(true);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setRefused(true);
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
const inputType = (e.nativeEvent as InputEvent).inputType ?? "";
|
||||||
|
if (!TYPED.test(inputType)) {
|
||||||
|
onChange("");
|
||||||
|
setRefused(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRefused(false);
|
||||||
|
onChange(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{refused && <span className="block text-xs text-amber-600">{hint}</span>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+72
-2
@@ -3,7 +3,7 @@
|
|||||||
// Interpolate with {name} placeholders.
|
// Interpolate with {name} placeholders.
|
||||||
|
|
||||||
export const en = {
|
export const en = {
|
||||||
"common.appName": "Webmail",
|
"common.appName": "Ultimo Email",
|
||||||
"common.loading": "Loading…",
|
"common.loading": "Loading…",
|
||||||
"common.cancel": "Cancel",
|
"common.cancel": "Cancel",
|
||||||
"common.save": "Save",
|
"common.save": "Save",
|
||||||
@@ -34,6 +34,26 @@ export const en = {
|
|||||||
"auth.create": "Create account",
|
"auth.create": "Create account",
|
||||||
"auth.creating": "Creating…",
|
"auth.creating": "Creating…",
|
||||||
"auth.noAccount": "No account?",
|
"auth.noAccount": "No account?",
|
||||||
|
"auth.forgotPassword": "Forgot password?",
|
||||||
|
"auth.forgotTitle": "Reset your password",
|
||||||
|
"auth.forgotHint":
|
||||||
|
"Enter your user name. We'll email a link for choosing a new password to the recovery email address of the account.",
|
||||||
|
"auth.changePassword": "Change password",
|
||||||
|
"auth.sending": "Sending…",
|
||||||
|
"auth.forgotSent": "If this account exists you will receive an email to your recovery email address.",
|
||||||
|
"auth.backToSignIn": "Back to sign in",
|
||||||
|
"auth.resetTitle": "Choose a new password",
|
||||||
|
"auth.newPassword": "New password",
|
||||||
|
"auth.repeatPassword": "Repeat the new password",
|
||||||
|
"auth.passwordsDiffer": "The two passwords are different.",
|
||||||
|
"auth.passwordTooShort": "Use at least 8 characters.",
|
||||||
|
"auth.resetTotpPrompt":
|
||||||
|
"This account uses 2-step verification. Enter the 6-digit code from your authenticator app to finish.",
|
||||||
|
"auth.confirm": "Confirm",
|
||||||
|
"auth.resetDone": "Your password has been changed. You have been signed out everywhere — sign in with the new password.",
|
||||||
|
"auth.resetInvalid":
|
||||||
|
"This link is invalid, was already used, or has expired. Request a new one to change your password.",
|
||||||
|
"auth.requestNewLink": "Request a new link",
|
||||||
"auth.haveAccount": "Already have an account?",
|
"auth.haveAccount": "Already have an account?",
|
||||||
"auth.register": "Register",
|
"auth.register": "Register",
|
||||||
"auth.checking": "Checking…",
|
"auth.checking": "Checking…",
|
||||||
@@ -327,6 +347,15 @@ export const en = {
|
|||||||
"settings.appearance": "Appearance",
|
"settings.appearance": "Appearance",
|
||||||
"settings.handle": "Handle",
|
"settings.handle": "Handle",
|
||||||
"settings.recoveryEmail": "Recovery email",
|
"settings.recoveryEmail": "Recovery email",
|
||||||
|
"settings.recoveryEmailHint":
|
||||||
|
"If you forget your password, a link to choose a new one is sent here.",
|
||||||
|
"settings.recoveryEmailNew": "Recovery email address",
|
||||||
|
"settings.recoveryEmailRemoveHint": "Leave empty to remove it.",
|
||||||
|
"settings.currentPassword": "Current password",
|
||||||
|
"settings.typePassword": "Type your password — for this confirmation it can't be filled in or pasted.",
|
||||||
|
"settings.change": "Change",
|
||||||
|
"settings.add": "Add",
|
||||||
|
"settings.saving": "Saving…",
|
||||||
"settings.twoFactor": "Two-factor authentication",
|
"settings.twoFactor": "Two-factor authentication",
|
||||||
"settings.twoFactorOn": "2FA is enabled.",
|
"settings.twoFactorOn": "2FA is enabled.",
|
||||||
"settings.twoFactorOff": "2FA is not enabled.",
|
"settings.twoFactorOff": "2FA is not enabled.",
|
||||||
@@ -412,7 +441,7 @@ export const en = {
|
|||||||
"settings.alsoFile": "Also mark the message as read and file it",
|
"settings.alsoFile": "Also mark the message as read and file it",
|
||||||
"settings.integrations": "Integrations",
|
"settings.integrations": "Integrations",
|
||||||
|
|
||||||
"admin.title": "Webmail administration",
|
"admin.title": "Ultimo Email administration",
|
||||||
"admin.signInTitle": "Administrator sign-in",
|
"admin.signInTitle": "Administrator sign-in",
|
||||||
"admin.signInHint": "Sign in with an administrator account to manage this server.",
|
"admin.signInHint": "Sign in with an administrator account to manage this server.",
|
||||||
"admin.signOut": "Sign out",
|
"admin.signOut": "Sign out",
|
||||||
@@ -432,6 +461,47 @@ export const en = {
|
|||||||
"admin.saving": "Saving…",
|
"admin.saving": "Saving…",
|
||||||
"admin.saved": "Saved",
|
"admin.saved": "Saved",
|
||||||
"admin.quotaInvalid": "Enter a size greater than 0.",
|
"admin.quotaInvalid": "Enter a size greater than 0.",
|
||||||
|
"admin.mail": "Outgoing system mail",
|
||||||
|
"admin.mailHelp":
|
||||||
|
"The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.",
|
||||||
|
"admin.mailOn": "Set up — password-reset links can be sent.",
|
||||||
|
"admin.mailOff": "Not set up — password-reset links cannot be sent yet.",
|
||||||
|
"admin.smtpHost": "SMTP server",
|
||||||
|
"admin.smtpPort": "Port",
|
||||||
|
"admin.smtpSecurity": "Security",
|
||||||
|
"admin.smtpStarttls": "STARTTLS",
|
||||||
|
"admin.smtpSsl": "SSL/TLS",
|
||||||
|
"admin.smtpNone": "None",
|
||||||
|
"admin.smtpUsername": "Login",
|
||||||
|
"admin.smtpPassword": "Password",
|
||||||
|
"admin.smtpPasswordKept": "(stored — leave empty to keep)",
|
||||||
|
"admin.smtpPasswordRemove": "Remove the stored password",
|
||||||
|
"admin.smtpFrom": "From address",
|
||||||
|
"admin.publicUrl": "Public address of this server",
|
||||||
|
"admin.publicUrlHint": "Where users open Ultimo Email, e.g. https://mail.example.com — reset links point here.",
|
||||||
|
"admin.testTo": "Send a test email to",
|
||||||
|
"admin.sendTest": "Send test",
|
||||||
|
"admin.testSent": "Test email sent.",
|
||||||
|
"admin.users": "Users",
|
||||||
|
"admin.userSearch": "Search by login, email address or name…",
|
||||||
|
"admin.noUsers": "No users match.",
|
||||||
|
"admin.you": "you",
|
||||||
|
"admin.adminBadge": "admin",
|
||||||
|
"admin.blockedBadge": "blocked",
|
||||||
|
"admin.usage": "{used} of {quota}",
|
||||||
|
"admin.noMailAccounts": "No mail accounts connected",
|
||||||
|
"admin.userQuota": "Quota",
|
||||||
|
"admin.blockedSince": "Blocked since {date}. This user cannot sign in.",
|
||||||
|
"admin.block": "Block account",
|
||||||
|
"admin.unblock": "Unblock account",
|
||||||
|
"admin.blockConfirm":
|
||||||
|
"Block {handle}? They are signed out everywhere and cannot sign in until you unblock them. Their mail and data are kept.",
|
||||||
|
"admin.deleteUser": "Delete account",
|
||||||
|
"admin.deleteConfirm":
|
||||||
|
"Permanently delete {handle} and all of their mail, contacts, calendars and settings? This can't be undone.",
|
||||||
|
"admin.selfNote": "This is your own account: it cannot be blocked or deleted from here.",
|
||||||
|
"admin.cancel": "Cancel",
|
||||||
|
"admin.close": "Close",
|
||||||
"settings.jitsi": "Jitsi Meet",
|
"settings.jitsi": "Jitsi Meet",
|
||||||
"settings.jitsiHint":
|
"settings.jitsiHint":
|
||||||
"Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.",
|
"Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.",
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
AccountDetail,
|
AccountDetail,
|
||||||
|
AdminMail,
|
||||||
AdminSettings,
|
AdminSettings,
|
||||||
|
AdminUser,
|
||||||
AccountOut,
|
AccountOut,
|
||||||
AttachmentOut,
|
AttachmentOut,
|
||||||
CalendarOut,
|
CalendarOut,
|
||||||
@@ -83,6 +85,38 @@ export const api = {
|
|||||||
// PUT merges: send only the fields you are changing.
|
// PUT merges: send only the fields you are changing.
|
||||||
saveAdminSettings: (body: Partial<AdminSettings>) =>
|
saveAdminSettings: (body: Partial<AdminSettings>) =>
|
||||||
apiFetch<AdminSettings>("/admin/settings", { method: "PUT", body: JSON.stringify(body) }),
|
apiFetch<AdminSettings>("/admin/settings", { method: "PUT", body: JSON.stringify(body) }),
|
||||||
|
adminUsers: (q: string) => apiFetch<AdminUser[]>(`/admin/users?q=${encodeURIComponent(q)}`),
|
||||||
|
// PATCH merges: send only the fields you are changing.
|
||||||
|
updateAdminUser: (id: string, body: { quota_bytes?: number; blocked?: boolean }) =>
|
||||||
|
apiFetch<AdminUser>(`/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
deleteAdminUser: (id: string) =>
|
||||||
|
apiFetch<{ status: string }>(`/admin/users/${id}`, { method: "DELETE" }),
|
||||||
|
adminMail: () => apiFetch<AdminMail>("/admin/mail"),
|
||||||
|
// PUT merges: omit smtp_password to keep the stored one, "" to remove it.
|
||||||
|
saveAdminMail: (body: Partial<Omit<AdminMail, "has_password" | "configured">> & { smtp_password?: string }) =>
|
||||||
|
apiFetch<AdminMail>("/admin/mail", { method: "PUT", body: JSON.stringify(body) }),
|
||||||
|
testAdminMail: (to: string) =>
|
||||||
|
apiFetch<{ status: string }>("/admin/mail/test", { method: "POST", body: JSON.stringify({ to }) }),
|
||||||
|
passwordResetRequest: (handle: string) =>
|
||||||
|
apiFetch<{ status: string }>("/auth/password/reset-request", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ handle }),
|
||||||
|
}),
|
||||||
|
passwordResetCheck: (token: string) =>
|
||||||
|
apiFetch<{ valid: boolean; totp_required: boolean }>("/auth/password/reset/check", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
}),
|
||||||
|
passwordReset: (token: string, password: string, totp?: string) =>
|
||||||
|
apiFetch<{ status: string }>("/auth/password/reset", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ token, password, totp }),
|
||||||
|
}),
|
||||||
|
setRecoveryEmail: (password: string, recovery_email: string | null) =>
|
||||||
|
apiFetch<UserOut>("/auth/recovery-email", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ password, recovery_email }),
|
||||||
|
}),
|
||||||
registrationStatus: () => apiFetch<RegistrationStatus>("/auth/registration"),
|
registrationStatus: () => apiFetch<RegistrationStatus>("/auth/registration"),
|
||||||
register: (localpart: string, password: string) =>
|
register: (localpart: string, password: string) =>
|
||||||
apiFetch<UserOut>("/auth/register", {
|
apiFetch<UserOut>("/auth/register", {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { type QueryClient, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api, ApiError } from "./api";
|
import { api, ApiError } from "./api";
|
||||||
|
|
||||||
@@ -13,3 +13,21 @@ export function useMe() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server no longer knows this session — signed out elsewhere, or an
|
||||||
|
* administrator blocked or deleted the account. Drop everything cached and let
|
||||||
|
* the auth guard send the tab to the sign-in screen.
|
||||||
|
*/
|
||||||
|
export function signedOut(queryClient: QueryClient) {
|
||||||
|
if (!queryClient.getQueryData(["me"])) return; // already signed out
|
||||||
|
// "me" first, in place: its watchers see it and route to sign-in. Clearing the
|
||||||
|
// whole cache first would orphan them from the query they watch.
|
||||||
|
queryClient.setQueryData(["me"], null);
|
||||||
|
queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an error means "this session is gone" (vs. a wrong password, etc.). */
|
||||||
|
export function isSessionGone(error: unknown): boolean {
|
||||||
|
return error instanceof ApiError && error.status === 401 && error.code === "not_authenticated";
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,31 @@ export interface UserOut {
|
|||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
handle: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
/** ISO timestamp while an administrator has blocked the account. */
|
||||||
|
blocked_at: string | null;
|
||||||
|
quota_bytes: number;
|
||||||
|
used_bytes: number;
|
||||||
|
created_at: string;
|
||||||
|
/** The user's connected mail accounts. */
|
||||||
|
accounts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outgoing system mail (password-reset links). The password is never sent back. */
|
||||||
|
export interface AdminMail {
|
||||||
|
smtp_host: string;
|
||||||
|
smtp_port: number;
|
||||||
|
smtp_security: "starttls" | "ssl" | "none";
|
||||||
|
smtp_username: string;
|
||||||
|
has_password: boolean;
|
||||||
|
smtp_from: string;
|
||||||
|
public_url: string;
|
||||||
|
configured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminSettings {
|
export interface AdminSettings {
|
||||||
registration_open: boolean;
|
registration_open: boolean;
|
||||||
/** Quota given to users created from now on; existing mailboxes keep theirs. */
|
/** Quota given to users created from now on; existing mailboxes keep theirs. */
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { signedOut } from "./auth";
|
||||||
|
|
||||||
const frameSchema = z.object({
|
const frameSchema = z.object({
|
||||||
event: z.string(),
|
event: z.string(),
|
||||||
payload: z.record(z.unknown()),
|
payload: z.record(z.unknown()),
|
||||||
@@ -20,6 +22,7 @@ const ALLOWED_EVENTS = new Set([
|
|||||||
"account.state_changed",
|
"account.state_changed",
|
||||||
"draft.saved",
|
"draft.saved",
|
||||||
"outbox.state_changed",
|
"outbox.state_changed",
|
||||||
|
"user.signed_out",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Lightweight in-process fan-out of WS frames to plugin subscribers (SPEC §11.3).
|
// Lightweight in-process fan-out of WS frames to plugin subscribers (SPEC §11.3).
|
||||||
@@ -82,6 +85,12 @@ export function connectWs(queryClient: QueryClient): () => void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isCore) return;
|
if (!isCore) return;
|
||||||
|
if (name === "user.signed_out") {
|
||||||
|
// An administrator blocked or deleted this account: leave now, rather
|
||||||
|
// than keep showing mail that was already loaded.
|
||||||
|
signedOut(queryClient);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Coarse invalidation: refetch affected lists (SPEC §9.2 contract).
|
// Coarse invalidation: refetch affected lists (SPEC §9.2 contract).
|
||||||
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
|
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||||
|
|||||||
+10
-1
@@ -1,15 +1,24 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { I18nProvider } from "./i18n";
|
import { I18nProvider } from "./i18n";
|
||||||
|
import { isSessionGone, signedOut } from "./lib/auth";
|
||||||
import { ThemeProvider } from "./lib/theme";
|
import { ThemeProvider } from "./lib/theme";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3)
|
import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3)
|
||||||
|
|
||||||
|
// Any request answered "not signed in" means the session is gone (signed out
|
||||||
|
// elsewhere, or the account was blocked): go to sign-in instead of leaving the
|
||||||
|
// page up with failing requests behind it.
|
||||||
|
const onError = (error: unknown) => {
|
||||||
|
if (isSessionGone(error)) signedOut(queryClient);
|
||||||
|
};
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
queryCache: new QueryCache({ onError }),
|
||||||
|
mutationCache: new MutationCache({ onError }),
|
||||||
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
</button>
|
||||||
{showError && <p className="text-sm text-red-500">{error.message}</p>}
|
{showError && <p className="text-sm text-red-500">{error.message}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
<p className="mt-4 text-sm">
|
||||||
|
<Link className="text-app-accent" to="/forgot-password">
|
||||||
|
{t("auth.forgotPassword")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
{registration.data?.open && (
|
{registration.data?.open && (
|
||||||
<p className="mt-4 text-sm text-app-muted">
|
<p className="mt-4 text-sm text-app-muted">
|
||||||
{t("auth.noAccount")}{" "}
|
{t("auth.noAccount")}{" "}
|
||||||
|
|||||||
@@ -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 { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
||||||
|
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { api, ApiError } from "../../lib/api";
|
import { api, ApiError } from "../../lib/api";
|
||||||
import type { CalendarOut, ReminderPref } from "../../lib/types";
|
import type { CalendarOut, ReminderPref } from "../../lib/types";
|
||||||
@@ -105,11 +106,103 @@ function ProfileSection() {
|
|||||||
<section className="max-w-4xl space-y-3">
|
<section className="max-w-4xl space-y-3">
|
||||||
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
|
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
|
||||||
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
|
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
|
||||||
<Row label={t("settings.recoveryEmail")} value={data?.recovery_email ?? "—"} />
|
<RecoveryEmailRow current={data?.recovery_email ?? null} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whoever controls the recovery address can take the account over with a reset
|
||||||
|
// link, so adding, changing or removing it asks for the current password.
|
||||||
|
function RecoveryEmailRow({ current }: { current: string | null }) {
|
||||||
|
const t = useT();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => api.setRecoveryEmail(password, email.trim() || null),
|
||||||
|
onSuccess: (user) => {
|
||||||
|
queryClient.setQueryData(["me"], user);
|
||||||
|
setEditing(false);
|
||||||
|
setPassword("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-app-border py-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-app-muted">{t("settings.recoveryEmail")}</span>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<span>{current ?? "—"}</span>
|
||||||
|
<button
|
||||||
|
className="rounded border border-app-border px-2.5 py-1 text-app-fg hover:bg-app-hover"
|
||||||
|
onClick={() => {
|
||||||
|
setEmail(current ?? "");
|
||||||
|
setPassword("");
|
||||||
|
save.reset();
|
||||||
|
setEditing(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{current ? t("settings.change") : t("settings.add")}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-app-faint">{t("settings.recoveryEmailHint")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const field =
|
||||||
|
"w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none";
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="space-y-2 rounded-lg border border-app-border p-3 text-sm"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
save.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-app-muted">{t("settings.recoveryEmailNew")}</span>
|
||||||
|
<input
|
||||||
|
className={field}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="name@example.com"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{current && (
|
||||||
|
<span className="block text-xs text-app-faint">{t("settings.recoveryEmailRemoveHint")}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-app-muted">{t("settings.currentPassword")}</span>
|
||||||
|
<TypedPasswordInput
|
||||||
|
className={field}
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
hint={t("settings.typePassword")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{save.error instanceof ApiError && <p className="text-red-500">{save.error.message}</p>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" className="px-2 text-app-muted" onClick={() => setEditing(false)}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded bg-app-accent px-3 py-1 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||||
|
disabled={save.isPending || !password}
|
||||||
|
>
|
||||||
|
{save.isPending ? t("settings.saving") : t("common.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: string }) {
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between border-b border-app-border py-2 text-sm">
|
<div className="flex justify-between border-b border-app-border py-2 text-sm">
|
||||||
|
|||||||
Reference in New Issue
Block a user