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.
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-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>
|
|
);
|
|
}
|