Files
vulncheck/frontend/app/login/page.tsx
T
vulncheck dbad9a365e chore(rebrand): VulnCheck → TrueVuln
Renames the product name in every user-visible surface and internal
self-reference: page title, nav/shell, login/MFA pages, email templates and
subject prefixes ([VULNCHECK] → [TRUEVULN]), TOTP issuer label, report/PDF
headers, notification previews, outbound User-Agent/HTTP-Referer headers we
set ourselves, docs (README, ARCHITECTURE, PROJECT_OVERVIEW, DATABASE_SCHEMA,
README.DEV, TROUBLESHOOTING is untouched — see below), and .env.example
placeholder config (LDAP/OIDC/SAML example domains and paths).

Also renamed the on-disk cache file paths (/tmp/vulncheck-*.zip|csv|json →
/tmp/truevuln-*), kept consistent across the two files that share the
cvelistV5 ZIP cache path — first run after deploy re-downloads that ~557 MB
cache once (harmless, disposable).

Deliberately LEFT UNCHANGED (not branding — real external references or
infra identifiers; renaming the text without renaming the underlying thing
would just break/mislead):
- The actual Gitea repo URL/path (gitea.isuit.ch/vulncheck/vulncheck) and the
  README lines derived from it (git clone target dir, tree listing) — a real
  repo rename is a manual Gitea-side step (Settings → repository name) the
  user would need to do themselves, and existing clones would need
  `git remote set-url` after.
- The real support mailbox (support-vulncheck.sq9vd@passmail.net, in both
  README and TROUBLESHOOTING) and the Buy Me A Coffee link — both point to
  accounts that still exist under the old name; renaming the text alone
  wouldn't create new ones.
- GitNexus MCP resource URIs in CLAUDE.md/AGENTS.md (gitnexus://repo/
  vulncheck/...) — tied to GitNexus's own index name for this repo, not our
  branding; those files are untracked in this repo anyway.
- docker-compose.yml container/network/Postgres user+db names
  (vulnmanager-*) — explicit user decision: infra naming carries real
  deploy/data risk on an already-running instance and isn't part of the
  product-branding ask.
- The Tailwind color token class `vulncheck-blue` (frontend/app/globals.css)
  — invisible internal CSS variable name, renaming it would touch ~270
  className occurrences for zero user-visible benefit.

Verified: backend py_compile clean on every touched .py file; frontend tsc
clean (two pre-existing, unrelated errors remain: assets/page.tsx SVG title
prop, mfa-setup missing qrcode.react types). All diffs are exact-string
renames — no other changes riding along.
2026-07-07 16:34:40 +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-vulncheck-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-vulncheck-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-vulncheck-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-vulncheck-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-vulncheck-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>
);
}