feat: add operator landing and login
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-22 20:10:21 +02:00
parent 36d137e224
commit 115f9850a7
27 changed files with 1448 additions and 8 deletions
@@ -0,0 +1,48 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LandingPage } from './LandingPage'
import { login } from '../../services/api/auth'
vi.mock('../../services/api/auth', () => ({
login: vi.fn(),
}))
describe('LandingPage', () => {
beforeEach(() => {
vi.mocked(login).mockReset()
})
afterEach(() => cleanup())
it('shows the Stitch-derived landing content and submits the real login flow', async () => {
const onAuthenticated = vi.fn()
vi.mocked(login).mockResolvedValue({
authentication_required: true,
authenticated: true,
username: 'operator',
expires_at: '2026-07-22T20:00:00Z',
})
render(<LandingPage onAuthenticated={onAuthenticated} />)
expect(screen.getByRole('heading', { name: /Operationele GIS-analyse/i })).toBeTruthy()
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: 'operator' } })
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'correct' } })
fireEvent.click(screen.getAllByRole('button', { name: 'Inloggen' })[1])
await waitFor(() => expect(login).toHaveBeenCalledWith('operator', 'correct'))
expect(onAuthenticated).toHaveBeenCalledWith(expect.objectContaining({ authenticated: true }))
})
it('surfaces an authentication error without entering the workbench', async () => {
vi.mocked(login).mockRejectedValue(new Error('Gebruikersnaam of wachtwoord is onjuist.'))
render(<LandingPage onAuthenticated={vi.fn()} />)
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: 'operator' } })
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'wrong' } })
fireEvent.click(screen.getAllByRole('button', { name: 'Inloggen' })[1])
expect((await screen.findByRole('alert')).textContent).toContain('Gebruikersnaam of wachtwoord is onjuist.')
})
})
@@ -0,0 +1,229 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'
import {
ArrowRight,
BrainCircuit,
CheckCircle2,
Database,
Layers3,
LockKeyhole,
LogIn,
MapPinned,
Menu,
ShieldCheck,
X,
} from 'lucide-react'
import { login } from '../../services/api/auth'
import type { AuthSession } from '../../services/api/auth'
import { formatError } from '../../lib/formatError'
import '../../styles/landing.css'
interface LandingPageProps {
onAuthenticated: (session: AuthSession) => void
serviceError?: string | null
}
const capabilityItems = [
{
icon: MapPinned,
title: 'Heel België in beeld',
description:
'Werk met officiële bronnen voor Vlaanderen, Wallonië, Brussel en de Belgische Noordzee, zonder regionale semantiek te vermengen.',
tags: ['NGI', 'SPW', 'Digitaal Vlaanderen'],
tone: 'primary',
},
{
icon: BrainCircuit,
title: 'AI met bewijsgrenzen',
description:
'Voer objectdetectie uit op geschikte luchtbeelden en beoordeel resultaten tegen referentiedata met zichtbare model- en validatiegrenzen.',
tags: ['Objectdetectie', 'Lokale assistent'],
tone: 'secondary',
},
{
icon: ShieldCheck,
title: 'Operationele kwaliteit',
description:
'Elke analyse bewaart bron, meetmoment, CRS, eenheid en beperkingen. Resultaten blijven inspecteerbaar vóór export of besluitvorming.',
tags: ['QA/QC', 'Herleidbaar'],
tone: 'attention',
},
]
export function LandingPage({ onAuthenticated, serviceError = null }: LandingPageProps): JSX.Element {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [loginError, setLoginError] = useState<string | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const usernameRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
document.body.classList.add('landing-body')
return () => document.body.classList.remove('landing-body')
}, [])
const submitLogin = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setSubmitting(true)
setLoginError(null)
try {
const session = await login(username.trim(), password)
onAuthenticated(session)
} catch (error) {
setLoginError(formatError(error, 'Aanmelden is niet gelukt. Probeer het opnieuw.'))
} finally {
setSubmitting(false)
}
}
const focusLogin = () => {
setMenuOpen(false)
window.requestAnimationFrame(() => usernameRef.current?.focus())
}
return (
<div className="landing-page">
<a className="landing-skip-link" href="#login-panel">Ga naar aanmelden</a>
<header className="landing-header">
<a className="landing-brand" href="#top" aria-label="GeoIntel Atlas startpagina">
<span className="landing-brand-mark" aria-hidden="true">GI</span>
<span>GeoIntel Atlas</span>
</a>
<button
className="landing-menu-toggle"
type="button"
aria-label={menuOpen ? 'Navigatie sluiten' : 'Navigatie openen'}
aria-expanded={menuOpen}
onClick={() => setMenuOpen((current) => !current)}
>
{menuOpen ? <X aria-hidden="true" /> : <Menu aria-hidden="true" />}
</button>
<nav className={menuOpen ? 'landing-nav landing-nav-open' : 'landing-nav'} aria-label="Landingspagina">
<a href="#mogelijkheden" onClick={() => setMenuOpen(false)}>Verkennen</a>
<a href="#werkproces" onClick={() => setMenuOpen(false)}>Analyseren</a>
<a href="#kwaliteit" onClick={() => setMenuOpen(false)}>Kwaliteit</a>
</nav>
<button className="landing-header-login" type="button" onClick={focusLogin}>
Inloggen
</button>
</header>
<main id="top">
<section className="landing-hero" aria-labelledby="landing-title">
<div className="landing-hero-background" aria-hidden="true" />
<div className="landing-hero-content">
<div className="landing-hero-copy">
<p className="landing-kicker"><CheckCircle2 aria-hidden="true" /> Operationele GeoAI-workbench</p>
<h1 id="landing-title">Operationele GIS-analyse <span>op topniveau.</span></h1>
<p className="landing-lead">
De kaartgerichte workbench voor professionals die werken met gegevens van België en de Belgische Noordzee. Selecteer een gebied, meet officiële bronnen en controleer ieder resultaat.
</p>
<div className="landing-hero-actions">
<a className="landing-primary-action" href="#mogelijkheden">
<MapPinned aria-hidden="true" /> Bekijk mogelijkheden
</a>
<button className="landing-secondary-action" type="button" onClick={focusLogin}>
Naar inloggen <ArrowRight aria-hidden="true" />
</button>
</div>
<dl className="landing-trust-strip" aria-label="Platformbereik">
<div><dt>Geografie</dt><dd>België + Noordzee</dd></div>
<div><dt>Bronnen</dt><dd>Officieel per regio</dd></div>
<div><dt>Uitvoer</dt><dd>GIS-herleidbaar</dd></div>
</dl>
</div>
<div className="landing-login-card" id="login-panel">
<div className="landing-login-heading">
<span className="landing-login-icon" aria-hidden="true"><LockKeyhole /></span>
<div>
<h2>Toegang Workbench</h2>
<p>Log in met uw GeoIntel-account.</p>
</div>
</div>
<form onSubmit={submitLogin} aria-busy={submitting}>
<label htmlFor="login-username">Gebruikersnaam</label>
<input
ref={usernameRef}
id="login-username"
name="username"
type="text"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={submitting}
required
/>
<label htmlFor="login-password">Wachtwoord</label>
<input
id="login-password"
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={submitting}
required
/>
{loginError || serviceError ? (
<p className="landing-login-error" role="alert">{loginError ?? serviceError}</p>
) : null}
<button type="submit" disabled={submitting || !username.trim() || !password}>
<LogIn aria-hidden="true" /> {submitting ? 'Aanmelden…' : 'Inloggen'}
</button>
</form>
<p className="landing-session-note"><ShieldCheck aria-hidden="true" /> Beveiligde, tijdelijke operatorsessie</p>
</div>
</div>
</section>
<section className="landing-capabilities" id="mogelijkheden" aria-labelledby="capabilities-title">
<div className="landing-section-heading">
<p>Van bron tot besluit</p>
<h2 id="capabilities-title">Eén werkbank, controle over de hele keten</h2>
</div>
<div className="landing-capability-grid">
{capabilityItems.map(({ icon: Icon, title, description, tags, tone }) => (
<article key={title} className={`landing-capability landing-capability-${tone}`}>
<span className="landing-capability-icon" aria-hidden="true"><Icon /></span>
<h3>{title}</h3>
<p>{description}</p>
<div>{tags.map((tag) => <span key={tag}>{tag}</span>)}</div>
</article>
))}
</div>
</section>
<section className="landing-workflow" id="werkproces" aria-labelledby="workflow-title">
<div className="landing-workflow-map">
<div className="landing-workflow-map-image" aria-hidden="true" />
<div>
<p>Kaart als werkomgeving</p>
<h2 id="workflow-title">Van selectie naar aantoonbaar inzicht</h2>
<span>Kies thema → teken gebied → controleer bron → analyseer → exporteer</span>
</div>
</div>
<div className="landing-workflow-details">
<article>
<Database aria-hidden="true" />
<div><h3>Bronnen per rechtsgebied</h3><p>Vlaamse, Waalse, Brusselse en maritieme bronnen blijven herkenbaar gescheiden.</p></div>
</article>
<article>
<Layers3 aria-hidden="true" />
<div><h3>Toestand en evolutie</h3><p>Vergelijk alleen meetmomenten die inhoudelijk en ruimtelijk verenigbaar zijn.</p></div>
</article>
<article id="kwaliteit">
<ShieldCheck aria-hidden="true" />
<div><h3>Kwaliteit vóór export</h3><p>CRS, eenheid, dekking, herkomst en beperkingen blijven naast het resultaat zichtbaar.</p></div>
</article>
</div>
</section>
</main>
<footer className="landing-footer">
<div><strong>GeoIntel Atlas Workbench</strong><p>Operationele GIS-analyse voor België en de Belgische Noordzee.</p></div>
<p>© {new Date().getFullYear()} GeoIntel · Interne operatoromgeving</p>
</footer>
</div>
)
}