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:
co-authored by
Claude Fable 5
parent
053bfeab23
commit
0822eca887
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user