Logo in system mail; "don't ask for the 2FA code on this device"

System mail: the password-reset email (and the admin panel's test email) now
opens with the Ultimo logo, centered. It is a PNG rendered from
icons/ultimo-logo.svg and embedded in the message (multipart/related, cid:),
so it shows where remote images are blocked and in clients that do not
render SVG. The PNG is listed in package-data so the Docker image carries it.

Trusted devices: a 2FA sign-in offers "Don't ask for the code on this device
in future". Ticked with a correct code, the browser gets its own HttpOnly
trusted-device cookie (token in Redis, 90 days, renewed on use); later
sign-ins to the same account from that browser need only the password. Other
accounts and browsers are unaffected. The trust is revoked on a password
reset, when 2FA is turned off, and when an admin blocks or deletes the user;
signing out keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 21:02:44 +02:00
co-authored by Claude Opus 5.5
parent d1f6d183e0
commit 165442d897
14 changed files with 334 additions and 12 deletions
+1
View File
@@ -29,6 +29,7 @@ export const en = {
"auth.passwordHint": "at least 8 characters",
"auth.totpCode": "2FA code",
"auth.totpPrompt": "Enter the 6-digit code from your authenticator app",
"auth.rememberDevice": "Don't ask for the code on this device in future",
"auth.signIn": "Sign in",
"auth.signingIn": "Signing in…",
"auth.create": "Create account",
+8 -2
View File
@@ -123,10 +123,16 @@ export const api = {
method: "POST",
body: JSON.stringify({ localpart, password }),
}),
login: (handle: string, password: string, totp?: string) =>
// rememberDevice: with a correct 2FA code, don't ask this browser for one again.
login: (handle: string, password: string, totp?: string, rememberDevice = false) =>
apiFetch<UserOut>("/auth/login", {
method: "POST",
body: JSON.stringify({ handle, password, totp: totp || undefined }),
body: JSON.stringify({
handle,
password,
totp: totp || undefined,
remember_device: rememberDevice || undefined,
}),
}),
logout: () => apiFetch<{ status: string }>("/auth/logout", { method: "POST" }),
me: () => apiFetch<UserOut>("/auth/me"),
+11 -1
View File
@@ -11,13 +11,14 @@ export function Login() {
const [password, setPassword] = useState("");
const [totp, setTotp] = useState("");
const [needTotp, setNeedTotp] = useState(false);
const [rememberDevice, setRememberDevice] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
// Only offer the signup link when the server would actually accept one.
const registration = useQuery({ queryKey: ["registration"], queryFn: api.registrationStatus });
const mutation = useMutation({
mutationFn: () => api.login(handle, password, needTotp ? totp : undefined),
mutationFn: () => api.login(handle, password, needTotp ? totp : undefined, needTotp && rememberDevice),
onSuccess: (user) => {
queryClient.setQueryData(["me"], user);
navigate("/mail");
@@ -71,6 +72,15 @@ export function Login() {
autoFocus
/>
<p className="text-xs text-app-muted">{t("auth.totpPrompt")}</p>
<label className="flex items-center gap-2 pt-1 text-sm text-app-fg">
<input
type="checkbox"
checked={rememberDevice}
onChange={(e) => setRememberDevice(e.target.checked)}
className="h-4 w-4 accent-[rgb(var(--app-accent))]"
/>
{t("auth.rememberDevice")}
</label>
</div>
)}
<button