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
+94 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { TypedPasswordInput } from "../../components/TypedPasswordInput";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import type { CalendarOut, ReminderPref } from "../../lib/types";
@@ -105,11 +106,103 @@ function ProfileSection() {
<section className="max-w-4xl space-y-3">
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
<Row label={t("settings.handle")} value={data?.handle ?? ""} />
<Row label={t("settings.recoveryEmail")} value={data?.recovery_email ?? "—"} />
<RecoveryEmailRow current={data?.recovery_email ?? null} />
</section>
);
}
// Whoever controls the recovery address can take the account over with a reset
// link, so adding, changing or removing it asks for the current password.
function RecoveryEmailRow({ current }: { current: string | null }) {
const t = useT();
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const save = useMutation({
mutationFn: () => api.setRecoveryEmail(password, email.trim() || null),
onSuccess: (user) => {
queryClient.setQueryData(["me"], user);
setEditing(false);
setPassword("");
},
});
if (!editing) {
return (
<div className="border-b border-app-border py-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-app-muted">{t("settings.recoveryEmail")}</span>
<span className="flex items-center gap-3">
<span>{current ?? "—"}</span>
<button
className="rounded border border-app-border px-2.5 py-1 text-app-fg hover:bg-app-hover"
onClick={() => {
setEmail(current ?? "");
setPassword("");
save.reset();
setEditing(true);
}}
>
{current ? t("settings.change") : t("settings.add")}
</button>
</span>
</div>
<p className="mt-1 text-xs text-app-faint">{t("settings.recoveryEmailHint")}</p>
</div>
);
}
const field =
"w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none";
return (
<form
className="space-y-2 rounded-lg border border-app-border p-3 text-sm"
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
>
<label className="block space-y-1">
<span className="text-app-muted">{t("settings.recoveryEmailNew")}</span>
<input
className={field}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
autoFocus
/>
{current && (
<span className="block text-xs text-app-faint">{t("settings.recoveryEmailRemoveHint")}</span>
)}
</label>
<label className="block space-y-1">
<span className="text-app-muted">{t("settings.currentPassword")}</span>
<TypedPasswordInput
className={field}
value={password}
onChange={setPassword}
hint={t("settings.typePassword")}
/>
</label>
{save.error instanceof ApiError && <p className="text-red-500">{save.error.message}</p>}
<div className="flex justify-end gap-2">
<button type="button" className="px-2 text-app-muted" onClick={() => setEditing(false)}>
{t("common.cancel")}
</button>
<button
type="submit"
className="rounded bg-app-accent px-3 py-1 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={save.isPending || !password}
>
{save.isPending ? t("settings.saving") : t("common.save")}
</button>
</div>
</form>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between border-b border-app-border py-2 text-sm">