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:
@@ -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",
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user