0.2.38: optional two-factor authentication (authenticator-app TOTP)

Per-user opt-in 2FA: enroll from the Two-factor option next to Change
password (QR + confirm code + 8 one-time recovery codes), login becomes
two-step for enrolled users, disable requires the account password.
Escape hatch for lost phones: reset-2fa CLI + Reset Two-Factor StartOS
action. Second-factor guesses share the login rate limiter; the pending
login window expires after 5 minutes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-11 22:08:04 +02:00
co-authored by Claude Fable 5
parent 053bfeab23
commit 0822eca887
20 changed files with 846 additions and 41 deletions
+86 -27
View File
@@ -3,9 +3,11 @@ import { useAuth } from "../context/AuthContext";
import PasswordInput from "../components/PasswordInput";
export default function Login() {
const { login } = useAuth();
const { login, verifyTotp } = useAuth();
const [handle, setHandle] = useState("");
const [password, setPassword] = useState("");
const [needsCode, setNeedsCode] = useState(false);
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -14,7 +16,8 @@ export default function Login() {
setError("");
setLoading(true);
try {
await login(handle, password);
const result = await login(handle, password);
if (result === "2fa") setNeedsCode(true);
} catch (err: any) {
setError(err.message || "Login failed");
} finally {
@@ -22,6 +25,25 @@ export default function Login() {
}
};
const handleCodeSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await verifyTotp(code);
} catch (err: any) {
setError(err.message || "Verification failed");
// A 401 "expired" means the pending window lapsed — send them back to step one.
if (/expired/i.test(err.message || "")) {
setNeedsCode(false);
setCode("");
setPassword("");
}
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 w-96">
@@ -29,31 +51,68 @@ export default function Login() {
<img src="/ten31-logo.png" alt="" className="w-9 h-9 rounded-lg" />
<h1 className="text-xl font-semibold text-gray-900">Ten31 Portal</h1>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-gray-700 mb-1">Username or email</label>
<input
type="text"
autoComplete="username"
value={handle}
onChange={(e) => setHandle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Password</label>
<PasswordInput value={password} onChange={setPassword} autoComplete="current-password" />
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
{needsCode ? (
<form onSubmit={handleCodeSubmit} className="space-y-4">
<div>
<label className="block text-sm text-gray-700 mb-1">Two-factor code</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6-digit code"
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
required
/>
<p className="mt-1.5 text-xs text-gray-400">
Enter the code from your authenticator app, or one of your recovery codes.
</p>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{loading ? "Verifying..." : "Verify"}
</button>
<button
type="button"
onClick={() => { setNeedsCode(false); setCode(""); setError(""); }}
className="w-full text-xs text-gray-400 hover:text-gray-600"
>
Back to sign in
</button>
</form>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-gray-700 mb-1">Username or email</label>
<input
type="text"
autoComplete="username"
value={handle}
onChange={(e) => setHandle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Password</label>
<PasswordInput value={password} onChange={setPassword} autoComplete="current-password" />
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
)}
<p className="mt-5 text-xs text-gray-400 text-center">
Trouble signing in?{" "}
<a href="mailto:Portal@ten31.xyz" className="text-accent-600 hover:text-accent-700">