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
@@ -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>}
</>
);
}