#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

@@ -2,7 +2,10 @@
import React, { useState } from "react";
import Link from "next/link";
import { Mail, Lock, ShieldCheck, ArrowLeft, KeyRound, AlertCircle } from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Card } from "../../../../components/ui/Card";
import { Button } from "../../../../components/ui/Button";
export default function UserLoginPage() {
const t = useTranslations();
@@ -27,7 +30,7 @@ export default function UserLoginPage() {
body: JSON.stringify({ challenge_token: challengeToken, code: mfaCode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "Verification failed");
if (!res.ok) throw new Error(data.message || "Two-factor verification failed");
window.location.href = "/user/mail/inbox";
return;
}
@@ -39,7 +42,7 @@ export default function UserLoginPage() {
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "Authentication failed");
if (!res.ok) throw new Error(data.message || "Invalid credentials");
if (data.mfa_required) {
setChallengeToken(data.challenge_token);
@@ -47,91 +50,109 @@ export default function UserLoginPage() {
window.location.href = "/user/mail/inbox";
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "An error occurred");
setError(err instanceof Error ? err.message : "Ocorreu um erro ao autenticar");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center px-4 bg-slate-950">
<div className="glass-panel p-8 rounded-2xl max-w-md w-full border border-slate-800 shadow-2xl">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-indigo-600/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400 font-bold text-xl">
@
<div className="min-h-screen flex items-center justify-center px-4 bg-dark-bg relative overflow-hidden">
{/* Background Ambient Glow */}
<div className="absolute -top-40 -left-40 w-96 h-96 bg-indigo-600/20 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -bottom-40 -right-40 w-96 h-96 bg-cyan-600/20 rounded-full blur-3xl pointer-events-none" />
<Card className="max-w-md w-full border border-slate-800 shadow-2xl p-8 z-10">
<div className="flex items-center gap-3.5 mb-6">
<div className="w-11 h-11 rounded-2xl bg-gradient-to-tr from-indigo-600 to-cyan-500 flex items-center justify-center text-white font-bold shadow-lg shadow-indigo-500/20">
<Mail className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold text-white">{t("auth.userLoginTitle")}</h1>
<h1 className="text-xl font-extrabold text-white tracking-tight">{t("auth.userLoginTitle")}</h1>
<p className="text-xs text-slate-400">{t("common.appName")} &middot; {t("common.userPortal")}</p>
</div>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
<div className="mb-5 p-3.5 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center gap-2.5">
<AlertCircle className="w-4 h-4 text-rose-400 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<form onSubmit={handleLogin} className="space-y-4">
<form onSubmit={handleLogin} className="space-y-4 text-xs">
{!challengeToken ? (
<>
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">{t("auth.emailLabel")}</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@domain.com"
required
className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
/>
<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 text-xs font-medium text-slate-300 mb-1">{t("auth.passwordLabel")}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="************"
required
className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
/>
<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 mb-4 rounded-lg bg-indigo-500/10 border border-indigo-500/30 text-indigo-300 text-xs">
{t("auth.mfaRequired")}
<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)}
placeholder="123456"
maxLength={6}
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>
<label className="block text-xs font-medium text-slate-300 mb-1">{t("auth.totpCodeLabel")}</label>
<input
type="text"
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
placeholder="123456"
maxLength={6}
required
className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white text-center tracking-widest text-lg font-mono focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
)}
<button
<Button
type="submit"
variant="primary"
size="lg"
className="w-full mt-2"
disabled={loading}
className="w-full py-3 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors shadow-lg shadow-indigo-600/20 disabled:opacity-50"
>
{loading ? t("common.loading") : challengeToken ? t("auth.verifyCodeButton") : t("auth.signInButton")}
</button>
</Button>
</form>
<div className="mt-6 pt-4 border-t border-slate-800 text-center">
<Link href="/" className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
Back to Surface Selector
<div className="mt-6 pt-4 border-t border-slate-800/80 text-center">
<Link href="/" className="text-xs text-slate-400 hover:text-slate-200 transition-colors inline-flex items-center gap-1.5">
<ArrowLeft className="w-3.5 h-3.5" />
<span>Back to surface selection</span>
</Link>
</div>
</div>
</Card>
</div>
);
}