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
+12
View File
@@ -5,6 +5,18 @@ DATABASE_URL=postgresql+psycopg://geointel:geointel@localhost:5432/geointel?conn
STORAGE_ROOT=./storage
MAX_UPLOAD_MB=500
CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202
# Optional single-operator access gate. Store only a PBKDF2-SHA256 hash and
# a unique 32+ character signing secret. Guest access is enabled by default
# whenever this gate is active; disable it explicitly on non-demo instances.
GEOINTEL_AUTH_ENABLED=false
GEOINTEL_AUTH_USERNAME=
GEOINTEL_AUTH_PASSWORD_HASH=
GEOINTEL_AUTH_SESSION_SECRET=
GEOINTEL_AUTH_SESSION_TTL_SECONDS=43200
GEOINTEL_GUEST_ACCESS_ENABLED=true
GEOINTEL_GUEST_DISPLAY_NAME=Gast
GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200
ORTHOPHOTO_ENABLED=true
ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms
SPW_ORTHOPHOTO_WMS_URL=https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer
+2
View File
@@ -9,6 +9,8 @@
## Unreleased - Post-V1 capability completion (2026-07-19)
- Enabled the restricted guest demo by default whenever the operator login gate is active. Compose, Unraid, environment examples and backend defaults now agree; operators can still disable it explicitly with `GEOINTEL_GUEST_ACCESS_ENABLED=false`.
- Implemented the supplied Stitch landing-page direction as the real React
entry surface, with responsive navigation, accurate Belgian/North Sea
product copy, a project-owned optimized hero asset, loading/error states and
+22
View File
@@ -105,6 +105,28 @@ GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage
GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data
```
### Guest demonstration access
Guest access is enabled by default whenever the operator login gate is active.
No additional guest toggle is required for a new authenticated installation:
```env
GEOINTEL_AUTH_ENABLED=true
GEOINTEL_AUTH_USERNAME=operator
GEOINTEL_AUTH_PASSWORD_HASH=pbkdf2_sha256$...
GEOINTEL_AUTH_SESSION_SECRET=<independent-random-secret-of-at-least-32-characters>
GEOINTEL_GUEST_ACCESS_ENABLED=true
GEOINTEL_GUEST_DISPLAY_NAME=Gast
GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200
```
The login page offers **Als gast verkennen**. A guest receives a short-lived,
read-only session scoped to the seeded demo project and sees only the map and
existing quality evidence. Set `GEOINTEL_GUEST_ACCESS_ENABLED=false` to hide and
disable this route. This is not multi-user authorization or tenant isolation;
use a separate demo instance when the installation contains private or
operational datasets.
The backend and PostGIS ports are intentionally not exposed to the LAN in the all-in-one runtime. See `deploy/unraid/README.md` for full setup, port-change and cleanup notes.
On Tower/Unraid, `scripts/deploy_tower.ps1` and `scripts/deploy_tower.sh` validate the Compose reference but build with plain `docker build`, then automatically install the editable DockerMan template as `/boot/config/plugins/dockerMan/templates-user/my-geointel.xml`, install the PNG icon as `/boot/config/plugins/dockerMan/images/geointel-icon.png`, remove any old Compose-owned `geointel` container and start the final container with DockerMan labels.
+106 -23
View File
@@ -2,30 +2,71 @@ from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Request, Response, status
from fastapi import APIRouter, Depends, Request, Response, status
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.errors import AppError
from app.db.session import get_db
from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope
from app.services.auth_service import AuthService
from app.services.auth_service import AuthPrincipal, AuthService
from app.services.demo_workflow_service import DemoWorkflowService
router = APIRouter(prefix="/auth", tags=["auth"])
COOKIE_NAME = "geointel_session"
def _session_payload(request: Request) -> AuthSession:
settings = get_settings()
if not settings.auth_enabled:
return AuthSession(authentication_required=False, authenticated=True)
principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings)
if principal is None:
return AuthSession(authentication_required=True, authenticated=False)
def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: bool) -> AuthSession:
return AuthSession(
authentication_required=True,
authenticated=True,
username=principal.username,
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
role=principal.role,
guest_access_enabled=guest_access_enabled,
guest_project_id=principal.project_id,
)
def _session_payload(request: Request) -> AuthSession:
settings = get_settings()
guest_access_enabled = settings.auth_enabled and settings.guest_access_enabled
if not settings.auth_enabled:
return AuthSession(
authentication_required=False,
authenticated=True,
guest_access_enabled=False,
)
principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings)
if principal is None:
return AuthSession(
authentication_required=True,
authenticated=False,
guest_access_enabled=guest_access_enabled,
)
return _session_from_principal(
principal,
guest_access_enabled=guest_access_enabled,
)
def _set_session_cookie(
*,
request: Request,
response: Response,
token: str,
max_age: int,
) -> None:
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=max_age,
httponly=True,
secure=forwarded_proto == "https" or request.url.scheme == "https",
samesite="strict",
path="/",
)
@@ -69,29 +110,71 @@ def login(payload: AuthLoginRequest, request: Request, response: Response) -> Au
message="De beveiligde sessie kon niet worden aangemaakt.",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
response.set_cookie(
key=COOKIE_NAME,
value=token,
_set_session_cookie(
request=request,
response=response,
token=token,
max_age=settings.auth_session_ttl_seconds,
httponly=True,
secure=forwarded_proto == "https" or request.url.scheme == "https",
samesite="strict",
path="/",
)
return AuthSessionEnvelope(
data=AuthSession(
authentication_required=True,
authenticated=True,
username=principal.username,
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
data=_session_from_principal(
principal,
guest_access_enabled=settings.guest_access_enabled,
)
)
@router.post("/guest", response_model=AuthSessionEnvelope)
def guest_login(
request: Request,
response: Response,
db: Session = Depends(get_db),
) -> AuthSessionEnvelope:
settings = get_settings()
if not settings.auth_enabled or not settings.guest_access_enabled:
raise AppError(
code="GUEST_ACCESS_DISABLED",
message="Gasttoegang is niet ingeschakeld op deze GeoIntel-installatie.",
status_code=status.HTTP_403_FORBIDDEN,
)
demo = DemoWorkflowService.seed(db)
token = AuthService.create_session_token(
settings.guest_display_name,
settings,
role="guest",
project_id=demo.project_id,
ttl_seconds=settings.guest_session_ttl_seconds,
)
principal = AuthService.verify_session_token(token, settings)
if principal is None: # pragma: no cover - defensive invariant
raise AppError(
code="SESSION_CREATION_FAILED",
message="De tijdelijke gastensessie kon niet worden aangemaakt.",
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
_set_session_cookie(
request=request,
response=response,
token=token,
max_age=settings.guest_session_ttl_seconds,
)
return AuthSessionEnvelope(
data=_session_from_principal(
principal,
guest_access_enabled=True,
)
)
@router.post("/logout", response_model=AuthSessionEnvelope)
def logout(response: Response) -> AuthSessionEnvelope:
settings = get_settings()
response.delete_cookie(key=COOKIE_NAME, path="/", httponly=True, samesite="strict")
return AuthSessionEnvelope(
data=AuthSession(authentication_required=True, authenticated=False)
data=AuthSession(
authentication_required=settings.auth_enabled,
authenticated=not settings.auth_enabled,
guest_access_enabled=settings.auth_enabled and settings.guest_access_enabled,
)
)
+20 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.core.errors import AppError
@@ -43,6 +43,19 @@ def _assert_project_exists(db: Session, project_id):
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
def _assert_guest_project_scope(request: Request, project_id) -> None:
principal = getattr(request.state, "auth_principal", None)
if (
getattr(principal, "role", None) == "guest"
and getattr(principal, "project_id", None) != project_id
):
raise AppError(
code="GUEST_PROJECT_SCOPE_REQUIRED",
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
status_code=403,
)
def _normalize_layer_input(layers: list[str] | None) -> list[str]:
return [layer.strip() for layer in (layers or []) if isinstance(layer, str) and layer.strip()]
@@ -66,7 +79,12 @@ def get_coverage_catalog() -> dict:
@router.post("/coverage/resolve", response_model=Envelope[CoverageResolveResponse])
def resolve_project_coverage(payload: CoverageResolveRequest, db: Session = Depends(get_db)) -> dict:
def resolve_project_coverage(
payload: CoverageResolveRequest,
request: Request,
db: Session = Depends(get_db),
) -> dict:
_assert_guest_project_scope(request, payload.project_id)
result = CoverageRegistryService.resolve(
db,
project_id=payload.project_id,
+23 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.orm import Session
from app.db.session import get_db
@@ -17,12 +17,34 @@ router = APIRouter(prefix="/projects", tags=["projects"])
@router.get("", response_model=Envelope[ProjectList])
def list_projects(
request: Request,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
name: str | None = Query(default=None, min_length=1, max_length=255),
project_status: Literal["active", "archived", "all"] = Query(default="active", alias="status"),
db: Session = Depends(get_db),
):
principal = getattr(request.state, "auth_principal", None)
if principal is not None and principal.role == "guest":
project = ProjectService.get_project(db, principal.project_id)
status_matches = bool(
project is not None
and (project_status == "all" or project.status == project_status)
)
name_matches = bool(
project is not None
and (name is None or name.casefold() in project.name.casefold())
)
matches = project is not None and status_matches and name_matches
visible = [project] if matches and offset == 0 else []
return envelope(
{
"items": [ProjectRead.model_validate(item).model_dump() for item in visible[:limit]],
"total": 1 if matches else 0,
"limit": limit,
"offset": offset,
}
)
projects, total = ProjectService.list_projects(
db,
limit=limit,
+19
View File
@@ -28,6 +28,22 @@ class Settings(BaseSettings):
le=604_800,
validation_alias="GEOINTEL_AUTH_SESSION_TTL_SECONDS",
)
guest_access_enabled: bool = Field(
default=True,
validation_alias="GEOINTEL_GUEST_ACCESS_ENABLED",
)
guest_display_name: str = Field(
default="Gast",
min_length=1,
max_length=64,
validation_alias="GEOINTEL_GUEST_DISPLAY_NAME",
)
guest_session_ttl_seconds: int = Field(
default=7_200,
ge=900,
le=86_400,
validation_alias="GEOINTEL_GUEST_SESSION_TTL_SECONDS",
)
database_url: str = Field(
default="postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1",
validation_alias="DATABASE_URL",
@@ -406,6 +422,9 @@ class Settings(BaseSettings):
@model_validator(mode="after")
def validate_operator_auth(self) -> "Settings":
self.guest_display_name = self.guest_display_name.strip()
if not self.guest_display_name:
raise ValueError("GEOINTEL_GUEST_DISPLAY_NAME must not be blank")
if not self.auth_enabled:
return self
if not (self.auth_username or "").strip():
+86
View File
@@ -147,6 +147,7 @@ def create_app() -> FastAPI:
public_auth_paths = {
f"{settings.api_prefix}/auth/session",
f"{settings.api_prefix}/auth/login",
f"{settings.api_prefix}/auth/guest",
f"{settings.api_prefix}/auth/logout",
}
direct_loopback_request = (
@@ -177,6 +178,91 @@ def create_app() -> FastAPI:
response.headers["x-request-id"] = request_id
return response
request.state.auth_principal = principal
if principal.role == "guest":
project_path_prefix = f"{settings.api_prefix}/projects/"
guest_project_root = f"{project_path_prefix}{principal.project_id}"
if raw_path.startswith(project_path_prefix):
scoped_path = raw_path[len(project_path_prefix):]
requested_project_id = scoped_path.split("/", 1)[0]
if str(principal.project_id) != requested_project_id:
response = JSONResponse(
status_code=403,
content=_to_error_payload(
"GUEST_PROJECT_SCOPE_REQUIRED",
"Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
request_id=request_id,
),
)
response.headers["x-request-id"] = request_id
return response
query_project_id = request.query_params.get("project_id")
if query_project_id and query_project_id != str(principal.project_id):
response = JSONResponse(
status_code=403,
content=_to_error_payload(
"GUEST_PROJECT_SCOPE_REQUIRED",
"Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
request_id=request_id,
),
)
response.headers["x-request-id"] = request_id
return response
guest_safe_read_paths = {
f"{settings.api_prefix}/projects",
f"{settings.api_prefix}/external/providers",
}
normalized_path = raw_path.rstrip("/") or "/"
guest_project_read = (
normalized_path == guest_project_root
or normalized_path.startswith(f"{guest_project_root}/")
)
is_read_request = request.method in {"GET", "HEAD", "OPTIONS"}
if is_read_request:
if normalized_path not in guest_safe_read_paths and not guest_project_read:
response = JSONResponse(
status_code=403,
content=_to_error_payload(
"GUEST_ROUTE_NOT_AVAILABLE",
"Deze API-route maakt geen deel uit van de afgeschermde GeoIntel-demo.",
request_id=request_id,
),
)
response.headers["x-request-id"] = request_id
return response
else:
guest_safe_post_paths = {
f"{settings.api_prefix}/demo/workflow",
f"{settings.api_prefix}/external/coverage/resolve",
}
guest_safe_post_suffixes = (
"/vector/select",
"/raster/bathymetry/select",
"/raster/terrain/select",
"/raster/flood-hazard/select",
"/raster/thematic/select",
"/raster/walous/select",
"/temporal/compare",
"/datasets/vector/partitions/select",
"/datasets/bathymetry/profiles/partitions/select",
)
is_guest_safe_post = request.method == "POST" and (
raw_path in guest_safe_post_paths
or (
raw_path.startswith(project_path_prefix)
and raw_path.endswith(guest_safe_post_suffixes)
)
)
if not is_guest_safe_post:
response = JSONResponse(
status_code=403,
content=_to_error_payload(
"GUEST_READ_ONLY",
"Gasttoegang is een tijdelijke, alleen-lezen demo. Meld u aan als operator om gegevens te wijzigen of taken te starten.",
request_id=request_id,
),
)
response.headers["x-request-id"] = request_id
return response
response = await call_next(request)
response.headers["x-request-id"] = request_id
logger.info(
+5
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field
@@ -17,6 +19,9 @@ class AuthSession(BaseModel):
authenticated: bool
username: str | None = None
expires_at: datetime | None = None
role: Literal["operator", "guest"] | None = None
guest_access_enabled: bool = False
guest_project_id: UUID | None = None
class AuthSessionEnvelope(Envelope[AuthSession]):
+48 -6
View File
@@ -9,6 +9,8 @@ import threading
import time
from collections import deque
from dataclasses import dataclass
from typing import Literal, cast
from uuid import UUID
from app.core.config import Settings
@@ -17,6 +19,8 @@ from app.core.config import Settings
class AuthPrincipal:
username: str
expires_at: int
role: Literal["operator", "guest"] = "operator"
project_id: UUID | None = None
class AuthService:
@@ -93,15 +97,32 @@ class AuthService:
return username_matches and password_matches
@classmethod
def create_session_token(cls, username: str, settings: Settings, *, now: int | None = None) -> str:
def create_session_token(
cls,
username: str,
settings: Settings,
*,
role: Literal["operator", "guest"] = "operator",
project_id: UUID | None = None,
ttl_seconds: int | None = None,
now: int | None = None,
) -> str:
issued_at = int(time.time() if now is None else now)
if role == "guest" and project_id is None:
raise ValueError("Guest sessions must be scoped to a demo project")
resolved_ttl = ttl_seconds if ttl_seconds is not None else (
settings.guest_session_ttl_seconds if role == "guest" else settings.auth_session_ttl_seconds
)
payload = {
"exp": issued_at + settings.auth_session_ttl_seconds,
"exp": issued_at + resolved_ttl,
"iat": issued_at,
"jti": secrets.token_urlsafe(12),
"role": role,
"sub": username,
"v": 1,
"v": 2,
}
if project_id is not None:
payload["project_id"] = str(project_id)
encoded_payload = cls._b64_encode(
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
)
@@ -136,14 +157,35 @@ class AuthService:
username = str(payload.get("sub") or "")
expires_at = int(payload.get("exp") or 0)
issued_at = int(payload.get("iat") or 0)
version = int(payload.get("v") or 0)
role_value = str(payload.get("role") or "operator")
current = int(time.time() if now is None else now)
if payload.get("v") != 1 or username != settings.auth_username:
if version not in {1, 2} or role_value not in {"operator", "guest"}:
return None
role = cast(Literal["operator", "guest"], role_value)
if issued_at <= 0 or issued_at > current + 60 or expires_at <= current:
return None
if expires_at - issued_at > settings.auth_session_ttl_seconds:
if role == "operator":
if username != settings.auth_username:
return None
max_ttl = settings.auth_session_ttl_seconds
project_id = None
else:
if not settings.guest_access_enabled or username != settings.guest_display_name:
return None
max_ttl = settings.guest_session_ttl_seconds
raw_project_id = payload.get("project_id")
if not raw_project_id:
return None
project_id = UUID(str(raw_project_id))
if expires_at - issued_at > max_ttl:
return None
return AuthPrincipal(username=username, expires_at=expires_at)
return AuthPrincipal(
username=username,
expires_at=expires_at,
role=role,
project_id=project_id,
)
except (TypeError, ValueError, json.JSONDecodeError, UnicodeDecodeError):
return None
+126 -3
View File
@@ -1,14 +1,19 @@
from __future__ import annotations
from pathlib import Path
from uuid import UUID
from fastapi.testclient import TestClient
from app.core.config import get_settings
from app.db.session import get_db
from app.main import create_app
from app.schemas.demo import DemoWorkflowResponse
from app.services.auth_service import AuthService
from app.services.demo_workflow_service import DemoWorkflowService
def auth_client(monkeypatch) -> TestClient:
def auth_client(monkeypatch, *, guest_access: bool = False) -> TestClient:
password_hash = AuthService.hash_password(
"correct horse battery staple",
salt=b"geointel-test-salt",
@@ -18,9 +23,48 @@ def auth_client(monkeypatch) -> TestClient:
monkeypatch.setenv("GEOINTEL_AUTH_USERNAME", "operator")
monkeypatch.setenv("GEOINTEL_AUTH_PASSWORD_HASH", password_hash)
monkeypatch.setenv("GEOINTEL_AUTH_SESSION_SECRET", "test-session-secret-that-is-long-enough")
monkeypatch.setenv("GEOINTEL_GUEST_ACCESS_ENABLED", "true" if guest_access else "false")
monkeypatch.setenv("GEOINTEL_GUEST_DISPLAY_NAME", "Gast")
monkeypatch.setenv("GEOINTEL_GUEST_SESSION_TTL_SECONDS", "7200")
return TestClient(create_app())
def test_guest_access_defaults_on_when_operator_authentication_is_enabled(monkeypatch) -> None:
password_hash = AuthService.hash_password(
"correct horse battery staple",
salt=b"geointel-test-salt",
iterations=100_000,
)
monkeypatch.setenv("GEOINTEL_AUTH_ENABLED", "true")
monkeypatch.setenv("GEOINTEL_AUTH_USERNAME", "operator")
monkeypatch.setenv("GEOINTEL_AUTH_PASSWORD_HASH", password_hash)
monkeypatch.setenv("GEOINTEL_AUTH_SESSION_SECRET", "test-session-secret-that-is-long-enough")
monkeypatch.delenv("GEOINTEL_GUEST_ACCESS_ENABLED", raising=False)
client = TestClient(create_app())
session = client.get("/api/v1/auth/session")
assert session.status_code == 200
assert session.json()["data"]["authentication_required"] is True
assert session.json()["data"]["guest_access_enabled"] is True
def test_guest_default_is_inactive_but_valid_when_operator_authentication_is_disabled(monkeypatch) -> None:
monkeypatch.setenv("GEOINTEL_AUTH_ENABLED", "false")
monkeypatch.delenv("GEOINTEL_AUTH_USERNAME", raising=False)
monkeypatch.delenv("GEOINTEL_AUTH_PASSWORD_HASH", raising=False)
monkeypatch.delenv("GEOINTEL_AUTH_SESSION_SECRET", raising=False)
monkeypatch.delenv("GEOINTEL_GUEST_ACCESS_ENABLED", raising=False)
client = TestClient(create_app())
session = client.get("/api/v1/auth/session")
assert session.status_code == 200
assert session.json()["data"]["authentication_required"] is False
assert session.json()["data"]["authenticated"] is True
assert session.json()["data"]["guest_access_enabled"] is False
def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) -> None:
client = auth_client(monkeypatch)
@@ -34,6 +78,9 @@ def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) ->
"authenticated": False,
"username": None,
"expires_at": None,
"role": None,
"guest_access_enabled": False,
"guest_project_id": None,
}
assert protected.status_code == 401
assert protected.json()["error"] == "AUTHENTICATION_REQUIRED"
@@ -41,7 +88,7 @@ def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) ->
def test_login_uses_http_only_session_cookie_and_logout_revokes_browser_access(monkeypatch) -> None:
client = auth_client(monkeypatch)
client = auth_client(monkeypatch, guest_access=True)
invalid = client.post(
"/api/v1/auth/login",
@@ -59,16 +106,90 @@ def test_login_uses_http_only_session_cookie_and_logout_revokes_browser_access(m
assert invalid.status_code == 401
assert invalid.json()["error"] == "INVALID_CREDENTIALS"
assert login.status_code == 200
assert login.json()["data"]["username"] == "operator"
assert login.json()["data"] == {
"authentication_required": True,
"authenticated": True,
"username": "operator",
"expires_at": login.json()["data"]["expires_at"],
"role": "operator",
"guest_access_enabled": True,
"guest_project_id": None,
}
cookie = login.headers["set-cookie"].lower()
assert "httponly" in cookie
assert "samesite=strict" in cookie
assert authenticated.json()["data"]["authenticated"] is True
assert authenticated.json()["data"]["role"] == "operator"
assert protected_after_login.status_code == 404
assert logout.status_code == 200
assert logout.json()["data"]["guest_access_enabled"] is True
assert protected_after_logout.status_code == 401
def test_guest_login_seeds_scoped_demo_and_rejects_mutating_or_cross_project_requests(monkeypatch) -> None:
project_id = UUID("00000000-0000-0000-0000-000000000123")
demo = DemoWorkflowResponse(
project_id=project_id,
area_id=UUID("00000000-0000-0000-0000-000000000124"),
reference_dataset_id=UUID("00000000-0000-0000-0000-000000000125"),
candidate_dataset_id=UUID("00000000-0000-0000-0000-000000000126"),
raster_dataset_id=UUID("00000000-0000-0000-0000-000000000127"),
quality_check_id=UUID("00000000-0000-0000-0000-000000000128"),
metric_count=6,
status="ok",
message="Demo ready",
created=False,
)
monkeypatch.setattr(DemoWorkflowService, "seed", staticmethod(lambda _db: demo))
client = auth_client(monkeypatch, guest_access=True)
def fake_db():
yield object()
client.app.dependency_overrides[get_db] = fake_db
guest_login = client.post("/api/v1/auth/guest")
guest_session = client.get("/api/v1/auth/session")
mutation = client.post("/api/v1/projects", json={"name": "Not allowed"})
other_project = client.get("/api/v1/projects/00000000-0000-0000-0000-000000000999")
unscoped_read = client.get("/api/v1/detection/models")
cross_project_coverage = client.post(
"/api/v1/external/coverage/resolve",
json={
"project_id": "00000000-0000-0000-0000-000000000999",
"bbox": {"minx": 4.9, "miny": 51.0, "maxx": 5.0, "maxy": 51.1},
"themes": [],
},
)
assert guest_login.status_code == 200
assert guest_login.json()["data"]["role"] == "guest"
assert guest_login.json()["data"]["username"] == "Gast"
assert guest_login.json()["data"]["guest_project_id"] == str(project_id)
assert "httponly" in guest_login.headers["set-cookie"].lower()
assert guest_session.json()["data"]["role"] == "guest"
assert mutation.status_code == 403
assert mutation.json()["error"] == "GUEST_READ_ONLY"
assert other_project.status_code == 403
assert other_project.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
assert unscoped_read.status_code == 403
assert unscoped_read.json()["error"] == "GUEST_ROUTE_NOT_AVAILABLE"
assert cross_project_coverage.status_code == 403
assert cross_project_coverage.json()["error"] == "GUEST_PROJECT_SCOPE_REQUIRED"
def test_guest_session_tokens_fail_closed_without_a_project_scope(monkeypatch) -> None:
client = auth_client(monkeypatch, guest_access=True)
settings = get_settings()
try:
AuthService.create_session_token("Gast", settings, role="guest")
except ValueError as error:
assert "demo project" in str(error)
else: # pragma: no cover - defensive assertion
raise AssertionError("An unscoped guest token should not be created")
def test_password_hash_and_session_signatures_fail_closed(monkeypatch) -> None:
client = auth_client(monkeypatch)
login = client.post(
@@ -94,4 +215,6 @@ def test_unraid_runtime_carries_only_hashed_operator_credentials() -> None:
assert '-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH"' in runner
assert "GEOINTEL_AUTH_PASSWORD_HASH=" in example
assert "GEOINTEL_AUTH_PASSWORD=" not in runner
assert "GEOINTEL_GUEST_ACCESS_ENABLED=true" in example
assert 'GEOINTEL_GUEST_ACCESS_ENABLED="${GEOINTEL_GUEST_ACCESS_ENABLED:-true}"' in runner
assert "/api/v1/auth/session" in browser_smoke
+18
View File
@@ -86,6 +86,24 @@ the backend remain available to trusted in-container operator scripts.
Production startup fails before replacing the active container when the
password is empty or one of the documented defaults.
Guest access is enabled by default when the operator login gate is active:
```env
GEOINTEL_GUEST_ACCESS_ENABLED=true
GEOINTEL_GUEST_DISPLAY_NAME=Gast
GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200
```
No extra guest setting is required for a new authenticated deployment. Set
`GEOINTEL_GUEST_ACCESS_ENABLED=false` to disable the button and guest endpoint.
This adds **Als gast verkennen** to the landing page. The generated guest cookie
is short-lived, project-scoped and limited to the canonical demo workflow.
Operator mutations and access to another project are rejected by the backend,
and the frontend hides management and task-starting controls. The mechanism is
not tenant isolation: never enable it on an instance that contains private,
customer or operational data. Deploy a separate demo container and storage
root for public or recruiter-facing access.
The repository deploy scripts run the same flow automatically. They validate
the Compose reference, preserve the current image as
`geointel-all-in-one:previous`, build an immutable `<commit-sha>-ai` or
@@ -37,6 +37,9 @@
<Config Name="Operator Password Hash" Target="GEOINTEL_AUTH_PASSWORD_HASH" Default="" Mode="" Description="PBKDF2-SHA256 password hash. Never enter a plaintext password." Type="Variable" Display="advanced" Required="false" Mask="true"></Config>
<Config Name="Operator Session Secret" Target="GEOINTEL_AUTH_SESSION_SECRET" Default="" Mode="" Description="Random secret of at least 32 characters used only to sign browser sessions." Type="Variable" Display="advanced" Required="false" Mask="true"></Config>
<Config Name="Operator Session TTL" Target="GEOINTEL_AUTH_SESSION_TTL_SECONDS" Default="43200" Mode="" Description="Session lifetime in seconds (900-604800)." Type="Variable" Display="advanced" Required="true" Mask="false">43200</Config>
<Config Name="Guest Demo Enabled" Target="GEOINTEL_GUEST_ACCESS_ENABLED" Default="true" Mode="" Description="Show a guest button that opens only the seeded, restricted demo workspace. Enabled by default when operator login is active; set false on non-demo instances." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
<Config Name="Guest Display Name" Target="GEOINTEL_GUEST_DISPLAY_NAME" Default="Gast" Mode="" Description="Label shown for the temporary guest session." Type="Variable" Display="advanced" Required="true" Mask="false">Gast</Config>
<Config Name="Guest Session TTL" Target="GEOINTEL_GUEST_SESSION_TTL_SECONDS" Default="7200" Mode="" Description="Temporary guest session lifetime in seconds (900-86400)." Type="Variable" Display="advanced" Required="true" Mask="false">7200</Config>
<Config Name="Official Orthophoto Acquisition" Target="ORTHOPHOTO_ENABLED" Default="true" Mode="" Description="Allow explicit bounded map selections to request the official Digitaal Vlaanderen orthophoto WMS." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
<Config Name="Orthophoto WMS URL" Target="ORTHOPHOTO_WMS_URL" Default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms" Mode="" Description="Official Digitaal Vlaanderen most-recent winter orthophoto WMS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geo.api.vlaanderen.be/OMWRGBMRVL/wms</Config>
<Config Name="SPW Orthophoto WMS URL" Target="SPW_ORTHOPHOTO_WMS_URL" Default="https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer" Mode="" Description="Official SPW latest Walloon orthophoto WMS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer</Config>
+7
View File
@@ -40,6 +40,13 @@ GEOINTEL_AUTH_PASSWORD_HASH=
GEOINTEL_AUTH_SESSION_SECRET=
GEOINTEL_AUTH_SESSION_TTL_SECONDS=43200
# Guest access is enabled by default whenever operator authentication is active.
# It opens the seeded GeoIntel demo in a temporary, API-enforced restricted
# session. Set this to false on installations containing private project data.
GEOINTEL_GUEST_ACCESS_ENABLED=true
GEOINTEL_GUEST_DISPLAY_NAME=Gast
GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200
# Explicit, bounded acquisition from the official Digitaal Vlaanderen WMS.
ORTHOPHOTO_ENABLED=true
ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms
+28
View File
@@ -40,6 +40,9 @@ GEOINTEL_AUTH_USERNAME="${GEOINTEL_AUTH_USERNAME:-}"
GEOINTEL_AUTH_PASSWORD_HASH="${GEOINTEL_AUTH_PASSWORD_HASH:-}"
GEOINTEL_AUTH_SESSION_SECRET="${GEOINTEL_AUTH_SESSION_SECRET:-}"
GEOINTEL_AUTH_SESSION_TTL_SECONDS="${GEOINTEL_AUTH_SESSION_TTL_SECONDS:-43200}"
GEOINTEL_GUEST_ACCESS_ENABLED="${GEOINTEL_GUEST_ACCESS_ENABLED:-true}"
GEOINTEL_GUEST_DISPLAY_NAME="${GEOINTEL_GUEST_DISPLAY_NAME:-Gast}"
GEOINTEL_GUEST_SESSION_TTL_SECONDS="${GEOINTEL_GUEST_SESSION_TTL_SECONDS:-7200}"
ORTHOPHOTO_ENABLED="${ORTHOPHOTO_ENABLED:-true}"
ORTHOPHOTO_WMS_URL="${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms}"
SPW_ORTHOPHOTO_WMS_URL="${SPW_ORTHOPHOTO_WMS_URL:-https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer}"
@@ -207,6 +210,28 @@ validate_runtime_config() {
esac
fi
case "$GEOINTEL_GUEST_ACCESS_ENABLED" in
true|false) ;;
*)
echo "GEOINTEL_GUEST_ACCESS_ENABLED must be true or false." >&2
return 2
;;
esac
if [ -z "${GEOINTEL_GUEST_DISPLAY_NAME// }" ]; then
echo "GEOINTEL_GUEST_DISPLAY_NAME must not be blank." >&2
return 2
fi
case "$GEOINTEL_GUEST_SESSION_TTL_SECONDS" in
''|*[!0-9]*)
echo "GEOINTEL_GUEST_SESSION_TTL_SECONDS must be an integer." >&2
return 2
;;
esac
if [ "$GEOINTEL_GUEST_SESSION_TTL_SECONDS" -lt 900 ] || [ "$GEOINTEL_GUEST_SESSION_TTL_SECONDS" -gt 86400 ]; then
echo "GEOINTEL_GUEST_SESSION_TTL_SECONDS must be between 900 and 86400." >&2
return 2
fi
case "$GEOINTEL_POSTGRES_PASSWORD" in
''|geointel|postgres|password|changeme|change-me-before-shared-use)
echo "Refusing deployment with an empty or known-default PostGIS password." >&2
@@ -285,6 +310,9 @@ docker run -d \
-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH" \
-e GEOINTEL_AUTH_SESSION_SECRET="$GEOINTEL_AUTH_SESSION_SECRET" \
-e GEOINTEL_AUTH_SESSION_TTL_SECONDS="$GEOINTEL_AUTH_SESSION_TTL_SECONDS" \
-e GEOINTEL_GUEST_ACCESS_ENABLED="$GEOINTEL_GUEST_ACCESS_ENABLED" \
-e GEOINTEL_GUEST_DISPLAY_NAME="$GEOINTEL_GUEST_DISPLAY_NAME" \
-e GEOINTEL_GUEST_SESSION_TTL_SECONDS="$GEOINTEL_GUEST_SESSION_TTL_SECONDS" \
-e ORTHOPHOTO_ENABLED="$ORTHOPHOTO_ENABLED" \
-e ORTHOPHOTO_WMS_URL="$ORTHOPHOTO_WMS_URL" \
-e SPW_ORTHOPHOTO_WMS_URL="$SPW_ORTHOPHOTO_WMS_URL" \
+8
View File
@@ -21,6 +21,14 @@ services:
GEOINTEL_AOI_WORKER_POLL_SECONDS: ${GEOINTEL_AOI_WORKER_POLL_SECONDS:-2}
GEOINTEL_CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}
GEOINTEL_MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500}
GEOINTEL_AUTH_ENABLED: ${GEOINTEL_AUTH_ENABLED:-false}
GEOINTEL_AUTH_USERNAME: ${GEOINTEL_AUTH_USERNAME:-}
GEOINTEL_AUTH_PASSWORD_HASH: ${GEOINTEL_AUTH_PASSWORD_HASH:-}
GEOINTEL_AUTH_SESSION_SECRET: ${GEOINTEL_AUTH_SESSION_SECRET:-}
GEOINTEL_AUTH_SESSION_TTL_SECONDS: ${GEOINTEL_AUTH_SESSION_TTL_SECONDS:-43200}
GEOINTEL_GUEST_ACCESS_ENABLED: ${GEOINTEL_GUEST_ACCESS_ENABLED:-true}
GEOINTEL_GUEST_DISPLAY_NAME: ${GEOINTEL_GUEST_DISPLAY_NAME:-Gast}
GEOINTEL_GUEST_SESSION_TTL_SECONDS: ${GEOINTEL_GUEST_SESSION_TTL_SECONDS:-7200}
ORTHOPHOTO_ENABLED: ${ORTHOPHOTO_ENABLED:-true}
ORTHOPHOTO_WMS_URL: ${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms}
SPW_ORTHOPHOTO_WMS_URL: ${SPW_ORTHOPHOTO_WMS_URL:-https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer}
+8
View File
@@ -23,6 +23,14 @@ services:
STORAGE_ROOT: /app/storage
CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}
MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500}
GEOINTEL_AUTH_ENABLED: ${GEOINTEL_AUTH_ENABLED:-false}
GEOINTEL_AUTH_USERNAME: ${GEOINTEL_AUTH_USERNAME:-}
GEOINTEL_AUTH_PASSWORD_HASH: ${GEOINTEL_AUTH_PASSWORD_HASH:-}
GEOINTEL_AUTH_SESSION_SECRET: ${GEOINTEL_AUTH_SESSION_SECRET:-}
GEOINTEL_AUTH_SESSION_TTL_SECONDS: ${GEOINTEL_AUTH_SESSION_TTL_SECONDS:-43200}
GEOINTEL_GUEST_ACCESS_ENABLED: ${GEOINTEL_GUEST_ACCESS_ENABLED:-true}
GEOINTEL_GUEST_DISPLAY_NAME: ${GEOINTEL_GUEST_DISPLAY_NAME:-Gast}
GEOINTEL_GUEST_SESSION_TTL_SECONDS: ${GEOINTEL_GUEST_SESSION_TTL_SECONDS:-7200}
ORTHOPHOTO_ENABLED: ${ORTHOPHOTO_ENABLED:-true}
ORTHOPHOTO_WMS_URL: ${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms}
SPW_ORTHOPHOTO_WMS_URL: ${SPW_ORTHOPHOTO_WMS_URL:-https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer}
+48 -14
View File
@@ -45,27 +45,37 @@ Any valid GeoJSON geometry object. V1 primarily expects `Polygon` and `MultiPoly
}
```
## Operator authentication
## Operator authentication and guest demo
Authentication is an optional single-operator access gate, not multi-user
account management. When `GEOINTEL_AUTH_ENABLED=true`, every `/api/v1/*`
request except the three authentication endpoints below requires a valid
signed `geointel_session` cookie. Missing, expired or modified sessions return
HTTP 401 with `AUTHENTICATION_REQUIRED`. Direct loopback calls to the backend
without proxy headers remain available to trusted in-container operator tools;
the backend is bound to loopback in the all-in-one runtime.
Authentication remains an optional single-operator access gate, not multi-user
account management or tenant isolation. When `GEOINTEL_AUTH_ENABLED=true`,
every `/api/v1/*` request except the four authentication endpoints below
requires a valid signed `geointel_session` cookie. Missing, expired or modified
sessions return HTTP 401 with `AUTHENTICATION_REQUIRED`. Direct loopback calls
to the backend without proxy headers remain available to trusted in-container
operator tools; the backend is bound to loopback in the all-in-one runtime.
The runtime stores only a PBKDF2-SHA256 password hash and an independent
session-signing secret. The browser receives an HttpOnly, SameSite=Strict,
time-limited cookie. Five failed attempts for one client/username combination
within five minutes temporarily return HTTP 429 `LOGIN_RATE_LIMITED`.
The runtime stores only a PBKDF2-SHA256 operator password hash and an
independent session-signing secret. The browser receives an HttpOnly,
SameSite=Strict, time-limited cookie. Five failed operator-login attempts for
one client/username combination within five minutes temporarily return HTTP
429 `LOGIN_RATE_LIMITED`.
Optional guest access is a configuration-gated demonstration mode. It creates
a shorter signed session with role `guest`, scopes that session to the
idempotently seeded demo project and blocks mutating operator routes. Project
listing is filtered to the bound demo project. The frontend exposes only the
map and the already calculated quality evidence. This is deliberately **not**
a substitute for user accounts, authorization or tenant isolation; expose it
only on a dedicated demo installation without private or operational data.
### GET `/api/v1/auth/session`
Public session probe used by the frontend before it mounts the workbench.
When authentication is disabled, `authenticated` is true and
`authentication_required` is false so local development retains its existing
direct workflow.
direct workflow. `guest_access_enabled` tells the landing page whether it may
show the guest action.
```json
{
@@ -73,11 +83,18 @@ direct workflow.
"authentication_required": true,
"authenticated": false,
"username": null,
"expires_at": null
"expires_at": null,
"role": null,
"guest_access_enabled": true,
"guest_project_id": null
}
}
```
Authenticated operator sessions return `role: "operator"`. Guest sessions
return `role: "guest"` and the UUID of their bound demo project in
`guest_project_id`.
### POST `/api/v1/auth/login`
```json
@@ -91,6 +108,23 @@ Successful login sets the session cookie and returns the authenticated session
shape. Invalid credentials return HTTP 401 `INVALID_CREDENTIALS`; username
existence is not disclosed.
### POST `/api/v1/auth/guest`
No request body is required. The endpoint is available only when both
`GEOINTEL_AUTH_ENABLED=true` and `GEOINTEL_GUEST_ACCESS_ENABLED=true`. It
idempotently prepares the canonical demo workflow, creates a short-lived guest
session bound to that project and returns the normal session shape.
Disabled guest access returns HTTP 403 `GUEST_ACCESS_DISABLED`. A guest request
for a different project returns HTTP 403 `GUEST_PROJECT_SCOPE_REQUIRED`; a
blocked mutation returns HTTP 403 `GUEST_READ_ONLY`. Unscoped read routes that
are not needed by the demo return HTTP 403 `GUEST_ROUTE_NOT_AVAILABLE`.
Guest reads are limited to the filtered project list, provider metadata and the
bound project tree. A small, explicit set of `POST` selection/read-analysis
routes remains available because those routes query persisted evidence without
exposing operator administration. Coverage resolution additionally verifies
the `project_id` in the request body against the guest-session scope.
### POST `/api/v1/auth/logout`
Clears the browser cookie and returns an unauthenticated session. Logout is
+31
View File
@@ -1,3 +1,34 @@
## 2026-07-27 - Guest demo and product professionalization
- Audited the access experience, workbench information density, responsive
layout and accumulated frontend styling; recorded findings in
`docs/PROJECT_PROFESSIONALIZATION_AUDIT_2026-07-27.md`.
- Added configuration-gated guest access with a short signed session, explicit
guest role, demo-project scope, filtered project listing and backend-enforced
read-only/cross-project restrictions.
- Rebuilt the landing and login hierarchy, added **Als gast verkennen**, mapped
authentication failures to user-facing Dutch messages and improved mobile
navigation and accessibility states.
- Reduced the guest workbench to map exploration and existing quality evidence,
added persistent demo context and removed operator-only controls from the
guest surface.
- Added targeted final layout overrides instead of destructively rewriting the
four historical workbench stylesheets without a complete visual-regression
baseline.
- Added Compose, Unraid, DockerMan and runtime validation settings for guest
enablement. A later packaging follow-up changed the default to enabled whenever
operator authentication is active; installations can still opt out explicitly.
- Validation: 6/6 targeted backend auth/guest tests and 2/2 direct frontend
interaction smokes passed; Python compile, complete frontend TypeScript
typecheck, CSS parsing, Compose YAML, Unraid XML, DockerMan shell syntax and
scoped diff-whitespace checks passed.
- Environment boundary: the supplied frontend dependency tree contains only
Windows-native Rollup/esbuild packages. Vitest and Vite therefore could not
start in this Linux review container, and the available package proxy returned
503 responses/time-outs while fetching Linux replacements. Re-run unit tests
and the production bundle after a clean `npm ci` in the normal Windows or
Linux CI/Docker environment.
## 2026-07-26 - Complete Belgium PyTorch training roadmap
- Added `docs/PYTORCH_TRAINING_ROADMAP_BELGIUM.md` as the executable programme board.
+8 -6
View File
@@ -7,9 +7,11 @@ CRITICAL or HIGH release defect. Coverage and capability responses remain the
runtime source of truth.
- The access gate intentionally supports one environment-configured operator
account. There is no registration, password-recovery email, role model,
organisation management or multi-user database. Password rotation is an
operator configuration action followed by a runtime restart.
account. There is no registration, password-recovery email, organisation
management or multi-user database. Optional guest access is a short-lived,
read-only, demo-project-scoped role; it is not tenant isolation and must be
used only on a dedicated demo instance without private data. Operator
password rotation remains a configuration action followed by a restart.
## Source coverage
@@ -72,8 +74,8 @@ runtime source of truth.
- Long AI/GIS work still uses the existing synchronous job abstraction rather
than a distributed durable queue. Interrupted synchronous work is marked
failed on restart and must be retried explicitly.
- GeoIntel RC is a controlled single-operator deployment. Authentication,
multi-user authorization and tenant isolation are outside the frozen RC
scope.
- GeoIntel remains a controlled single-operator product. The optional guest
demo adds bounded presentation access only; multi-user authorization and
tenant isolation remain outside the product scope.
- Cleanup remains manual, dry-run-first and confirmation-gated. No automatic
retention schedule is installed.
@@ -0,0 +1,185 @@
# GeoIntel professionaliseringsaudit — 27 juli 2026
## Managementsamenvatting
GeoIntel is inhoudelijk veel sterker dan de eerste visuele indruk deed
vermoeden. De repository bevat een volwassen, documentatiegestuurde
GIS-architectuur, expliciete bron- en provenancecontracten, uitgebreide
kwaliteitscontrole en een product dat bewust geen resultaten fabriceert wanneer
brondata of modellen ontbreken. De grootste productrisico's zaten niet in de
GIS-kern, maar in de toegangservaring, de presentatie van de functiedichtheid
en de gegroeide frontend-stijllagen.
Deze pass professionaliseert de eerste gebruikerservaring en voegt een veilige
gastdemonstratie toe. Een bezoeker kan nu rechtstreeks vanaf de landingspagina
een tijdelijke demowerkruimte openen. Die sessie is server-side aan één
voorbeeldproject gebonden, heeft een kortere levensduur en kan geen
operatorwijzigingen uitvoeren. De interface toont in gastmodus alleen de kaart
en bestaand kwaliteitsbewijs.
## Wat al sterk was
- **Inhoudelijke geloofwaardigheid.** Officiële bronnen, meeteenheden, CRS,
dekking, beperkingen en provenance worden als productgegevens behandeld en
niet als decoratieve metadata.
- **Fail-closed gedrag.** Niet-geconfigureerde bronnen en modellen worden niet
stilzwijgend vervangen door fixtures of gesimuleerd succes.
- **Map-first productmodel.** Project, gebied, dataset, analyse en QA delen een
ruimtelijke context, wat veel sterker is dan een verzameling losse dashboards.
- **Operationele discipline.** De repository bevat releasegates, Unraid-assets,
migraties, herstelpaden, tests en expliciete scope-/beperkingsdocumentatie.
- **Bestaande demofundering.** Het idempotente demoworkflowcontract maakte een
gecontroleerde gastbeleving mogelijk zonder een tweede fictieve applicatie te
bouwen.
## Belangrijkste bevindingen
### P0 — Er ontbrak een toegankelijke productdemo
De oorspronkelijke ingang bood alleen een operatorlogin. Voor een recruiter,
stakeholder of eerste beoordelaar was daardoor niet zichtbaar wat het platform
kan zonder vooraf accounts of wachtwoorden uit te wisselen. Een onbegrensde
“login zonder wachtwoord” zou echter toegang tot operationele functies hebben
gegeven.
**Oplossing:** een config-gated `POST /api/v1/auth/guest`, een gesigneerde
gastrol met projectscope, server-side mutatieblokkering en een expliciete knop
**Als gast verkennen**. De demo wordt bij openen idempotent voorbereid.
### P1 — De landingspagina communiceerde de productwaarde onvoldoende snel
De informatie was aanwezig, maar de primaire actie, productbelofte,
betrouwbaarheidssignalen en demonstratiemogelijkheid concurreerden visueel met
elkaar. Op kleinere schermen voelde de ingang langer en minder doelgericht.
**Oplossing:** nieuwe hero- en loginhiërarchie, heldere keuze tussen operator en
gast, compactere capability-sectie, concreter vierstappenproces, betere mobiele
navigatie en begrijpelijke foutmeldingen in plaats van ruwe servicefouten.
### P1 — De workbench was voor een gast te breed en te technisch
De volledige operatornavigatie bevat projectbeheer, imports, AI-taken, exports
en geavanceerde analyses. Dat is gepast voor een beheerder, maar werkt tegen een
snelle demonstratie.
**Oplossing:** de gastrol ziet alleen **Kaart** en **Kwaliteit**, krijgt een
blijvende alleen-lezen contextbanner en ziet geen creatie-, import-, export-,
AI- of beheeracties. De backend blijft de autoritatieve grens.
### P1 — De visuele laag is historisch gegroeid
Vier opeenvolgende workbench-stijlbestanden bevatten samen 13.818 regels CSS:
`app.css`, `premium.css`, `atlas-workbench.css` en `atlas-premium-v2.css`. Over
de volledige actieve stijllaag zijn tientallen mediaqueries aanwezig. Dat
verhoogt de kans op cascadeconflicten, onverwachte responsive afwijkingen en
onnodig moeilijke toekomstige aanpassingen.
**Oplossing in deze pass:** een kleine, als laatste geladen
`professionalization.css` met gerichte correcties voor navigatierail, contextbalk,
werkruimtehoogte, gaststatus, truncation en responsive gedrag. De historische
lagen zijn bewust niet massaal herschreven zonder volledige visuele
regressiebaseline.
**Aanbevolen vervolgstap:** component voor component consolideren naar tokens,
layout primitives en één stylesheet per functioneel domein, telkens beschermd
door desktop-, ultrawide- en mobiele screenshots.
### P2 — Twee frontendcomponenten dragen te veel verantwoordelijkheid
`App.tsx` telt circa 1.400 regels en `MapWorkspace.tsx` circa 3.900 regels. Dat
is nog werkbaar, maar maakt layout-, permissie- en interactiewijzigingen
risicovoller dan nodig.
**Aanbevolen vervolgstap:** splits shell/navigatie, workspace-routing,
gastsessiecontext, kaartselectie, bronresolutie en analysepresentatie in
afzonderlijke domeincomponenten en hooks. Doe dit pas na de huidige
regressietests, zodat gedrag niet tegelijk met structuur wordt gewijzigd.
### P2 — Gastmodus is geen tenantisolatie
De sessie is cryptografisch gesigneerd, kort geldig, projectgebonden en
alleen-lezen. Toch blijft GeoIntel architecturaal een single-operatorproduct. De
gastrol is bedoeld voor een aparte demo-installatie, niet om operationele en
publieke gebruikers veilig in dezelfde datastore te mengen.
## Geleverde wijzigingen
| Domein | Professionalisering |
|---|---|
| Toegang | Nieuwe gastactie, wachtwoordzichtbaarheid, heldere operator/gastkeuze en bruikbare foutmeldingen |
| Sessies | Versie 2-sessietoken met expliciete `operator`/`guest`-rol, TTL en optionele projectscope |
| Backendgrens | Positieve read-allowlist, gastprojectfilter, cross-projectblokkering en mutatieblokkering met stabiele foutcodes |
| Demo | Canonieke demoworkflow wordt idempotent voorbereid bij gastlogin |
| Workbench | Gereduceerde gastnavigatie, alleen-lezen statusbanner en verborgen beheerfuncties |
| Kaart | Alleen-lezen variant zonder on-demand acquisitie of geavanceerde operatorcontrole |
| Layout | Rustigere desktop-shell, betere truncation, responsieve gaststatus en reduced-motion ondersteuning |
| Deployment | Gastvariabelen in Compose, Unraid-env, DockerMan-template en runtimevalidatie |
| Documentatie | API-contract, README, Unraid-instructies, beperkingen, TODO en uitvoeringslog bijgewerkt |
## Configuratie
Gasttoegang staat standaard ingeschakeld zodra de operator-login actief is.
Voor een afzonderlijke demo-installatie:
```env
GEOINTEL_AUTH_ENABLED=true
GEOINTEL_AUTH_USERNAME=operator
GEOINTEL_AUTH_PASSWORD_HASH=pbkdf2_sha256$...
GEOINTEL_AUTH_SESSION_SECRET=<minstens-32-willekeurige-tekens>
GEOINTEL_GUEST_ACCESS_ENABLED=true
GEOINTEL_GUEST_DISPLAY_NAME=Gast
GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200
```
Zet `GEOINTEL_GUEST_ACCESS_ENABLED=false` om gasttoegang expliciet uit te schakelen.
Gebruik een afzonderlijke container, database en storage-root wanneer de demo
van buiten het vertrouwde LAN bereikbaar wordt. Plaats geen private, klant- of
operationele datasets in die omgeving.
## Validatie
De volgende controles zijn op 27 juli 2026 uitgevoerd:
| Controle | Resultaat |
|---|---|
| Gerichte backend auth-/gastbeveiligingstests | **Geslaagd — 6/6** |
| Python compile van `backend/app` en de nieuwe authtests | **Geslaagd** |
| Volledige frontend TypeScript-typecheck | **Geslaagd** |
| Gerichte frontend-interactiesmokes: gast/operator-login en beperkte bootstrap | **Geslaagd — 2/2** |
| CSS-syntax van de vernieuwde landing en professionaliseringslaag | **Geslaagd** |
| Compose YAML, Unraid XML en DockerMan-shellsyntax | **Geslaagd** |
| Whitespacecontrole op alle in deze pass gewijzigde bestanden | **Geslaagd** |
De gerichte backendtests draaiden met SQLite en een tijdelijke minimale
`geoalchemy2`-importstub buiten de repository, omdat de reviewcontainer de
PostGIS-runtimepackages niet bevatte. Daarmee zijn tokenvalidatie, cookies,
login/logout, gastscope, cross-projectblokkering en route-/mutatieblokkering
wel rechtstreeks getest; het is geen vervanging voor de bestaande volledige
PostgreSQL/PostGIS-integratiegate.
De nieuwe logincomponent en de beperkte workbench-bootstrap zijn aanvullend
rechtstreeks in JSDOM uitgevoerd via een tijdelijke TypeScript-loader buiten de
repository. Daarmee zijn de zichtbaarheid van gastacties, de `POST` naar de
gastendpoint, de operatorlogin en het uitschakelen van operator-only
bootstrapcalls interactief gecontroleerd.
De aangeleverde `node_modules` bevat alleen Windows-native Rollup- en
esbuildpakketten. Daardoor konden de normale Vitest-runner en de
Vite-productiebundel in deze Linux-reviewcontainer niet starten. Een schone
dependency-installatie was niet mogelijk doordat de beschikbare packageproxy
tijdens de controle 503-responses en time-outs gaf. De TypeScript-compiler voltooide wel zonder fouten. De
frontend-unit- en productiebuildgates moeten daarom na `npm ci` op Windows of
in de normale Linux CI-/Dockeromgeving nogmaals worden uitgevoerd.
## Aanbevolen roadmap
1. Leg visuele regressiesnapshots vast voor login, kaart, kwaliteit en alle
primaire workspaces op mobiel, desktop en ultrawide.
2. Consolideer de vier historische workbench-CSS-lagen incrementeel; verwijder
pas selectors nadat screenshots en interactietests gelijkwaardig zijn.
3. Splits `App.tsx` en `MapWorkspace.tsx` langs domeingrenzen, zonder API- of
analysegedrag te wijzigen.
4. Voeg een expliciete demo-reset/refreshstrategie en misbruiktelemetrie toe
wanneer de demo publiek wordt blootgesteld.
5. Bouw alleen bij echte multi-userbehoefte een afzonderlijk identiteits-,
autorisatie- en tenantmodel; breid gastmodus daar niet ad hoc voor uit.
+15
View File
@@ -13,6 +13,21 @@ Uitvoeringsbord: `docs/PYTORCH_TRAINING_ROADMAP_BELGIUM.md`.
## Actieve post-RC datadekkingsfase
Professionaliseringspass (2026-07-27):
- [x] Voeg een expliciete gastknop toe aan de toegangspoort en open daarmee
een korte, projectgebonden, alleen-lezen demowerkruimte.
- [x] Beperk de gastinterface tot kaartverkenning en bestaand kwaliteitsbewijs;
blokkeer operatoracties en toegang tot andere projecten ook server-side.
- [x] Herwerk de landingspagina, aanmeldhiërarchie, mobiele navigatie en
workbenchcontext tot één rustigere en professionelere productervaring.
- [ ] Consolideer na visuele regressiesnapshots de vier historische
workbench-stijllagen (`app`, `premium`, `atlas-workbench`,
`atlas-premium-v2`) tot een kleiner gelaagd stijlsysteem.
- [ ] Ontwerp alleen bij een toekomstige publieke multi-projectinstallatie een
volwaardig account-, autorisatie- en tenantisolatiemodel; gastmodus is daar
uitdrukkelijk geen vervanging voor.
- [x] Voeg een interactieve, data-gedreven projectatlas toe aan de statuswerkruimte met toegankelijke navigatie, echte readiness-toestanden en reduced-motion ondersteuning.
- [x] Geef alle primaire werkruimtes een eigen geanimeerde signaalillustratie, verbeter lege toestanden en verwijder de dubbele kaartfoutmelding bij sessieverval.
+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;
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"types": ["vite/client"],
"types": ["vite/client", "geojson"],
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,