Update
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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.')
|
||||
})
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
+1584
-235
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "geojson"],
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
|
||||
Reference in New Issue
Block a user