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.
260 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|