Files
vulncheck/frontend/app/login/page.tsx
T
vulncheck d6d2c3209b fix(ui): rebrand left ~115 truevuln-blue classes with no matching CSS var
The rebrand renamed many Tailwind color classes vulncheck-blue → truevuln-blue
but left the theme token as --color-vulncheck-blue, so those 115 classes
referenced an undefined color → transparent backgrounds / unstyled text
(tester: the "Save Template" button was invisible until hover; also affected
buttons, links, focus rings, sort arrows app-wide).

Unified everything on truevuln-blue: renamed the @theme token to
--color-truevuln-blue and the remaining 156 vulncheck-blue class usages to
truevuln-blue. Now all 271 usages resolve to one defined token; 0 vulncheck-blue
left.
2026-07-09 08:21:07 +02:00

260 lines
13 KiB
TypeScript

"use client";
import { useEffect, useState } from 'react';
import api from '../../lib/api';
type AuthProvider = {
name: 'local' | 'ldap' | 'oidc' | 'saml';
credentials: boolean; // shows username/password form
redirect: boolean; // shows redirect button
display_name: string;
login_url?: string | null;
};
type Stage = 'credentials' | 'mfa';
export default function LoginPage() {
const [providers, setProviders] = useState<AuthProvider[]>([]);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [mfaToken, setMfaToken] = useState('');
const [stage, setStage] = useState<Stage>('credentials');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
// Fetch enabled providers so we render the right buttons.
useEffect(() => {
api.get('/auth/providers')
.then(res => setProviders(res.data?.providers || []))
.catch(() => setProviders([{ name: 'local', credentials: true, redirect: false, display_name: 'Local account' }]));
}, []);
const credentialProviders = providers.filter(p => p.credentials);
const redirectProviders = providers.filter(p => p.redirect && p.login_url);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await api.post('/auth/login', { username, password });
// Forced TOTP enrolment (policy-driven). Hand off to the
// dedicated /auth/forced-mfa-setup page carrying the setup
// token. User cannot get a session before completing it.
if (response.data?.mfa_setup_required && response.data?.setup_token) {
window.location.href = `/mfa-setup?token=${encodeURIComponent(response.data.setup_token)}`;
return;
}
// Backend signals MFA needed — switch to second-factor stage.
if (response.data?.mfa_required) {
setMfaToken(response.data.mfa_token);
setStage('mfa');
setLoading(false);
return;
}
if (response.data?.access_token) {
window.location.href = '/';
return;
}
setError('Login failed: no access token received.');
} catch (err: any) {
const status = err.response?.status;
const detail = err.response?.data?.detail;
if (status === 403) setError(detail || 'Account locked or deactivated.');
else if (status === 429) setError('Too many login attempts. Please wait a moment.');
else if (status === 401) setError('Invalid username or password.');
else setError(detail || 'Login failed. Please try again.');
} finally {
setLoading(false);
}
};
const handleMfaVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await api.post('/auth/mfa/verify', {
mfa_token: mfaToken,
code: mfaCode,
});
if (response.data?.access_token) {
window.location.href = '/';
return;
}
setError('MFA verification failed.');
} catch (err: any) {
const detail = err.response?.data?.detail;
setError(detail || 'Invalid or expired MFA code.');
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen flex-col justify-center px-6 py-12 lg:px-8 bg-gray-50">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<img src="/logo.svg" alt="TrueVuln Logo" className="mx-auto h-24 w-24" />
<h2 className="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900 font-mono">
TrueVuln
</h2>
<p className="text-center text-sm text-gray-500 font-mono mt-2">
{stage === 'credentials' ? 'Sign in to your account' : 'Enter your verification code'}
</p>
</div>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
{stage === 'credentials' && (
<>
{/* SSO redirect buttons (OIDC / SAML) */}
{redirectProviders.length > 0 && (
<div className="space-y-2 mb-6">
{redirectProviders.map(p => (
<a
key={p.name}
href={p.login_url || '#'}
className="flex w-full items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm hover:bg-gray-50 font-mono"
>
<span>Sign in with {p.display_name}</span>
</a>
))}
{credentialProviders.length > 0 && (
<div className="relative my-4">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-gray-50 px-2 text-gray-500 font-mono uppercase">or</span>
</div>
</div>
)}
</div>
)}
{/* Local / LDAP credentials form */}
{credentialProviders.length > 0 && (
<form className="space-y-6" onSubmit={handleLogin}>
<div>
<label htmlFor="username" className="block text-sm font-medium leading-6 text-gray-900 font-mono">
Username
</label>
<div className="mt-2">
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium leading-6 text-gray-900 font-mono">
Password
</label>
<div className="mt-2">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue sm:text-sm sm:leading-6"
/>
</div>
</div>
{error && (
<div className="text-red-600 text-sm font-mono text-center bg-red-50 p-2 rounded">
{error}
</div>
)}
<div>
<button
type="submit"
disabled={loading}
className="flex w-full justify-center rounded-md bg-truevuln-blue px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 font-mono"
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</div>
{credentialProviders.length > 1 && (
<p className="text-center text-[11px] text-gray-400 font-mono">
Tries {credentialProviders.map(p => p.display_name).join(' → ')}
</p>
)}
</form>
)}
</>
)}
{stage === 'mfa' && (
<form className="space-y-6" onSubmit={handleMfaVerify}>
<div>
<label htmlFor="mfa" className="block text-sm font-medium leading-6 text-gray-900 font-mono">
Authenticator code
</label>
<p className="text-xs text-gray-500 font-mono mt-1">
Open your authenticator app and enter the 6-digit code.
</p>
<div className="mt-2">
<input
id="mfa"
name="mfa"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
autoComplete="one-time-code"
autoFocus
required
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, ''))}
className="block w-full rounded-md border-0 py-2 text-center text-xl tracking-[0.5em] font-mono text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-truevuln-blue"
placeholder="000000"
/>
</div>
</div>
{error && (
<div className="text-red-600 text-sm font-mono text-center bg-red-50 p-2 rounded">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => { setStage('credentials'); setMfaCode(''); setMfaToken(''); setError(''); }}
className="flex-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-semibold text-gray-700 hover:bg-gray-50 font-mono"
>
Back
</button>
<button
type="submit"
disabled={loading || mfaCode.length !== 6}
className="flex-[2] rounded-md bg-truevuln-blue px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-600 disabled:opacity-60 font-mono"
>
{loading ? 'Verifying…' : 'Verify'}
</button>
</div>
</form>
)}
</div>
</div>
);
}