Admin user management, password reset by email, and the Ultimo Email name

Branding: the page titles and app name are now "Ultimo Email".

Admin panel (now two columns with a divider):
- Users: search by login or by a mail account's address or name (results
  only, nothing listed until something is typed). A user opens a window to
  change their quota, block/unblock them, or delete them; block and delete ask
  for confirmation, and an admin cannot do either to their own account.
- Blocking (users.blocked_at, migration 0019) refuses sign-in with a clear
  message and revokes every session; a new user.signed_out event sends the
  user's open tabs to the sign-in screen at once. Any "not signed in" answer
  now does the same, not only /auth/me. Deleting removes mail accounts (sync
  stops), plugin rows (cleared inside the user's RLS context, since plugin
  tables have no FK to users), blobs, the search index, sessions and the user.
- Outgoing system mail (migration 0020): SMTP server, port, security, login,
  password (vault-encrypted, never sent back), From address and the public
  address reset links point to, plus a "Send test" that reports the server's
  error.

Password reset:
- "Forgot password?" on the sign-in page asks for the user name and always
  answers "If this account exists you will receive an email to your recovery
  email address"; the mail goes out after the response, so neither the reply
  nor its timing reveals an account, and it is capped at 3 per hour.
- The emailed link (token in the URL fragment, single use, 30 minutes) opens a
  page to set a new password. With 2-step verification on, the code is checked
  before anything changes; five wrong codes burn the link.
- Settings > Profile: the recovery email can be added, changed or removed,
  confirmed with the current password, which must be typed — autofill and
  paste are refused (TypedPasswordInput).

