#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:
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>
|
||||
|
||||
Reference in new issue
Block a user