#22: fix the admin lockout, and the regressions the UI rewrite brought back

The console demanded a second factor and refused to open without one, but
enrolment lived on the other surface - so an operator whose only account
was the administrator had no way in. The password was already verified at
that point, so the refusal now carries a short-lived grant that permits
enrolling a factor and nothing else, and the admin login screen walks
through it: secret, confirmation, recovery codes, sign in. Two tests cover
it, including that the grant opens no other endpoint.

The rewrite also undid work that was already in place:

  - The postcss override was dropped and the advisory came back, along
    with a critical one in vitest. postcss is a direct dependency now, so
    the fix is the version plus an override scoped to Next's own copy;
    vitest moves to 3.2.7. The lockfile needed regenerating for either to
    take effect. Zero advisories now.

  - 100 language-lint violations: the admin console had been rewritten
    with its strings in Portuguese, hardcoded. That fails CI, and it also
    puts those strings beyond the reach of the translation bundles - an
    English or Spanish user would have read Portuguese. They are English
    in the source again.

  - The mail screen put a fixed 320px list beside the reader with no
    breakpoint, so on a phone the viewport overflowed and the message was
    a few pixels wide. One pane at a time below md, with a way back.

  - The icon-only navigation toggles had nothing for a screen reader to
    announce.

The layouts' own mobile navigation and every previously fixed behaviour
survived the rewrite - checked rather than assumed.

Also fixes a marker collision I introduced: the E2E helper locates the
repository by a migrations directory, and services/migrations satisfied
that one directory too early. It now requires the SQL to actually be
there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 16:13:14 -03:00
1 parent 2282dc96e9
commit 3a82d82428
26 files changed
+3554 -1402

No files matched your search

@@ -10,6 +10,13 @@ export default function AdminLoginPage() {
const [password, setPassword] = useState("");
const [mfaCode, setMfaCode] = useState("");
const [challengeToken, setChallengeToken] = useState<string | null>(null);
// Enrolment state: the console requires a second factor, so an account
// without one is walked through creating it here rather than being told to
// go and find another screen.
const [enrolmentToken, setEnrolmentToken] = useState<string | null>(null);
const [enrolSecret, setEnrolSecret] = useState<string | null>(null);
const [enrolUri, setEnrolUri] = useState<string | null>(null);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -19,6 +26,23 @@ export default function AdminLoginPage() {
setLoading(true);
try {
if (enrolmentToken) {
const res = await fetch("/api/v1/user/mfa/totp/confirm", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${enrolmentToken}` },
body: JSON.stringify({ code: mfaCode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "Verification failed");
// Shown once. The operator signs in again with the factor they just
// created, which is also the first proof that it works.
setRecoveryCodes(data.recovery_codes ?? []);
setEnrolmentToken(null);
setEnrolSecret(null);
setMfaCode("");
return;
}
if (challengeToken) {
// Step 2: Mandatory Admin 2FA Code
const res = await fetch("/api/v1/auth/mfa/verify", {
@@ -39,6 +63,22 @@ export default function AdminLoginPage() {
body: JSON.stringify({ email, password }),
});
const data = await res.json();
// The password was right but no second factor exists. The response
// carries a grant that permits enrolling one and nothing else.
if (res.status === 403 && data.error === "MFA_ENROLLMENT_REQUIRED" && data.enrollment_token) {
const enroll = await fetch("/api/v1/user/mfa/totp/enroll", {
method: "POST",
headers: { Authorization: `Bearer ${data.enrollment_token}` },
});
const enrolData = await enroll.json();
if (!enroll.ok) throw new Error(enrolData.message || "Could not start enrolment");
setEnrolmentToken(data.enrollment_token);
setEnrolSecret(enrolData.secret);
setEnrolUri(enrolData.uri);
return;
}
if (!res.ok) throw new Error(data.message || "Admin authentication failed");
if (data.mfa_required) {
@@ -73,7 +113,59 @@ export default function AdminLoginPage() {
)}
<form onSubmit={handleLogin} className="space-y-4">
{!challengeToken ? (
{recoveryCodes ? (
<div className="space-y-3">
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-300">
<div className="mb-1 font-bold">{t("settings.recoveryCodesTitle")}</div>
<p className="mb-2">{t("settings.saveRecoveryCodes")}</p>
<div className="grid grid-cols-2 gap-1 font-mono text-[11px] text-white">
{recoveryCodes.map((code) => (
<span key={code}>{code}</span>
))}
</div>
</div>
<button
type="button"
onClick={() => setRecoveryCodes(null)}
className="w-full rounded-xl bg-emerald-600 py-3 font-medium text-white transition-colors hover:bg-emerald-500"
>
{t("auth.signInButton")}
</button>
</div>
) : enrolSecret ? (
<div className="space-y-3">
<div className="rounded-lg border border-indigo-500/30 bg-indigo-500/10 p-3 text-xs text-indigo-300">
{t("auth.mfaRequired")}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-300">{t("settings.enableMfa")}</label>
<code className="block break-all rounded-lg border border-slate-800 bg-slate-900 p-3 font-mono text-sm text-white">
{enrolSecret}
</code>
{enrolUri && (
<a
href={enrolUri}
className="mt-1 block truncate text-[11px] text-indigo-400 hover:underline"
>
{enrolUri}
</a>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-300">{t("auth.totpCodeLabel")}</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
maxLength={6}
required
className="w-full rounded-lg border border-slate-800 bg-slate-900 px-4 py-2.5 text-center font-mono text-lg tracking-widest text-white focus:border-emerald-500 focus:outline-none"
/>
</div>
</div>
) : !challengeToken ? (
<>
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">{t("auth.emailLabel")}</label>