#34: fix the mail counters, the reader pane, and the state between surfaces

The counts were computed inline in JSX where nothing could test them. The
sidebar matched a folder by a special-use role folders do not always carry, its
badges were fetched once and then froze - new mail did not raise them and
reading a message did not lower it - and the list header printed the size of
the loaded page, capped at 50, as the size of the folder. The rules now live in
mailCounts.ts behind tests, useFolders keeps them current from the server's
push and from this tab's own changes, and each filter chip carries its number.

Inline images never left the server: RenderMessage dropped every part a body
references as cid:, so mail written by any normal client rendered with broken
icons. They now come back as bounded data URIs, refusing image/svg+xml, and the
reader resolves them, sizes its frame from the message instead of a fixed
500px, and renders on the light surface mail assumes.

Reading mail no longer asks for a second factor - the mailbox is behind the
account's own password - while the console still requires one unconditionally,
now collected by a step-up that does not ask again for the password the session
in hand just proved. An admin sign-in issues the webmail session too, so
leaving the console is a link rather than another login.

Two defects found while testing the above:

  - The admin sign-in checked the password and the second factor and never the
    role, so any account that had enrolled a factor from its own settings could
    obtain an admin-audience session.
  - Session tokens carried nothing that varied below one second, so signing in
    twice within the same second minted the identical token and collided with
    the unique index on web_sessions.refresh_token_hash. A correct password
    answered 500, and a double-clicked sign-in was enough to do it. Tokens now
    carry a jti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-18 14:51:56 -03:00
1 parent e2ea58a4c9
commit 8b69521c76
25 files changed
+2276 -380

No files matched your search

@@ -1,14 +1,7 @@
"use client";
import React, { useState } from "react";
import Link from "next/link";
import {
Mail,
Lock,
ShieldCheck,
KeyRound,
AlertCircle,
} from "lucide-react";
import { Mail, Lock, AlertCircle } from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Card } from "../../../../components/ui/Card";
import { Button } from "../../../../components/ui/Button";
@@ -17,46 +10,40 @@ export default function UserLoginPage() {
const t = useTranslations();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [mfaCode, setMfaCode] = useState("");
const [challengeToken, setChallengeToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
/**
* One step, because the mailbox is one step.
*
* Reading your own mail is behind your own password; the second factor guards
* the console, and is collected on the way in there. This screen used to have
* a code step as well, so an account that had enrolled a factor could not open
* its inbox without its phone.
*/
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (challengeToken) {
// Step 2: Verify TOTP code
const res = await fetch("/api/v1/auth/mfa/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ challenge_token: challengeToken, code: mfaCode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "Two-factor verification failed");
window.location.href = "/user/mail/inbox";
return;
}
// Step 1: Password authentication
const res = await fetch("/api/v1/auth/user/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "Invalid credentials");
if (!res.ok) throw new Error(data.message || t("auth.invalidCredentials"));
if (data.mfa_required) {
setChallengeToken(data.challenge_token);
} else {
window.location.href = "/user/mail/inbox";
}
// The destination the middleware attached when it sent an unauthenticated
// visitor here, so a deep link survives the sign-in.
// A path on this origin only: "//evil.test" also begins with a slash, and
// following it would turn the sign-in into an open redirect.
const next = new URLSearchParams(window.location.search).get("next");
const safe = next && next.startsWith("/") && !next.startsWith("//");
window.location.href = safe ? next : "/user/mail/inbox";
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Ocorreu um erro ao autenticar");
setError(err instanceof Error ? err.message : t("auth.genericError"));
} finally {
setLoading(false);
}
@@ -87,68 +74,46 @@ export default function UserLoginPage() {
)}
<form onSubmit={handleLogin} className="space-y-4 text-xs">
{!challengeToken ? (
<>
<div>
<label className="block font-semibold text-slate-300 mb-1.5">{t("auth.emailLabel")}</label>
<div className="relative">
<Mail className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@domain.com"
required
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
</div>
<div>
<label className="block font-semibold text-slate-300 mb-1.5">{t("auth.passwordLabel")}</label>
<div className="relative">
<Lock className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="************"
required
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
</div>
</>
) : (
<div>
<div className="p-3.5 mb-4 rounded-xl bg-indigo-500/15 border border-indigo-500/30 text-indigo-300 text-xs flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-indigo-400 flex-shrink-0" />
<span>{t("auth.mfaRequired")}</span>
</div>
<label className="block font-semibold text-slate-300 mb-1.5">{t("auth.totpCodeLabel")}</label>
<div className="relative">
<KeyRound className="w-4 h-4 absolute left-3.5 top-3.5 text-slate-500" />
<input
type="text"
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="123456"
maxLength={7}
required
className="w-full pl-10 pr-4 py-3 rounded-xl bg-slate-900/90 border border-slate-800 text-white text-center tracking-widest text-lg font-mono focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
<div>
<label htmlFor="login-email" className="block font-semibold text-slate-300 mb-1.5">
{t("auth.emailLabel")}
</label>
<div className="relative">
<Mail className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
<input
id="login-email"
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@domain.com"
required
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
)}
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full mt-2"
disabled={loading}
>
{loading ? t("common.loading") : challengeToken ? t("auth.verifyCodeButton") : t("auth.signInButton")}
<div>
<label htmlFor="login-password" className="block font-semibold text-slate-300 mb-1.5">
{t("auth.passwordLabel")}
</label>
<div className="relative">
<Lock className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
<input
id="login-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="************"
required
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
</div>
<Button type="submit" variant="primary" size="lg" className="w-full mt-2" disabled={loading}>
{loading ? t("common.loading") : t("auth.signInButton")}
</Button>
</form>