"use client"; import React, { useState } from "react"; import Link from "next/link"; import { useTranslations } from "../../../../i18n/provider"; import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment"; export default function AdminLoginPage() { const t = useTranslations(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [mfaCode, setMfaCode] = useState(""); const [challengeToken, setChallengeToken] = useState(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(null); const [enrolSecret, setEnrolSecret] = useState(null); const [enrolUri, setEnrolUri] = useState(null); const [recoveryCodes, setRecoveryCodes] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setError(null); 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 || t("auth.verificationFailed")); // 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", { 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 || t("auth.verificationFailed")); window.location.href = "/admin/dashboard"; return; } // Step 1: Admin credentials const res = await fetch("/api/v1/auth/admin/login", { method: "POST", headers: { "Content-Type": "application/json" }, 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 || t("auth.enrolmentFailed")); setEnrolmentToken(data.enrollment_token); setEnrolSecret(enrolData.secret); setEnrolUri(enrolData.uri); return; } if (!res.ok) throw new Error(data.message || t("auth.authFailed")); if (data.mfa_required) { setChallengeToken(data.challenge_token); } else { window.location.href = "/admin/dashboard"; } } catch (err: unknown) { setError(err instanceof Error ? err.message : t("auth.genericError")); } finally { setLoading(false); } }; return (
⚙

{t("auth.adminLoginTitle")}

{t("auth.adminStepUpNote")}

{error && (
{error}
)} {/* The recovery-code step is terminal: it owns its own buttons, so the form's submit button is not rendered underneath it. That overlap is what previously put two identical "sign in" buttons on the screen and left no way to copy the codes it insisted you save. */} {recoveryCodes ? ( setRecoveryCodes(null)} continueLabel={t("auth.signInButton")} /> ) : (
{enrolSecret ? (
{t("auth.enrolmentIntro")}
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" />
) : !challengeToken ? ( <>
setEmail(e.target.value)} placeholder="admin@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-emerald-500 transition-colors" />
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-emerald-500 transition-colors" />
) : (
{t("auth.mfaRequired")}
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-emerald-500 transition-colors" />
)}
)}
{t("admin.backToWebmail")}
); }