Update
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:
Jens
2026-07-27 23:28:43 +02:00
parent 21015757bd
commit c76a746cd7
39 changed files with 3268 additions and 519 deletions
+105 -38
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { CircleAlert, LogOut, UserRound } from 'lucide-react'
import { CircleAlert, LogOut, ShieldCheck, UserRound } from 'lucide-react'
import '@fontsource/manrope/latin-500.css'
import '@fontsource/manrope/latin-600.css'
import '@fontsource/manrope/latin-700.css'
@@ -10,6 +10,7 @@ import './styles/app.css'
import './styles/premium.css'
import './styles/atlas-workbench.css'
import './styles/atlas-premium-v2.css'
import './styles/professionalization.css'
import { LandingPage } from './components/auth/LandingPage'
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
import { GeoAssistantPanel } from './components/assistant/GeoAssistantPanel'
@@ -86,20 +87,39 @@ const workspaceNavGroups: WorkspaceNavigationGroup[] = [
{ label: 'Beheer', keys: ['overview', 'system'] },
]
const guestWorkspaceKeys = new Set<WorkspaceKey>(['map', 'analysis'])
const guestWorkspaceGroups: WorkspaceNavigationGroup[] = [
{ label: 'Demowerkruimte', keys: ['map', 'analysis'] },
]
interface WorkbenchAppProps {
username: string | null
accessMode: 'open' | 'operator' | 'guest'
loggingOut: boolean
onLogout: () => void
}
function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JSX.Element {
function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchAppProps): JSX.Element {
const isGuest = accessMode === 'guest'
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')
const [inspectorOpen, setInspectorOpen] = useState(false)
const [guestDemoReady, setGuestDemoReady] = useState(!isGuest)
const guestDemoStartedRef = useRef(false)
const visibleWorkspaceItems = useMemo(
() => isGuest ? workspaceNavItems.filter((item) => guestWorkspaceKeys.has(item.key)) : workspaceNavItems,
[isGuest],
)
const visibleWorkspaceGroups = isGuest ? guestWorkspaceGroups : workspaceNavGroups
const [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset')
const [mapContextSourceLabel, setMapContextSourceLabel] = useState<string | null>(null)
const [mapContextLayerLabel, setMapContextLayerLabel] = useState<string | null>(null)
const workbenchMainRef = useRef<HTMLElement | null>(null)
const previousWorkspaceRef = useRef<WorkspaceKey>(activeWorkspace)
useEffect(() => {
if (isGuest && !guestWorkspaceKeys.has(activeWorkspace)) {
setActiveWorkspace('map')
}
}, [activeWorkspace, isGuest])
useEffect(() => {
workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })
if (previousWorkspaceRef.current !== activeWorkspace) {
@@ -566,6 +586,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
demoWorkflowMessage,
loadDemoWorkflow,
} = useDemoWorkflow({
restrictedMode: isGuest,
loadProjects,
loadProjectData,
loadDatasetDetails,
@@ -586,7 +607,15 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
setErrorMessage,
})
useEffect(() => {
if (!isGuest || guestDemoStartedRef.current) return
guestDemoStartedRef.current = true
setGuestDemoReady(false)
void loadDemoWorkflow().finally(() => setGuestDemoReady(true))
}, [isGuest, loadDemoWorkflow])
useWorkbenchBootstrap({
restrictedMode: isGuest,
selectedProjectId,
selectedDetectionRunId,
detectionClassFilter,
@@ -613,7 +642,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
})
const selectedArea = areas.find((area) => area.id === selectedMapAreaId) ?? null
const activeWorkspaceItem = workspaceNavItems.find((item) => item.key === activeWorkspace) ?? workspaceNavItems[0]
const activeWorkspaceItem = visibleWorkspaceItems.find((item) => item.key === activeWorkspace) ?? visibleWorkspaceItems[0]
const mapLayerSourceLabel = useMemo(() => {
if (analysisMapLayerActive && changeDetectionResult?.geojson) {
return 'Veranderingsanalyse'
@@ -687,6 +716,10 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
setActiveWorkspace('exports')
}
const openWorkflowGuidanceStep = (target: WorkspaceKey) => {
if (isGuest && !guestWorkspaceKeys.has(target)) {
setActiveWorkspace(target === 'exports' ? 'analysis' : 'map')
return
}
if (target === 'map' && availableMapDatasets.length > 0 && !mapFeatureCollection) {
openDatasetInMap(availableMapDatasets[0])
return
@@ -781,7 +814,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
: 'Geen actieve laag'
return (
<div className="app-shell workbench-shell">
<div className={isGuest ? "app-shell workbench-shell workbench-shell-guest" : "app-shell workbench-shell"}>
<a
className="skip-link"
href="#workspace-main"
@@ -805,8 +838,8 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
<div className="workbench-layout">
<WorkbenchNavigation
activeWorkspace={activeWorkspace}
groups={workspaceNavGroups}
items={workspaceNavItems}
groups={visibleWorkspaceGroups}
items={visibleWorkspaceItems}
onSelect={setActiveWorkspace}
/>
@@ -838,18 +871,39 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
<span aria-hidden="true" />
<strong>{workspaceDataLoading ? 'Laden' : errorMessage ? 'Aandacht' : 'Gereed'}</strong>
</div>
{username ? (
<div className="context-account" aria-label="Aangemelde gebruiker">
<UserRound aria-hidden="true" />
<span title={username}>{username}</span>
{username || isGuest ? (
<div
className={isGuest ? 'context-account context-account-guest' : 'context-account'}
aria-label={isGuest ? 'Tijdelijke gastensessie' : 'Aangemelde gebruiker'}
>
{isGuest ? <ShieldCheck aria-hidden="true" /> : <UserRound aria-hidden="true" />}
<span title={username ?? undefined}>{isGuest ? 'Gastmodus' : username}</span>
<button type="button" onClick={onLogout} disabled={loggingOut}>
<LogOut aria-hidden="true" />
<span>{loggingOut ? 'Uitloggen' : 'Uitloggen'}</span>
<span>{loggingOut ? 'Afsluiten…' : isGuest ? 'Demo afsluiten' : 'Uitloggen'}</span>
</button>
</div>
) : null}
</header>
{isGuest ? (
<div className="guest-mode-banner" role="status">
<ShieldCheck aria-hidden="true" />
<div>
<strong>Tijdelijke demowerkruimte</strong>
<span>U verkent vooraf geladen voorbeelddata. Wijzigingen, nieuwe analyses en operatorfuncties zijn uitgeschakeld.</span>
</div>
<span className="guest-mode-badge">Alleen-lezen</span>
</div>
) : null}
{isGuest && (!guestDemoReady || loadingDemoWorkflow) ? (
<div className="guest-demo-loading" role="status" aria-live="polite">
<span aria-hidden="true" />
<strong>Demodata en kwaliteitsbewijs worden voorbereid</strong>
</div>
) : null}
<div className="workbench-content">
<main
@@ -867,7 +921,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
<WorkspaceSignal workspace={activeWorkspace} />
<div className="workspace-heading-actions">
<p>{activeWorkspaceItem.description}</p>
{activeWorkspace !== 'overview' ? (
{!isGuest && activeWorkspace !== 'overview' ? (
<button
type="button"
className={inspectorOpen ? 'inspector-toggle inspector-toggle-active' : 'inspector-toggle'}
@@ -950,6 +1004,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
<div className="workspace-persistent-map" hidden={activeWorkspace !== 'map'}>
<MapWorkspace
readOnly={isGuest}
selectedProjectId={selectedProjectId}
projects={projects}
areas={areas}
@@ -1045,8 +1100,8 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
onRefreshProjectData={() => (
selectedProjectId ? loadProjectData(selectedProjectId) : Promise.resolve(null)
)}
onOpenAssistant={() => setActiveWorkspace('assistant')}
onOpenExports={() => setActiveWorkspace('exports')}
onOpenAssistant={() => setActiveWorkspace(isGuest ? 'analysis' : 'assistant')}
onOpenExports={() => setActiveWorkspace(isGuest ? 'analysis' : 'exports')}
/>
</div>
@@ -1063,29 +1118,39 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
evidenceLoading={qualityEvidenceLoading}
evidenceError={qualityEvidenceError}
onOpenMapWorkspace={() => setActiveWorkspace('map')}
onOpenAnalysisWorkspace={() => setActiveWorkspace('ai')}
onOpenAnalysisWorkspace={() => setActiveWorkspace(isGuest ? 'map' : 'ai')}
/>
<details className="secondary-analysis-disclosure">
<summary>
<span>Historische vectorlagen vergelijken</span>
<strong>Geavanceerd</strong>
</summary>
<ChangeDetectionPanel
vectorDatasets={availableVectorDatasets}
sourceDatasetId={changeSourceDatasetId}
targetDatasetId={changeTargetDatasetId}
iouThreshold={changeIouThreshold}
includeUnchanged={changeIncludeUnchanged}
running={runningChangeDetection}
result={changeDetectionResult}
error={changeDetectionError}
onSourceDatasetChange={setChangeSourceDatasetId}
onTargetDatasetChange={setChangeTargetDatasetId}
onIouThresholdChange={setChangeIouThreshold}
onIncludeUnchangedChange={setChangeIncludeUnchanged}
onRun={runChangeDetection}
/>
</details>
{!isGuest ? (
<details className="secondary-analysis-disclosure">
<summary>
<span>Historische vectorlagen vergelijken</span>
<strong>Geavanceerd</strong>
</summary>
<ChangeDetectionPanel
vectorDatasets={availableVectorDatasets}
sourceDatasetId={changeSourceDatasetId}
targetDatasetId={changeTargetDatasetId}
iouThreshold={changeIouThreshold}
includeUnchanged={changeIncludeUnchanged}
running={runningChangeDetection}
result={changeDetectionResult}
error={changeDetectionError}
onSourceDatasetChange={setChangeSourceDatasetId}
onTargetDatasetChange={setChangeTargetDatasetId}
onIouThresholdChange={setChangeIouThreshold}
onIncludeUnchangedChange={setChangeIncludeUnchanged}
onRun={runChangeDetection}
/>
</details>
) : (
<div className="guest-readonly-card">
<ShieldCheck aria-hidden="true" />
<div>
<strong>Controleerbaar voorbeeldresultaat</strong>
<span>Deze kwaliteitsweergave gebruikt de vooraf berekende demo. Nieuwe vergelijkingen zijn alleen beschikbaar voor operators.</span>
</div>
</div>
)}
</div>
) : null}
@@ -1250,7 +1315,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
) : null}
</main>
{inspectorOpen ? (
{inspectorOpen && !isGuest ? (
<aside className="workbench-inspector" id="workbench-inspector" aria-label="Details van de huidige selectie">
<WorkbenchInspector
selectedProject={selectedProject}
@@ -1357,6 +1422,7 @@ function App(): JSX.Element {
return (
<LandingPage
serviceError={sessionError}
guestAccessEnabled={session.guest_access_enabled}
onAuthenticated={handleAuthenticated}
/>
)
@@ -1364,7 +1430,8 @@ function App(): JSX.Element {
return (
<WorkbenchApp
username={session.authentication_required ? session.username : null}
username={session.username}
accessMode={session.role ?? (session.authentication_required ? 'operator' : 'open')}
loggingOut={loggingOut}
onLogout={handleLogout}
/>
+4 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import maplibregl from 'maplibre-gl'
import type { ExpressionSpecification } from '@maplibre/maplibre-gl-style-spec'
import 'maplibre-gl/dist/maplibre-gl.css'
import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus'
import { featureCollectionBounds } from '../lib/geojsonBounds'
@@ -53,7 +54,7 @@ const DEFAULT_ROAD_BASEMAP_STYLE: maplibregl.StyleSpecification = {
],
}
function datasetFillColor(fallbackColor: string): maplibregl.ExpressionSpecification {
function datasetFillColor(fallbackColor: string): ExpressionSpecification {
return [
'case',
['==', ['get', 'layer_type'], 'municipality_boundary'],
@@ -74,7 +75,7 @@ function datasetFillColor(fallbackColor: string): maplibregl.ExpressionSpecifica
]
}
function datasetLineColor(fallbackColor: string): maplibregl.ExpressionSpecification {
function datasetLineColor(fallbackColor: string): ExpressionSpecification {
return [
'case',
['==', ['get', 'layer_type'], 'municipality_boundary'],
@@ -689,7 +690,7 @@ function GeoMap({
'false_negative',
'#d97706',
'#475569',
] as maplibregl.ExpressionSpecification
] as ExpressionSpecification
map.addLayer({
id: 'qa-evidence-fill',
type: 'fill',
@@ -1,4 +1,5 @@
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, render } from '@testing-library/react'
import { screen } from '@testing-library/dom'
import { afterEach, describe, expect, it } from 'vitest'
import type { AreaRead, DatasetCreateResponse, ProjectRead } from '../types'
import { WorkbenchStatusStrip } from './WorkbenchStatusStrip'
@@ -1,48 +1,83 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, render } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LandingPage } from './LandingPage'
import { login } from '../../services/api/auth'
import { login, loginAsGuest } from '../../services/api/auth'
vi.mock('../../services/api/auth', () => ({
login: vi.fn(),
loginAsGuest: vi.fn(),
}))
const operatorSession = {
authentication_required: true,
authenticated: true,
username: 'operator',
expires_at: '2026-07-27T20:00:00Z',
role: 'operator' as const,
guest_access_enabled: true,
guest_project_id: null,
}
const guestSession = {
authentication_required: true,
authenticated: true,
username: 'Gast',
expires_at: '2026-07-27T20:00:00Z',
role: 'guest' as const,
guest_access_enabled: true,
guest_project_id: '00000000-0000-0000-0000-000000000123',
}
describe('LandingPage', () => {
beforeEach(() => {
vi.mocked(login).mockReset()
vi.mocked(loginAsGuest).mockReset()
})
afterEach(() => cleanup())
it('shows the Stitch-derived landing content and submits the real login flow', async () => {
it('presents the professionalised landing page and submits the operator login', 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} />)
vi.mocked(login).mockResolvedValue(operatorSession)
render(<LandingPage onAuthenticated={onAuthenticated} guestAccessEnabled />)
expect(screen.getByRole('heading', { name: /Operationele GIS-analyse/i })).toBeTruthy()
expect(screen.getByRole('heading', { name: /Van kaartlaag naar aantoonbaar inzicht/i })).toBeTruthy()
expect(screen.getByRole('img', { name: 'ITWorx.tech' })).toBeTruthy()
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: 'operator' } })
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: ' operator ' } })
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'correct' } })
fireEvent.click(screen.getAllByRole('button', { name: 'Inloggen' })[1])
fireEvent.click(screen.getByRole('button', { name: 'Inloggen als operator' }))
await waitFor(() => expect(login).toHaveBeenCalledWith('operator', 'correct'))
expect(onAuthenticated).toHaveBeenCalledWith(expect.objectContaining({ authenticated: true }))
expect(onAuthenticated).toHaveBeenCalledWith(operatorSession)
})
it('surfaces an authentication error without entering the workbench', async () => {
it('opens a guest session without asking for credentials', async () => {
const onAuthenticated = vi.fn()
vi.mocked(loginAsGuest).mockResolvedValue(guestSession)
render(<LandingPage onAuthenticated={onAuthenticated} guestAccessEnabled />)
fireEvent.click(screen.getByRole('button', { name: /Als gast verkennen/i }))
await waitFor(() => expect(loginAsGuest).toHaveBeenCalledOnce())
expect(login).not.toHaveBeenCalled()
expect(onAuthenticated).toHaveBeenCalledWith(guestSession)
})
it('does not advertise guest access when the runtime has it disabled', () => {
render(<LandingPage onAuthenticated={vi.fn()} guestAccessEnabled={false} />)
expect(screen.queryByRole('button', { name: /gast/i })).toBeNull()
expect(screen.getByRole('button', { name: 'Open de workbench' })).toBeTruthy()
})
it('surfaces a useful 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])
fireEvent.click(screen.getByRole('button', { name: 'Inloggen als operator' }))
expect((await screen.findByRole('alert')).textContent).toContain('Gebruikersnaam of wachtwoord is onjuist.')
})
+263 -111
View File
@@ -2,19 +2,23 @@ import { useEffect, useRef, useState, type FormEvent } from 'react'
import {
ArrowRight,
BrainCircuit,
CheckCircle2,
Check,
ChevronRight,
Database,
Layers3,
Eye,
EyeOff,
LockKeyhole,
LogIn,
MapPinned,
Menu,
MousePointer2,
ShieldCheck,
Sparkles,
UserRound,
X,
} from 'lucide-react'
import { login } from '../../services/api/auth'
import type { AuthSession } from '../../services/api/auth'
import { formatError } from '../../lib/formatError'
import { login, loginAsGuest, type AuthSession } from '../../services/api/auth'
import { formatAuthError } from '../../lib/authError'
import '../../styles/landing.css'
import { GeoIntelMark } from '../brand/GeoIntelBrand'
import { ItWorxSignature } from '../brand/ItWorxSignature'
@@ -22,42 +26,54 @@ import { ItWorxSignature } from '../brand/ItWorxSignature'
interface LandingPageProps {
onAuthenticated: (session: AuthSession) => void
serviceError?: string | null
guestAccessEnabled?: boolean
}
const capabilityItems = [
{
icon: MapPinned,
title: 'Heel België in beeld',
number: '01',
title: 'Eén kaartgerichte werkruimte',
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',
'Selecteer een gebied in België of de Belgische Noordzee en werk verder vanuit dezelfde ruimtelijke context.',
},
{
icon: BrainCircuit,
title: 'AI met bewijsgrenzen',
icon: Database,
number: '02',
title: 'Bronnen blijven herkenbaar',
description:
'Voer objectdetectie uit op geschikte luchtbeelden en beoordeel resultaten tegen referentiedata met zichtbare model- en validatiegrenzen.',
tags: ['Objectdetectie', 'Lokale assistent'],
tone: 'secondary',
'Autoriteit, meetmoment, dekking, CRS en beperkingen blijven zichtbaar in plaats van achter één generieke kaartlaag te verdwijnen.',
},
{
icon: ShieldCheck,
title: 'Operationele kwaliteit',
number: '03',
title: 'Kwaliteit vóór resultaat',
description:
'Elke analyse bewaart bron, meetmoment, CRS, eenheid en beperkingen. Resultaten blijven inspecteerbaar vóór export of besluitvorming.',
tags: ['QA/QC', 'Herleidbaar'],
tone: 'attention',
'Vergelijk referentie- en kandidaatgegevens, controleer bewijs en exporteer pas wanneer de context klopt.',
},
]
export function LandingPage({ onAuthenticated, serviceError = null }: LandingPageProps): JSX.Element {
const workflowSteps = [
['Selecteer', 'Kies een officiële grens of teken zelf een gebied.'],
['Controleer', 'Bekijk dekking, bronkwaliteit en toepasselijke beperkingen.'],
['Analyseer', 'Meet toestand, evolutie of modelresultaten op de kaart.'],
['Onderbouw', 'Bewaar controleerbare resultaten en provenance.'],
]
export function LandingPage({
onAuthenticated,
serviceError = null,
guestAccessEnabled = false,
}: LandingPageProps): JSX.Element {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [loginError, setLoginError] = useState<string | null>(null)
const [pendingAction, setPendingAction] = useState<'operator' | 'guest' | null>(null)
const [authError, setAuthError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const usernameRef = useRef<HTMLInputElement | null>(null)
const accessPanelRef = useRef<HTMLElement | null>(null)
const busy = pendingAction !== null
useEffect(() => {
document.body.classList.add('landing-body')
@@ -66,167 +82,303 @@ export function LandingPage({ onAuthenticated, serviceError = null }: LandingPag
const submitLogin = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setSubmitting(true)
setLoginError(null)
setPendingAction('operator')
setAuthError(null)
try {
const session = await login(username.trim(), password)
onAuthenticated(session)
} catch (error) {
setLoginError(formatError(error, 'Aanmelden is niet gelukt. Probeer het opnieuw.'))
setAuthError(formatAuthError(error, 'Aanmelden is momenteel niet gelukt. Controleer de verbinding en probeer opnieuw.'))
} finally {
setSubmitting(false)
setPendingAction(null)
}
}
const submitGuestLogin = async () => {
setMenuOpen(false)
setPendingAction('guest')
setAuthError(null)
accessPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
try {
const session = await loginAsGuest()
onAuthenticated(session)
} catch (error) {
setAuthError(formatAuthError(error, 'De gastdemo kon niet worden voorbereid. Probeer het over enkele ogenblikken opnieuw.'))
} finally {
setPendingAction(null)
}
}
const focusLogin = () => {
setMenuOpen(false)
accessPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
window.requestAnimationFrame(() => usernameRef.current?.focus())
}
return (
<div className="landing-page">
<a className="landing-skip-link" href="#login-panel">Ga naar aanmelden</a>
<a className="landing-skip-link" href="#login-panel">Ga naar toegang</a>
<header className="landing-header">
<a className="landing-brand" href="#top" aria-label="GeoIntel Atlas startpagina">
<GeoIntelMark className="landing-brand-mark" />
<span>GeoIntel Atlas</span>
<span className="landing-brand-copy">
<strong>GeoIntel</strong>
<small>Atlas Workbench</small>
</span>
</a>
<button
className="landing-menu-toggle"
type="button"
aria-label={menuOpen ? 'Navigatie sluiten' : 'Navigatie openen'}
aria-expanded={menuOpen}
aria-controls="landing-navigation"
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>
<nav
id="landing-navigation"
className={menuOpen ? 'landing-nav landing-nav-open' : 'landing-nav'}
aria-label="Landingspagina"
>
<a href="#mogelijkheden" onClick={() => setMenuOpen(false)}>Mogelijkheden</a>
<a href="#werkproces" onClick={() => setMenuOpen(false)}>Werkproces</a>
<a href="#kwaliteit" onClick={() => setMenuOpen(false)}>Kwaliteit</a>
</nav>
<button className="landing-header-login" type="button" onClick={focusLogin}>
Inloggen
</button>
<div className="landing-header-actions">
{guestAccessEnabled ? (
<button className="landing-header-guest" type="button" onClick={submitGuestLogin} disabled={busy}>
Gastdemo
</button>
) : null}
<button className="landing-header-login" type="button" onClick={focusLogin} disabled={busy}>
Inloggen
</button>
</div>
</header>
<main id="top">
<section className="landing-hero" aria-labelledby="landing-title">
<div className="landing-hero-background" aria-hidden="true" />
<div className="landing-hero-orbit landing-hero-orbit-one" aria-hidden="true" />
<div className="landing-hero-orbit landing-hero-orbit-two" 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 className="landing-kicker">
<Sparkles aria-hidden="true" /> GeoAI-workbench voor België en de Noordzee
</p>
<h1 id="landing-title">
Van kaartlaag naar <span>aantoonbaar inzicht.</span>
</h1>
<p className="landing-lead">
GeoIntel brengt officiële bronnen, ruimtelijke analyse, AI-resultaten en kwaliteitscontrole samen in één professionele werkomgeving. Niet alleen zien wat er op de kaart staat, maar ook weten waarop het resultaat steunt.
</p>
<div className="landing-hero-actions">
<a className="landing-primary-action" href="#mogelijkheden">
<MapPinned aria-hidden="true" /> Bekijk mogelijkheden
{guestAccessEnabled ? (
<button className="landing-primary-action" type="button" onClick={submitGuestLogin} disabled={busy}>
<UserRound aria-hidden="true" />
{pendingAction === 'guest' ? 'Gastdemo voorbereiden…' : 'Verkennen als gast'}
</button>
) : (
<button className="landing-primary-action" type="button" onClick={focusLogin}>
<LogIn aria-hidden="true" /> Open de workbench
</button>
)}
<a className="landing-secondary-action" href="#mogelijkheden">
Bekijk mogelijkheden <ArrowRight aria-hidden="true" />
</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 className="landing-proof-row" aria-label="Kernkwaliteiten">
<div><Check aria-hidden="true" /><span><strong>Officiële bronnen</strong><small>per rechtsgebied</small></span></div>
<div><Check aria-hidden="true" /><span><strong>Meetbare kwaliteit</strong><small>vóór export</small></span></div>
<div><Check aria-hidden="true" /><span><strong>Volledige provenance</strong><small>bij ieder resultaat</small></span></div>
</div>
</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>
<section
className="landing-access-card"
id="login-panel"
ref={accessPanelRef}
aria-labelledby="access-title"
>
<div className="landing-access-preview" aria-hidden="true">
<div className="landing-preview-toolbar">
<span><i /> Live werkcontext</span>
<span>België · Noordzee</span>
</div>
<div className="landing-preview-focus">
<span className="landing-preview-pin"><MapPinned /></span>
<div>
<small>Actieve analyse</small>
<strong>Gebouwen &amp; referentiedata</strong>
<span>Brondekking gecontroleerd</span>
</div>
</div>
<div className="landing-preview-metrics">
<span><strong>4</strong><small>rechtsgebieden</small></span>
<span><strong>QA</strong><small>bewijs zichtbaar</small></span>
<span><strong>GIS</strong><small>exporteerbaar</small></span>
</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>
<div className="landing-access-body">
<div className="landing-access-heading">
<span className="landing-login-icon" aria-hidden="true"><LockKeyhole /></span>
<div>
<p>Veilige toegang</p>
<h2 id="access-title">Open de GeoIntel-workbench</h2>
<span>Meld u aan als operator of start een beperkte demosessie.</span>
</div>
</div>
<form onSubmit={submitLogin} aria-busy={pendingAction === 'operator'}>
<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={busy}
required
/>
<label htmlFor="login-password">Wachtwoord</label>
<div className="landing-password-field">
<input
id="login-password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={busy}
required
/>
<button
type="button"
aria-label={showPassword ? 'Wachtwoord verbergen' : 'Wachtwoord tonen'}
aria-pressed={showPassword}
onClick={() => setShowPassword((current) => !current)}
disabled={busy}
>
{showPassword ? <EyeOff aria-hidden="true" /> : <Eye aria-hidden="true" />}
</button>
</div>
{authError || serviceError ? (
<p className="landing-login-error" role="alert">{authError ?? serviceError}</p>
) : null}
<button
className="landing-operator-submit"
type="submit"
disabled={busy || !username.trim() || !password}
>
<LogIn aria-hidden="true" />
{pendingAction === 'operator' ? 'Veilig aanmelden…' : 'Inloggen als operator'}
</button>
</form>
{guestAccessEnabled ? (
<div className="landing-access-divider"><span>of verken zonder account</span></div>
) : 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>
{guestAccessEnabled ? (
<div className="landing-guest-access">
<span className="landing-guest-icon" aria-hidden="true"><UserRound /></span>
<div>
<strong>Gastmodus</strong>
<p>Open een tijdelijke, alleen-lezen demowerkruimte met voorbeelddata en een vooraf uitgevoerde kwaliteitscontrole.</p>
</div>
<button type="button" onClick={submitGuestLogin} disabled={busy}>
{pendingAction === 'guest' ? 'Demo voorbereiden…' : 'Als gast verkennen'}
<ChevronRight aria-hidden="true" />
</button>
</div>
) : null}
<p className="landing-session-note">
<ShieldCheck aria-hidden="true" /> HttpOnly-sessie · geen wachtwoordopslag in de browser
</p>
</div>
</section>
</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>
<p>Gebouwd voor controleerbare analyse</p>
<h2 id="capabilities-title">Geen los dashboard, maar één samenhangende GIS-keten</h2>
<span>De interface volgt de manier waarop een onderbouwde ruimtelijke analyse werkelijk tot stand komt.</span>
</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>
{capabilityItems.map(({ icon: Icon, number, title, description }) => (
<article key={title} className="landing-capability">
<div className="landing-capability-topline">
<span className="landing-capability-icon" aria-hidden="true"><Icon /></span>
<small>{number}</small>
</div>
<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-visual">
<div className="landing-workflow-map-image" aria-hidden="true" />
<div>
<div className="landing-workflow-overlay">
<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>
<h2 id="workflow-title">Van selectie naar een resultaat dat u kunt verdedigen</h2>
<span>Bron, gebied, meetmoment en kwaliteitsbewijs blijven samen zichtbaar.</span>
</div>
<div className="landing-workflow-floating" aria-hidden="true">
<MousePointer2 />
<span><small>Selectie gecontroleerd</small><strong>2,14 km² · 186 objecten</strong></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 className="landing-workflow-content">
<p className="landing-workflow-eyebrow">Een helder werkproces</p>
<h2>Vier stappen, zonder de technische context te verliezen</h2>
<div className="landing-workflow-steps">
{workflowSteps.map(([title, description], index) => (
<article key={title}>
<span>{String(index + 1).padStart(2, '0')}</span>
<div><h3>{title}</h3><p>{description}</p></div>
</article>
))}
</div>
<div className="landing-quality-callout" id="kwaliteit">
<BrainCircuit aria-hidden="true" />
<div>
<strong>AI blijft een gecontroleerde analysemethode</strong>
<p>Modelversie, confidence, referentiedata en validatiegrenzen blijven zichtbaar; ontbrekende configuratie wordt niet als succes voorgesteld.</p>
</div>
</div>
</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>
<div className="landing-footer-brand">
<GeoIntelMark className="landing-footer-mark" />
<div><strong>GeoIntel Atlas Workbench</strong><p>Operationele GIS-analyse voor België en de Belgische Noordzee.</p></div>
</div>
<div className="landing-footer-ownership">
<ItWorxSignature />
<p>© {new Date().getFullYear()} GeoIntel · Interne operatoromgeving</p>
<p>© {new Date().getFullYear()} GeoIntel · Professionele operator- en demonstratieomgeving</p>
</div>
</footer>
</div>
+65 -37
View File
@@ -697,6 +697,7 @@ function floodScenarioLabel(dataset: DatasetCreateResponse): string {
}
interface MapWorkspaceProps {
readOnly?: boolean
selectedProjectId: string | null
projects: ProjectRead[]
areas: AreaRead[]
@@ -791,6 +792,7 @@ interface MapWorkspaceProps {
}
export function MapWorkspace({
readOnly = false,
selectedProjectId,
projects,
areas,
@@ -884,6 +886,9 @@ export function MapWorkspace({
onOpenExports,
}: MapWorkspaceProps): JSX.Element {
const [advancedMode, setAdvancedMode] = useState(false)
useEffect(() => {
if (readOnly && advancedMode) setAdvancedMode(false)
}, [advancedMode, readOnly])
const [activeThemeId, setActiveThemeId] = useState<DataThemeId>(() => {
const selectedDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null
return themeIdForDataset(selectedDataset) ?? 'buildings'
@@ -2213,27 +2218,39 @@ export function MapWorkspace({
Evolutie
</button>
</div>
<button
className="secondary-action geo-explorer-advanced"
type="button"
onClick={() => setAdvancedMode(true)}
aria-expanded={advancedMode}
aria-controls="geo-advanced-workbench"
aria-label="Geavanceerde werkbank"
title="Geavanceerde werkbank"
>
<SlidersHorizontal aria-hidden="true" />
<span>Geavanceerde werkbank</span>
</button>
{!readOnly ? (
<button
className="secondary-action geo-explorer-advanced"
type="button"
onClick={() => setAdvancedMode(true)}
aria-expanded={advancedMode}
aria-controls="geo-advanced-workbench"
aria-label="Geavanceerde werkbank"
title="Geavanceerde werkbank"
>
<SlidersHorizontal aria-hidden="true" />
<span>Geavanceerde werkbank</span>
</button>
) : null}
</div>
</header>
<MunicipalitySearch
projectId={selectedProjectId}
activeArea={selectedMapArea ?? null}
disabled={workspaceLoading}
onActivate={onActivateMunicipality}
/>
{readOnly ? (
<div className="geo-guest-preview-note" role="note">
<MapPinned aria-hidden="true" />
<div>
<strong>Interactieve demo met bewaarde voorbeelddata</strong>
<span>U kunt kaartlagen verkennen, een selectie meten en kwaliteitsbewijs bekijken. Nieuwe gebieden, bronimports en bewaarde analyses zijn uitgeschakeld.</span>
</div>
</div>
) : (
<MunicipalitySearch
projectId={selectedProjectId}
activeArea={selectedMapArea ?? null}
disabled={workspaceLoading}
onActivate={onActivateMunicipality}
/>
)}
{workspaceLoading ? (
<div className="geo-bootstrap-status" role="status" aria-live="polite">
@@ -2266,6 +2283,8 @@ export function MapWorkspace({
<small>
{workspaceLoading
? 'Gebieden en bronnen worden geladen'
: readOnly
? `${municipalityAreaCount || 1} vooraf ingestelde demogrens; vrije kaartselectie blijft beschikbaar`
: municipalityAreaCount > 0
? `${municipalityAreaCount} geactiveerde gemeentegrenzen; vrij tekenen blijft mogelijk`
: 'Zoek optioneel een gemeente of teken vrij op de kaart'}
@@ -2280,7 +2299,7 @@ export function MapWorkspace({
const temporalGroup = temporalGroups[0]
const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
const available = !workspaceLoading && (analysisMode === 'current'
? Boolean(dataset || onDemandProduct)
? Boolean(dataset || (!readOnly && onDemandProduct))
: Boolean(dataset) && evolutionAvailable)
const active = activeThemeId === theme.id
const temporalRange = temporalGroup ? temporalRangeLabel(temporalGroup.items) : null
@@ -2308,7 +2327,7 @@ export function MapWorkspace({
: dataset
? `${datasetAvailabilityLabel(dataset, partitions)}${onDemandProduct ? ' · zo nodig automatisch aangevuld' : ''}`
: onDemandProduct
? onDemandProduct.availabilityLabel
? readOnly ? 'Niet opgenomen in deze demo' : onDemandProduct.availabilityLabel
: 'Bron nog niet ingeladen'}
</small>
</span>
@@ -2317,7 +2336,7 @@ export function MapWorkspace({
? 'Laden'
: analysisMode === 'evolution'
? evolutionAvailable ? 'Tijdreeks' : dataset ? 'Alleen huidig' : 'Ontbreekt'
: dataset ? 'Beschikbaar' : onDemandProduct ? 'Automatisch' : 'Ontbreekt'}
: dataset ? 'Beschikbaar' : onDemandProduct ? readOnly ? 'Niet in demo' : 'Automatisch' : 'Ontbreekt'}
</i>
</button>
)
@@ -2648,7 +2667,7 @@ export function MapWorkspace({
</div>
</div>
{analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? (
{!readOnly && analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? (
<div className={`geo-image-analysis geo-image-analysis-${orthophotoAnalysisStage}`}>
<div>
<span>Beeldanalyse</span>
@@ -2914,21 +2933,30 @@ export function MapWorkspace({
) : null}
{activeSelectionResult || temporalComparison ? (
<div className="geo-result-next-actions" aria-label="Volgende stap">
<span>
<strong>Analyse klaar</strong>
<small>Stel een vraag over dit gebied of open je bewaarde resultaten.</small>
</span>
<button className="primary-action" type="button" onClick={onOpenAssistant}>Stel AI-vraag</button>
<button
className="secondary-action"
type="button"
disabled={selectionExporting}
onClick={() => void persistActiveResultAndOpenDownloads()}
>
{selectionExporting ? 'Resultaat bewaren…' : 'Bewaar in downloads'}
</button>
</div>
readOnly ? (
<div className="geo-result-next-actions geo-result-next-actions-readonly" aria-label="Gastresultaat">
<span>
<strong>Analyse klaar</strong>
<small>Dit resultaat blijft tijdelijk in de browser. Meld u aan als operator om analyses te bewaren of verder te verwerken.</small>
</span>
</div>
) : (
<div className="geo-result-next-actions" aria-label="Volgende stap">
<span>
<strong>Analyse klaar</strong>
<small>Stel een vraag over dit gebied of open je bewaarde resultaten.</small>
</span>
<button className="primary-action" type="button" onClick={onOpenAssistant}>Stel AI-vraag</button>
<button
className="secondary-action"
type="button"
disabled={selectionExporting}
onClick={() => void persistActiveResultAndOpenDownloads()}
>
{selectionExporting ? 'Resultaat bewaren…' : 'Bewaar in downloads'}
</button>
</div>
)
) : null}
</>
)}
@@ -1,4 +1,5 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, render } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { areasApi } from '../../services/api/areas'
import { MunicipalitySearch } from './MunicipalitySearch'
@@ -1,4 +1,5 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, render } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ProjectAtlasIllustration } from './ProjectAtlasIllustration'
+14 -5
View File
@@ -4,6 +4,7 @@ import type { DatasetCreateResponse, QualityCheckRead } from '../types'
import { formatError } from '../lib/formatError'
interface DemoWorkflowOptions {
restrictedMode?: boolean
loadProjects: (preferredProjectId?: string | null) => Promise<void>
loadProjectData: (projectId: string) => Promise<{ datasets: DatasetCreateResponse[] } | null>
loadDatasetDetails: (projectId: string, dataset: DatasetCreateResponse) => Promise<void>
@@ -25,6 +26,7 @@ interface DemoWorkflowOptions {
}
export function useDemoWorkflow({
restrictedMode = false,
loadProjects,
loadProjectData,
loadDatasetDetails,
@@ -47,7 +49,7 @@ export function useDemoWorkflow({
const [loadingDemoWorkflow, setLoadingDemoWorkflow] = useState(false)
const [demoWorkflowMessage, setDemoWorkflowMessage] = useState<string | null>(null)
const loadDemoWorkflow = async () => {
const loadDemoWorkflow = async (): Promise<boolean> => {
setLoadingDemoWorkflow(true)
setDemoWorkflowMessage(null)
setErrorMessage(null)
@@ -65,12 +67,17 @@ export function useDemoWorkflow({
setSegmentationReferenceDatasetId(result.reference_dataset_id)
setDemoWorkflowMessage(result.message)
await loadProjects(result.project_id)
const operatorOnlyLoads = restrictedMode
? Promise.resolve()
: Promise.all([
loadDetectionRuns(result.project_id),
loadSegmentationRuns(result.project_id),
loadExports(result.project_id),
]).then(() => undefined)
const [projectData] = await Promise.all([
loadProjectData(result.project_id),
loadDetectionRuns(result.project_id),
loadSegmentationRuns(result.project_id),
loadQualityChecks(result.project_id),
loadExports(result.project_id),
operatorOnlyLoads,
])
const candidateDataset = projectData?.datasets.find((dataset) => dataset.id === result.candidate_dataset_id)
const rasterDataset = projectData?.datasets.find((dataset) => dataset.id === result.raster_dataset_id)
@@ -79,8 +86,10 @@ export function useDemoWorkflow({
} else if (rasterDataset) {
await loadDatasetDetails(result.project_id, rasterDataset)
}
return true
} catch (error) {
setErrorMessage(formatError(error, 'Failed to load demo workflow'))
setErrorMessage(formatError(error, 'De demowerkruimte kon niet worden geladen.'))
return false
} finally {
setLoadingDemoWorkflow(false)
}
+10 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { formatError } from '../lib/formatError'
import { formatAuthError } from '../lib/authError'
import { getAuthSession, logout, type AuthSession } from '../services/api/auth'
const signedOutSession: AuthSession = {
@@ -7,6 +7,9 @@ const signedOutSession: AuthSession = {
authenticated: false,
username: null,
expires_at: null,
role: null,
guest_access_enabled: false,
guest_project_id: null,
}
export function useOperatorSession() {
@@ -26,7 +29,7 @@ export function useOperatorSession() {
.catch((error) => {
if (active) {
setSession(signedOutSession)
setSessionError(formatError(error, 'De aanmeldservice is tijdelijk niet bereikbaar.'))
setSessionError(formatAuthError(error, 'De aanmeldservice is tijdelijk niet bereikbaar. Probeer het over enkele ogenblikken opnieuw.'))
}
})
return () => {
@@ -36,7 +39,10 @@ export function useOperatorSession() {
useEffect(() => {
const expireSession = () => {
setSession(signedOutSession)
setSession((current) => ({
...signedOutSession,
guest_access_enabled: current?.guest_access_enabled ?? false,
}))
setSessionError('Uw sessie is verlopen. Meld u opnieuw aan.')
}
window.addEventListener('geointel:session-expired', expireSession)
@@ -49,7 +55,7 @@ export function useOperatorSession() {
setSession(await logout())
setSessionError(null)
} catch (error) {
setSessionError(formatError(error, 'Uitloggen is niet gelukt.'))
setSessionError(formatAuthError(error, 'Uitloggen is niet gelukt. Vernieuw de pagina en probeer opnieuw.'))
} finally {
setLoggingOut(false)
}
@@ -1,4 +1,5 @@
import { renderHook, waitFor } from '@testing-library/react'
import { renderHook } from '@testing-library/react'
import { waitFor } from '@testing-library/dom'
import { describe, expect, it, vi } from 'vitest'
import { useWorkbenchBootstrap } from './useWorkbenchBootstrap'
@@ -63,4 +64,20 @@ describe('useWorkbenchBootstrap', () => {
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1')
expect(state.loadExports).toHaveBeenCalledWith('project-1')
})
it('keeps the guest bootstrap inside the read-only demo surface', async () => {
const state = { ...options('project-1'), restrictedMode: true }
renderHook(() => useWorkbenchBootstrap(state))
await waitFor(() => expect(state.loadProjectData).toHaveBeenCalledWith('project-1'))
expect(state.loadCapabilities).toHaveBeenCalledOnce()
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1')
expect(state.loadDetectionModels).not.toHaveBeenCalled()
expect(state.loadSegmentationModels).not.toHaveBeenCalled()
expect(state.loadDetectionRuns).not.toHaveBeenCalled()
expect(state.loadSegmentationRuns).not.toHaveBeenCalled()
expect(state.loadExports).not.toHaveBeenCalled()
expect(state.loadDetectionResults).not.toHaveBeenCalled()
expect(state.loadSegmentationResults).not.toHaveBeenCalled()
})
})
+17 -9
View File
@@ -4,6 +4,7 @@ type AsyncAction = () => Promise<unknown>
type ProjectAction = (projectId: string) => Promise<unknown>
interface WorkbenchBootstrapOptions {
restrictedMode?: boolean
selectedProjectId: string | null
selectedDetectionRunId: string
detectionClassFilter: string
@@ -30,6 +31,7 @@ interface WorkbenchBootstrapOptions {
}
export function useWorkbenchBootstrap({
restrictedMode = false,
selectedProjectId,
selectedDetectionRunId,
detectionClassFilter,
@@ -57,9 +59,11 @@ export function useWorkbenchBootstrap({
useEffect(() => {
loadProjects().catch(() => null)
loadCapabilities().catch(() => null)
loadDetectionModels().catch(() => null)
loadSegmentationModels().catch(() => null)
}, [])
if (!restrictedMode) {
loadDetectionModels().catch(() => null)
loadSegmentationModels().catch(() => null)
}
}, [restrictedMode])
useEffect(() => {
if (!selectedProjectId) {
@@ -76,17 +80,21 @@ export function useWorkbenchBootstrap({
resetSegmentationForProject()
resetExportsForProject()
loadProjectData(selectedProjectId).catch(() => null)
loadDetectionRuns(selectedProjectId).catch(() => null)
loadSegmentationRuns(selectedProjectId).catch(() => null)
loadQualityChecks(selectedProjectId).catch(() => null)
loadExports(selectedProjectId).catch(() => null)
}, [selectedProjectId])
if (!restrictedMode) {
loadDetectionRuns(selectedProjectId).catch(() => null)
loadSegmentationRuns(selectedProjectId).catch(() => null)
loadExports(selectedProjectId).catch(() => null)
}
}, [restrictedMode, selectedProjectId])
useEffect(() => {
if (restrictedMode) return
loadDetectionResults().catch(() => null)
}, [selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter])
}, [restrictedMode, selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter])
useEffect(() => {
if (restrictedMode) return
loadSegmentationResults().catch(() => null)
}, [selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
}, [restrictedMode, selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
}
+25
View File
@@ -0,0 +1,25 @@
const PASSTHROUGH_CODES = new Set([
'INVALID_CREDENTIALS',
'LOGIN_RATE_LIMITED',
'GUEST_ACCESS_DISABLED',
'GUEST_READ_ONLY',
'GUEST_PROJECT_SCOPE_REQUIRED',
])
export function formatAuthError(error: unknown, fallback: string): string {
if (!(error instanceof Error)) return fallback
const code = (error as { code?: string }).code
if (code && PASSTHROUGH_CODES.has(code)) return error.message
if (
code === 'REQUEST_ERROR'
|| code === 'INTERNAL_ERROR'
|| code === 'SESSION_CREATION_FAILED'
|| /request failed|failed to fetch|networkerror/i.test(error.message)
) {
return fallback
}
return error.message || fallback
}
+7
View File
@@ -5,6 +5,9 @@ export interface AuthSession {
authenticated: boolean
username: string | null
expires_at: string | null
role: 'operator' | 'guest' | null
guest_access_enabled: boolean
guest_project_id: string | null
}
export function getAuthSession(): Promise<AuthSession> {
@@ -15,6 +18,10 @@ export function login(username: string, password: string): Promise<AuthSession>
return apiPost<AuthSession>('/api/v1/auth/login', { username, password })
}
export function loginAsGuest(): Promise<AuthSession> {
return apiPost<AuthSession>('/api/v1/auth/guest')
}
export function logout(): Promise<AuthSession> {
return apiPost<AuthSession>('/api/v1/auth/logout')
}
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
/*
* GeoIntel professionalisation pass — 2026-07-27
*
* This intentionally remains a small, final override layer. The historical
* style sheets are consolidated in a separate refactor so this release does
* not destabilise the map workbench or its responsive contracts.
*/
.workbench-stage {
position: relative;
}
@media (min-width: 1361px) {
.workbench-layout {
grid-template-columns: 13.75rem minmax(0, 1fr);
}
.workbench-sidebar {
padding-inline: 0.72rem;
}
.workbench-main:not(.workbench-main-map) {
padding-inline: clamp(1.5rem, 2vw, 2.4rem);
}
}
.context-bar > div {
min-width: 0;
}
.context-bar strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-account-guest {
border-color: #9fcfc4;
color: #0c5e55;
background: linear-gradient(180deg, #f7fffc, #e8f7f3);
}
.context-account-guest > svg {
color: #0d7d6f;
}
.workbench-shell-guest .workbench-stage {
grid-template-rows: var(--atlas-topbar-height) auto minmax(0, 1fr);
}
.guest-mode-banner {
position: relative;
z-index: 22;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.72rem;
align-items: center;
min-width: 0;
border-bottom: 1px solid #b7dbd3;
padding: 0.68rem 1.35rem;
color: #164d47;
background:
radial-gradient(circle at 12% 0%, rgba(57, 172, 151, 0.13), transparent 16rem),
linear-gradient(90deg, #edf9f6, #f7fcfa);
}
.guest-mode-banner > svg {
width: 1.2rem;
height: 1.2rem;
color: #0f766e;
}
.guest-mode-banner > div {
display: grid;
min-width: 0;
gap: 0.12rem;
}
.guest-mode-banner strong {
font-family: "Manrope", sans-serif;
font-size: 0.8rem;
letter-spacing: -0.01em;
}
.guest-mode-banner span:not(.guest-mode-badge) {
overflow: hidden;
color: #52736d;
font-size: 0.72rem;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.guest-mode-badge {
border: 1px solid #9bcfc3;
border-radius: 999px;
padding: 0.33rem 0.62rem;
color: #0d665b;
background: rgba(255, 255, 255, 0.82);
font-size: 0.66rem;
font-weight: 800;
letter-spacing: 0.045em;
text-transform: uppercase;
}
.guest-demo-loading {
position: absolute;
inset: calc(var(--atlas-topbar-height) + 3.75rem) 1.25rem auto auto;
z-index: 85;
display: inline-flex;
max-width: min(28rem, calc(100vw - 2rem));
gap: 0.65rem;
align-items: center;
border: 1px solid #b7d9d1;
border-radius: 0.85rem;
padding: 0.72rem 0.9rem;
color: #164d47;
background: rgba(250, 255, 253, 0.97);
box-shadow: 0 18px 44px rgba(9, 65, 57, 0.16);
backdrop-filter: blur(16px);
}
.guest-demo-loading > span {
width: 0.85rem;
height: 0.85rem;
flex: 0 0 auto;
border: 2px solid #b5ddd4;
border-top-color: #0f766e;
border-radius: 999px;
animation: geointel-guest-spin 0.8s linear infinite;
}
.guest-demo-loading strong {
font-size: 0.75rem;
}
@keyframes geointel-guest-spin {
to { transform: rotate(360deg); }
}
.geo-guest-preview-note,
.guest-readonly-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.78rem;
align-items: start;
border: 1px solid #b9ddd5;
color: #174f48;
background: linear-gradient(135deg, #eef9f6, #fbfefd);
}
.geo-guest-preview-note {
border-width: 0 0 1px;
padding: 0.78rem 1.25rem;
}
.geo-guest-preview-note > svg,
.guest-readonly-card > svg {
width: 1.12rem;
height: 1.12rem;
margin-top: 0.08rem;
color: #0f766e;
}
.geo-guest-preview-note > div,
.guest-readonly-card > div {
display: grid;
gap: 0.16rem;
}
.geo-guest-preview-note strong,
.guest-readonly-card strong {
font-family: "Manrope", sans-serif;
font-size: 0.79rem;
}
.geo-guest-preview-note span,
.guest-readonly-card span {
color: #54756f;
font-size: 0.72rem;
line-height: 1.45;
}
.guest-readonly-card {
border-radius: 0.9rem;
padding: 0.9rem 1rem;
box-shadow: 0 8px 22px rgba(20, 81, 71, 0.06);
}
.geo-result-next-actions-readonly {
grid-template-columns: minmax(0, 1fr);
border-color: #b9dcd4;
background: #f1faf7;
}
.geo-result-next-actions-readonly small {
max-width: 48rem;
}
.workbench-shell-guest .geo-explorer-header {
min-height: 5.25rem;
}
.workbench-shell-guest .geo-explorer-layout {
grid-template-columns: minmax(13.5rem, 0.7fr) minmax(28rem, 1.75fr) minmax(18rem, 0.8fr);
}
@media (max-width: 1120px) {
.guest-mode-banner {
padding-inline: 0.9rem;
}
.guest-mode-banner span:not(.guest-mode-badge) {
white-space: normal;
}
.workbench-shell-guest .geo-explorer-layout {
grid-template-columns: 13rem minmax(24rem, 1fr);
}
}
@media (max-width: 920px) {
.workbench-shell-guest .workbench-stage {
grid-template-rows: auto auto minmax(0, 1fr);
}
.guest-mode-banner {
grid-template-columns: auto minmax(0, 1fr);
padding: 0.65rem 0.8rem;
}
.guest-mode-badge {
display: none;
}
.guest-demo-loading {
position: fixed;
inset: 0.75rem 0.75rem auto;
}
}
@media (max-width: 620px) {
.guest-mode-banner > svg {
display: none;
}
.guest-mode-banner {
grid-template-columns: minmax(0, 1fr);
}
.guest-mode-banner strong {
font-size: 0.75rem;
}
.guest-mode-banner span:not(.guest-mode-badge) {
font-size: 0.68rem;
}
.geo-guest-preview-note {
padding: 0.72rem 0.85rem;
}
}
@media (prefers-reduced-motion: reduce) {
.guest-demo-loading > span {
animation: none;
}
}