Also adds frontend/icons/ultimo-logo-bimi.svg, an SVG Tiny PS version of the
logo for a BIMI record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:16:27 +02:00
co-authored by Claude Opus 5.5
parent 8dc60dbf5c
commit 0ddc0cb001
32 changed files with 2267 additions and 87 deletions
+502 -4
View File
@@ -1,9 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
import { useMe } from "../lib/auth";
import type { AdminMail, AdminUser } from "../lib/types";
const GIB = 1024 ** 3;
@@ -28,11 +29,24 @@ export function AdminApp() {
<header className="border-b border-app-border px-6 py-3">
<h1 className="font-semibold">{t("admin.title")}</h1>
</header>
<main className="mx-auto max-w-2xl px-6 py-10">
{/* Two columns once there is room: instance settings and users on the
left, outgoing system mail on the right. Signed out, it stays narrow. */}
<main className={`mx-auto px-6 py-10 ${me.data?.is_admin ? "max-w-6xl" : "max-w-2xl"}`}>
{me.isPending && <p className="text-sm text-app-muted">{t("common.loading")}</p>}
{!me.isPending && !me.data && <SignIn />}
{me.data && !me.data.is_admin && <NotAdmin handle={me.data.handle} />}
{me.data?.is_admin && <Panel handle={me.data.handle} />}
{me.data?.is_admin && (
<div className="grid gap-y-10 lg:grid-cols-2">
<div className="min-w-0 lg:pr-12">
<Panel handle={me.data.handle} />
<Users selfId={me.data.id} />
</div>
{/* The divider runs the full height of the taller column. */}
<div className="min-w-0 lg:border-l lg:border-app-border lg:pl-12">
<SystemMail />
</div>
</div>
)}
</main>
</div>
);
@@ -235,3 +249,487 @@ function Panel({ handle }: { handle: string }) {
</section>
);
}
type MailDraft = Omit<AdminMail, "has_password" | "configured">;
/** The SMTP account the server sends password-reset links from. */
function SystemMail() {
const t = useT();
const queryClient = useQueryClient();
const mail = useQuery({ queryKey: ["adminMail"], queryFn: api.adminMail });
// Edits are a draft until Save; null means "show what the server has".
const [draft, setDraft] = useState<MailDraft | null>(null);
const [password, setPassword] = useState("");
const [testTo, setTestTo] = useState("");
const save = useMutation({
mutationFn: (body: Parameters<typeof api.saveAdminMail>[0]) => api.saveAdminMail(body),
onSuccess: (data) => {
queryClient.setQueryData(["adminMail"], data);
setDraft(null);
setPassword("");
},
});
const test = useMutation({ mutationFn: () => api.testAdminMail(testTo.trim()) });
if (!mail.data) {
return mail.error instanceof ApiError ? (
<p className="text-sm text-red-500">{mail.error.message}</p>
) : null;
}
const server = mail.data;
const value: MailDraft = draft ?? {
smtp_host: server.smtp_host,
smtp_port: server.smtp_port,
smtp_security: server.smtp_security,
smtp_username: server.smtp_username,
smtp_from: server.smtp_from,
public_url: server.public_url,
};
const set = (patch: Partial<MailDraft>) => setDraft({ ...value, ...patch });
const dirty = draft !== null || password !== "";
const field =
"w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none";
const label = "block space-y-1 text-sm";
return (
<section className="space-y-3">
<h2 className="text-lg font-semibold">{t("admin.mail")}</h2>
<p className="text-xs text-app-faint">{t("admin.mailHelp")}</p>
<p className={`text-sm ${server.configured ? "text-emerald-600" : "text-amber-600"}`}>
{server.configured ? t("admin.mailOn") : t("admin.mailOff")}
</p>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
save.mutate({ ...value, ...(password ? { smtp_password: password } : {}) });
}}
>
<div className="grid grid-cols-[1fr_6rem_8rem] gap-2">
<label className={label}>
<span className="text-app-muted">{t("admin.smtpHost")}</span>
<input
className={field}
value={value.smtp_host}
placeholder="smtp.example.com"
onChange={(e) => set({ smtp_host: e.target.value })}
/>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.smtpPort")}</span>
<input
className={field}
type="number"
min="1"
max="65535"
value={value.smtp_port}
onChange={(e) => set({ smtp_port: Number(e.target.value) })}
/>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.smtpSecurity")}</span>
<select
className={field}
value={value.smtp_security}
onChange={(e) => {
const security = e.target.value as MailDraft["smtp_security"];
// Follow the usual port for the mode, unless a custom one was typed.
const usual = { starttls: 587, ssl: 465, none: 25 };
const port = Object.values(usual).includes(value.smtp_port) ? usual[security] : value.smtp_port;
set({ smtp_security: security, smtp_port: port });
}}
>
<option value="starttls">{t("admin.smtpStarttls")}</option>
<option value="ssl">{t("admin.smtpSsl")}</option>
<option value="none">{t("admin.smtpNone")}</option>
</select>
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<label className={label}>
<span className="text-app-muted">{t("admin.smtpUsername")}</span>
<input
className={field}
autoComplete="off"
value={value.smtp_username}
placeholder="postmaster@example.com"
onChange={(e) => set({ smtp_username: e.target.value })}
/>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.smtpPassword")}</span>
<input
className={field}
type="password"
autoComplete="new-password"
value={password}
placeholder={server.has_password ? t("admin.smtpPasswordKept") : ""}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
</div>
<label className={label}>
<span className="text-app-muted">{t("admin.smtpFrom")}</span>
<input
className={field}
type="email"
value={value.smtp_from}
placeholder="postmaster@example.com"
onChange={(e) => set({ smtp_from: e.target.value })}
/>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.publicUrl")}</span>
<input
className={field}
value={value.public_url}
placeholder="https://mail.example.com"
onChange={(e) => set({ public_url: e.target.value })}
/>
<span className="block text-xs text-app-faint">{t("admin.publicUrlHint")}</span>
</label>
<div className="flex items-center justify-between gap-2">
{server.has_password ? (
<button
type="button"
className="text-xs text-app-muted underline hover:text-app-fg disabled:opacity-50"
disabled={save.isPending}
onClick={() => save.mutate({ smtp_password: "" })}
>
{t("admin.smtpPasswordRemove")}
</button>
) : (
<span />
)}
<button
type="submit"
className="rounded bg-app-accent px-3 py-1 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!dirty || save.isPending}
>
{save.isPending ? t("admin.saving") : t("admin.save")}
</button>
</div>
{save.error instanceof ApiError && <p className="text-xs text-red-500">{save.error.message}</p>}
</form>
<form
className="flex items-center gap-2 border-t border-app-border pt-3 text-sm"
onSubmit={(e) => {
e.preventDefault();
test.mutate();
}}
>
<span className="shrink-0 text-app-muted">{t("admin.testTo")}</span>
<input
className={field}
type="email"
value={testTo}
placeholder="you@example.com"
onChange={(e) => {
setTestTo(e.target.value);
test.reset();
}}
/>
<button
type="submit"
className="shrink-0 rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
disabled={!server.configured || !testTo.trim() || test.isPending || dirty}
>
{test.isPending ? t("auth.sending") : t("admin.sendTest")}
</button>
</form>
{test.isSuccess && <p className="text-xs text-emerald-600">{t("admin.testSent")}</p>}
{test.error instanceof ApiError && <p className="text-xs text-red-500">{test.error.message}</p>}
</section>
);
}
function formatBytes(bytes: number): string {
if (bytes >= GIB) return `${Number((bytes / GIB).toFixed(2))} GiB`;
return `${Number((bytes / 1024 ** 2).toFixed(1))} MiB`;
}
/** Find a user by login, mail address or name, and open them to manage. */
function Users({ selfId }: { selfId: string }) {
const t = useT();
const [term, setTerm] = useState("");
const [query, setQuery] = useState("");
const [openId, setOpenId] = useState<string | null>(null);
// Search as you type, but not on every keystroke.
useEffect(() => {
const id = window.setTimeout(() => setQuery(term.trim()), 250);
return () => window.clearTimeout(id);
}, [term]);
// Only search results are listed: nothing until something is typed.
const users = useQuery({
queryKey: ["adminUsers", query],
queryFn: () => api.adminUsers(query),
enabled: query !== "",
placeholderData: keepPreviousData,
});
const results = query ? users.data : undefined;
const open = results?.find((u) => u.id === openId) ?? null;
return (
<section className="mt-10 space-y-3">
<h2 className="text-lg font-semibold">{t("admin.users")}</h2>
<input
className={inputClass}
type="search"
placeholder={t("admin.userSearch")}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
{users.error instanceof ApiError && (
<p className="text-sm text-red-500">{users.error.message}</p>
)}
{results && results.length === 0 && (
<p className="text-sm text-app-muted">{t("admin.noUsers")}</p>
)}
{results && results.length > 0 && (
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
{results.map((u) => (
<li key={u.id}>
<button
type="button"
onClick={() => setOpenId(u.id)}
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-app-hover"
>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate font-medium">{u.handle}</span>
<Badges user={u} self={u.id === selfId} />
</span>
<span className="block truncate text-xs text-app-muted">
{u.accounts.length ? u.accounts.join(", ") : t("admin.noMailAccounts")}
</span>
</span>
<span className="shrink-0 text-xs tabular-nums text-app-muted">
{t("admin.usage", {
used: formatBytes(u.used_bytes),
quota: formatBytes(u.quota_bytes),
})}
</span>
</button>
</li>
))}
</ul>
)}
{open && <UserDialog user={open} self={open.id === selfId} onClose={() => setOpenId(null)} />}
</section>
);
}
function Badges({ user, self }: { user: AdminUser; self: boolean }) {
const t = useT();
const badge = "shrink-0 rounded px-1.5 text-[11px] font-medium";
return (
<>
{self && <span className={`${badge} bg-app-hover text-app-muted`}>{t("admin.you")}</span>}
{user.is_admin && (
<span className={`${badge} bg-app-accent/15 text-app-accent`}>{t("admin.adminBadge")}</span>
)}
{user.blocked_at && (
<span className={`${badge} bg-red-500/15 text-red-500`}>{t("admin.blockedBadge")}</span>
)}
</>
);
}
/** One user: change their quota, block or unblock them, or delete them. */
function UserDialog({
user,
self,
onClose,
}: {
user: AdminUser;
self: boolean;
onClose: () => void;
}) {
const t = useT();
const queryClient = useQueryClient();
const [quotaDraft, setQuotaDraft] = useState<string | null>(null);
const [confirm, setConfirm] = useState<null | "block" | "delete">(null);
const refresh = () => queryClient.invalidateQueries({ queryKey: ["adminUsers"] });
const update = useMutation({
mutationFn: (body: { quota_bytes?: number; blocked?: boolean }) =>
api.updateAdminUser(user.id, body),
onSuccess: () => {
setQuotaDraft(null);
setConfirm(null);
void refresh();
},
});
const remove = useMutation({
mutationFn: () => api.deleteAdminUser(user.id),
onSuccess: () => {
onClose();
void refresh();
},
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (confirm) setConfirm(null);
else onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [confirm, onClose]);
const serverGib = user.quota_bytes / GIB;
const quotaValue = quotaDraft ?? String(Number(serverGib.toFixed(3)));
const quotaGib = Number(quotaValue);
const quotaValid = Number.isFinite(quotaGib) && quotaGib > 0;
const quotaDirty = quotaDraft !== null && quotaGib !== serverGib;
const busy = update.isPending || remove.isPending;
const error = [update.error, remove.error].find((e) => e instanceof ApiError) as
| ApiError
| undefined;
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={user.handle}
className="relative w-full max-w-md rounded-xl border border-app-border bg-app-elevated p-5 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-start gap-3">
<div className="min-w-0 flex-1">
<h3 className="flex items-center gap-1.5 font-semibold">
<span className="truncate">{user.handle}</span>
<Badges user={user} self={self} />
</h3>
<p className="mt-0.5 text-xs text-app-muted">
{user.accounts.length ? user.accounts.join(", ") : t("admin.noMailAccounts")}
</p>
</div>
<button
type="button"
onClick={onClose}
title={t("admin.close")}
aria-label={t("admin.close")}
className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
<div className="border-y border-app-border py-3">
<div className="flex items-center justify-between gap-2 text-sm">
<span className="text-app-muted">{t("admin.userQuota")}</span>
<div className="flex items-center gap-2">
<input
className="w-24 rounded border border-app-border bg-app-surface px-2 py-1 text-right focus:border-app-accent focus:outline-none"
type="number"
min="0.1"
step="0.5"
value={quotaValue}
onChange={(e) => setQuotaDraft(e.target.value)}
/>
<span className="text-app-muted">GiB</span>
<button
className="rounded bg-app-accent px-3 py-1 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!quotaDirty || !quotaValid || busy}
onClick={() => update.mutate({ quota_bytes: Math.round(quotaGib * GIB) })}
>
{update.isPending && quotaDirty ? t("admin.saving") : t("admin.save")}
</button>
</div>
</div>
<p className="mt-1 text-xs text-app-faint">
{t("admin.usage", {
used: formatBytes(user.used_bytes),
quota: formatBytes(user.quota_bytes),
})}
</p>
{!quotaValid && <p className="mt-1 text-xs text-red-500">{t("admin.quotaInvalid")}</p>}
</div>
{user.blocked_at && (
<p className="mt-3 text-sm text-red-500">
{t("admin.blockedSince", { date: new Date(user.blocked_at).toLocaleString() })}
</p>
)}
{self ? (
<p className="mt-3 text-xs text-app-muted">{t("admin.selfNote")}</p>
) : (
<div className="mt-4 flex flex-wrap justify-end gap-2">
{user.blocked_at ? (
<button
className="rounded-lg border border-app-border px-3 py-1.5 text-sm hover:bg-app-hover disabled:opacity-50"
disabled={busy}
onClick={() => update.mutate({ blocked: false })}
>
{t("admin.unblock")}
</button>
) : (
<button
className="rounded-lg border border-app-border px-3 py-1.5 text-sm hover:bg-app-hover disabled:opacity-50"
disabled={busy}
onClick={() => setConfirm("block")}
>
{t("admin.block")}
</button>
)}
<button
className="rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
disabled={busy}
onClick={() => setConfirm("delete")}
>
{t("admin.deleteUser")}
</button>
</div>
)}
{error && <p className="mt-2 text-xs text-red-500">{error.message}</p>}
{confirm && (
<div
className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/40 p-4"
onClick={() => setConfirm(null)}
>
<div
role="alertdialog"
aria-modal="true"
className="w-full max-w-sm rounded-lg border border-app-border bg-app-elevated p-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<p className="mb-4 text-sm">
{t(confirm === "block" ? "admin.blockConfirm" : "admin.deleteConfirm", {
handle: user.handle,
})}
</p>
<div className="flex justify-end gap-2">
<button
className="rounded-md px-3 py-1.5 text-sm text-app-muted hover:bg-app-hover"
onClick={() => setConfirm(null)}
autoFocus
>
{t("admin.cancel")}
</button>
<button
className="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
disabled={busy}
onClick={() =>
confirm === "block" ? update.mutate({ blocked: true }) : remove.mutate()
}
>
{t(confirm === "block" ? "admin.block" : "admin.deleteUser")}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}