From c76a746cd722986a50a72a1484ae5d7d8d3be9ff Mon Sep 17 00:00:00 2001 From: Jens Date: Mon, 27 Jul 2026 23:28:43 +0200 Subject: [PATCH] Update --- .env.example | 12 + CHANGELOG.md | 2 + README.md | 22 + backend/app/api/routes/auth.py | 129 +- backend/app/api/routes/external.py | 22 +- backend/app/api/routes/projects.py | 24 +- backend/app/core/config.py | 19 + backend/app/main.py | 86 + backend/app/schemas/auth.py | 5 + backend/app/services/auth_service.py | 54 +- backend/tests/test_auth.py | 129 +- deploy/unraid/README.md | 18 + deploy/unraid/geointel-unraid-template.xml | 3 + deploy/unraid/geointel.env.example | 7 + deploy/unraid/run-dockerman-container.sh | 28 + docker-compose.unraid.yml | 8 + docker-compose.yml | 8 + docs/API_CONTRACTS.md | 62 +- docs/CODEX_EXECUTION_LOG.md | 31 + docs/KNOWN_LIMITATIONS.md | 14 +- ...CT_PROFESSIONALIZATION_AUDIT_2026-07-27.md | 185 ++ docs/TODO.md | 15 + frontend/src/App.tsx | 143 +- frontend/src/components/GeoMap.tsx | 7 +- .../components/WorkbenchStatusStrip.test.tsx | 3 +- .../src/components/auth/LandingPage.test.tsx | 69 +- frontend/src/components/auth/LandingPage.tsx | 374 +++- frontend/src/components/map/MapWorkspace.tsx | 102 +- .../map/MunicipalitySearch.test.tsx | 3 +- .../ProjectAtlasIllustration.test.tsx | 3 +- frontend/src/hooks/useDemoWorkflow.ts | 19 +- frontend/src/hooks/useOperatorSession.ts | 14 +- .../src/hooks/useWorkbenchBootstrap.test.tsx | 19 +- frontend/src/hooks/useWorkbenchBootstrap.ts | 26 +- frontend/src/lib/authError.ts | 25 + frontend/src/services/api/auth.ts | 7 + frontend/src/styles/landing.css | 1819 ++++++++++++++--- frontend/src/styles/professionalization.css | 269 +++ frontend/tsconfig.json | 2 +- 39 files changed, 3268 insertions(+), 519 deletions(-) create mode 100644 docs/PROJECT_PROFESSIONALIZATION_AUDIT_2026-07-27.md create mode 100644 frontend/src/lib/authError.ts create mode 100644 frontend/src/styles/professionalization.css diff --git a/.env.example b/.env.example index 72058506..c37c8566 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 17148843..83685b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 378dbf5a..4183f406 100644 --- a/README.md +++ b/README.md @@ -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= +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. diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 52b5c36b..13db641d 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -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, + ) ) diff --git a/backend/app/api/routes/external.py b/backend/app/api/routes/external.py index 963366bd..a9d1b9ac 100644 --- a/backend/app/api/routes/external.py +++ b/backend/app/api/routes/external.py @@ -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, diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index 8f34329b..d20129ac 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -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, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 677ee247..3128c37e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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(): diff --git a/backend/app/main.py b/backend/app/main.py index 834d89c4..07b4bd20 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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( diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index f4c13942..f21ffe92 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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]): diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index d592242d..bf93a3fc 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -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 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index bc0fd7c2..ccd82174 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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 diff --git a/deploy/unraid/README.md b/deploy/unraid/README.md index e5d12435..d7b7d076 100644 --- a/deploy/unraid/README.md +++ b/deploy/unraid/README.md @@ -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 `-ai` or diff --git a/deploy/unraid/geointel-unraid-template.xml b/deploy/unraid/geointel-unraid-template.xml index be84bccd..ae02ca04 100644 --- a/deploy/unraid/geointel-unraid-template.xml +++ b/deploy/unraid/geointel-unraid-template.xml @@ -37,6 +37,9 @@ 43200 + true + Gast + 7200 true https://geo.api.vlaanderen.be/OMWRGBMRVL/wms https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer diff --git a/deploy/unraid/geointel.env.example b/deploy/unraid/geointel.env.example index ded6c51b..9b3c3eb2 100644 --- a/deploy/unraid/geointel.env.example +++ b/deploy/unraid/geointel.env.example @@ -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 diff --git a/deploy/unraid/run-dockerman-container.sh b/deploy/unraid/run-dockerman-container.sh index 93ce323f..0196c4d3 100644 --- a/deploy/unraid/run-dockerman-container.sh +++ b/deploy/unraid/run-dockerman-container.sh @@ -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" \ diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index 840bb543..be2f3002 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -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} diff --git a/docker-compose.yml b/docker-compose.yml index 039d90e7..298b60f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 70e4d090..d5997a85 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -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 diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 0b030c58..b8efd3d3 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -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. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 1c38f0ee..a51097fb 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -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. diff --git a/docs/PROJECT_PROFESSIONALIZATION_AUDIT_2026-07-27.md b/docs/PROJECT_PROFESSIONALIZATION_AUDIT_2026-07-27.md new file mode 100644 index 00000000..0d1d5594 --- /dev/null +++ b/docs/PROJECT_PROFESSIONALIZATION_AUDIT_2026-07-27.md @@ -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= +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. diff --git a/docs/TODO.md b/docs/TODO.md index da40df84..91fa5adf 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9e3fda38..25217b04 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(['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('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(null) const [mapContextLayerLabel, setMapContextLayerLabel] = useState(null) const workbenchMainRef = useRef(null) const previousWorkspaceRef = useRef(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 ( -
+ - {username ? ( -
-
diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 3a0a8307..b192092e 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -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(() => { const selectedDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null return themeIdForDataset(selectedDataset) ?? 'buildings' @@ -2213,27 +2218,39 @@ export function MapWorkspace({ Evolutie
- + {!readOnly ? ( + + ) : null} - + {readOnly ? ( +
+
+ ) : ( + + )} {workspaceLoading ? (
@@ -2266,6 +2283,8 @@ export function MapWorkspace({ {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'} @@ -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'} ) @@ -2648,7 +2667,7 @@ export function MapWorkspace({
- {analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? ( + {!readOnly && analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? (
Beeldanalyse @@ -2914,21 +2933,30 @@ export function MapWorkspace({ ) : null} {activeSelectionResult || temporalComparison ? ( -
- - Analyse klaar - Stel een vraag over dit gebied of open je bewaarde resultaten. - - - -
+ readOnly ? ( +
+ + Analyse klaar + Dit resultaat blijft tijdelijk in de browser. Meld u aan als operator om analyses te bewaren of verder te verwerken. + +
+ ) : ( +
+ + Analyse klaar + Stel een vraag over dit gebied of open je bewaarde resultaten. + + + +
+ ) ) : null} )} diff --git a/frontend/src/components/map/MunicipalitySearch.test.tsx b/frontend/src/components/map/MunicipalitySearch.test.tsx index 00fdc17c..1e4e9a54 100644 --- a/frontend/src/components/map/MunicipalitySearch.test.tsx +++ b/frontend/src/components/map/MunicipalitySearch.test.tsx @@ -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' diff --git a/frontend/src/components/overview/ProjectAtlasIllustration.test.tsx b/frontend/src/components/overview/ProjectAtlasIllustration.test.tsx index 32dfdf53..7e26a8bc 100644 --- a/frontend/src/components/overview/ProjectAtlasIllustration.test.tsx +++ b/frontend/src/components/overview/ProjectAtlasIllustration.test.tsx @@ -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' diff --git a/frontend/src/hooks/useDemoWorkflow.ts b/frontend/src/hooks/useDemoWorkflow.ts index 8d3e3c81..66566a7d 100644 --- a/frontend/src/hooks/useDemoWorkflow.ts +++ b/frontend/src/hooks/useDemoWorkflow.ts @@ -4,6 +4,7 @@ import type { DatasetCreateResponse, QualityCheckRead } from '../types' import { formatError } from '../lib/formatError' interface DemoWorkflowOptions { + restrictedMode?: boolean loadProjects: (preferredProjectId?: string | null) => Promise loadProjectData: (projectId: string) => Promise<{ datasets: DatasetCreateResponse[] } | null> loadDatasetDetails: (projectId: string, dataset: DatasetCreateResponse) => Promise @@ -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(null) - const loadDemoWorkflow = async () => { + const loadDemoWorkflow = async (): Promise => { 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) } diff --git a/frontend/src/hooks/useOperatorSession.ts b/frontend/src/hooks/useOperatorSession.ts index 0e688bd6..61277bff 100644 --- a/frontend/src/hooks/useOperatorSession.ts +++ b/frontend/src/hooks/useOperatorSession.ts @@ -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) } diff --git a/frontend/src/hooks/useWorkbenchBootstrap.test.tsx b/frontend/src/hooks/useWorkbenchBootstrap.test.tsx index 9722937f..a465bfa4 100644 --- a/frontend/src/hooks/useWorkbenchBootstrap.test.tsx +++ b/frontend/src/hooks/useWorkbenchBootstrap.test.tsx @@ -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() + }) }) diff --git a/frontend/src/hooks/useWorkbenchBootstrap.ts b/frontend/src/hooks/useWorkbenchBootstrap.ts index f8423f51..29039ae0 100644 --- a/frontend/src/hooks/useWorkbenchBootstrap.ts +++ b/frontend/src/hooks/useWorkbenchBootstrap.ts @@ -4,6 +4,7 @@ type AsyncAction = () => Promise type ProjectAction = (projectId: string) => Promise 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]) } diff --git a/frontend/src/lib/authError.ts b/frontend/src/lib/authError.ts new file mode 100644 index 00000000..a6f250b4 --- /dev/null +++ b/frontend/src/lib/authError.ts @@ -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 +} diff --git a/frontend/src/services/api/auth.ts b/frontend/src/services/api/auth.ts index e21e8349..07349413 100644 --- a/frontend/src/services/api/auth.ts +++ b/frontend/src/services/api/auth.ts @@ -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 { @@ -15,6 +18,10 @@ export function login(username: string, password: string): Promise return apiPost('/api/v1/auth/login', { username, password }) } +export function loginAsGuest(): Promise { + return apiPost('/api/v1/auth/guest') +} + export function logout(): Promise { return apiPost('/api/v1/auth/logout') } diff --git a/frontend/src/styles/landing.css b/frontend/src/styles/landing.css index 06563a25..2bbf6897 100644 --- a/frontend/src/styles/landing.css +++ b/frontend/src/styles/landing.css @@ -1,14 +1,22 @@ :root { - --landing-canvas: #f5f7f6; + --landing-canvas: #f4f8f7; --landing-surface: #ffffff; - --landing-ink: #102f2a; - --landing-muted: #56706a; - --landing-primary: #087266; - --landing-primary-strong: #05574f; + --landing-ink: #0b2824; + --landing-ink-soft: #294944; + --landing-muted: #607873; + --landing-primary: #08786c; + --landing-primary-strong: #04584f; + --landing-primary-dark: #073d38; --landing-mint: #dff7f2; - --landing-blue: #315d73; - --landing-amber: #b46432; - --landing-line: #b9d0ca; + --landing-mint-strong: #a8e1d7; + --landing-blue: #4e7184; + --landing-line: #c8dbd7; + --landing-line-soft: #dde9e6; + --landing-shadow: 0 30px 90px rgba(9, 48, 42, 0.16); +} + +html:has(body.landing-body) { + scroll-behavior: smooth; } body.landing-body { @@ -20,7 +28,9 @@ body.landing-body { min-width: 20rem; min-height: 100dvh; overflow-x: hidden; - background: var(--landing-canvas); + background: + radial-gradient(circle at 10% 10%, rgba(153, 224, 211, 0.18), transparent 28rem), + var(--landing-canvas); color: var(--landing-ink); font-family: "Public Sans", "Segoe UI", sans-serif; } @@ -31,17 +41,38 @@ body.landing-body { box-sizing: border-box; } -.landing-skip-link { - position: fixed; - z-index: 120; - top: -4rem; - left: 1rem; - padding: 0.7rem 1rem; - background: var(--landing-primary-strong); - color: #fff; +.landing-page button, +.landing-page input { + font: inherit; } -.landing-skip-link:focus { top: 0.75rem; } +.landing-page button, +.landing-page a { + -webkit-tap-highlight-color: transparent; +} + +.landing-page button:not(:disabled), +.landing-page a { + cursor: pointer; +} + +.landing-skip-link { + position: fixed; + z-index: 200; + top: -5rem; + left: 1rem; + border-radius: 0.65rem; + padding: 0.75rem 1rem; + background: var(--landing-primary-dark); + color: #fff; + font-weight: 700; + text-decoration: none; + box-shadow: 0 12px 30px rgba(4, 42, 37, 0.24); +} + +.landing-skip-link:focus { + top: 0.75rem; +} .landing-header { position: fixed; @@ -50,324 +81,1642 @@ body.landing-body { right: 0; left: 0; display: grid; - min-height: 3.65rem; + min-height: 4.5rem; grid-template-columns: auto minmax(0, 1fr) auto; - gap: 2rem; + gap: clamp(1.25rem, 3vw, 3rem); align-items: center; - border-bottom: 1px solid rgba(70, 105, 97, 0.26); - padding: 0.55rem clamp(1rem, 4vw, 4.5rem); - background: rgba(255, 255, 255, 0.96); - backdrop-filter: blur(14px); + border-bottom: 1px solid rgba(104, 139, 132, 0.22); + padding: 0.65rem clamp(1rem, 4vw, 4.5rem); + background: rgba(248, 251, 250, 0.9); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.78) inset; + backdrop-filter: blur(18px) saturate(1.25); } .landing-brand { display: inline-flex; - gap: 0.65rem; + min-width: max-content; + gap: 0.7rem; align-items: center; - color: var(--landing-primary-strong); - font-family: "Manrope", "Segoe UI", sans-serif; - font-weight: 800; + color: var(--landing-ink); text-decoration: none; } .landing-brand-mark { - width: 2rem; - height: 2rem; - border-radius: 0.48rem; - box-shadow: 0 5px 14px rgba(6, 40, 36, 0.19); + width: 2.35rem; + height: 2.35rem; + border-radius: 0.7rem; + box-shadow: 0 8px 22px rgba(3, 55, 49, 0.2); +} + +.landing-brand-copy { + display: grid; + gap: 0.08rem; +} + +.landing-brand-copy strong { + font-family: "Manrope", "Segoe UI", sans-serif; + font-size: 0.97rem; + letter-spacing: -0.02em; +} + +.landing-brand-copy small { + color: var(--landing-muted); + font-size: 0.59rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } .landing-nav { display: flex; - gap: 1.75rem; + gap: clamp(1.15rem, 2.5vw, 2.2rem); align-items: center; } .landing-nav a { - border-bottom: 2px solid transparent; - padding: 0.35rem 0; - color: #34534d; - font-size: 0.82rem; - font-weight: 600; + position: relative; + padding: 0.55rem 0; + color: #3c5a55; + font-size: 0.78rem; + font-weight: 650; text-decoration: none; } -.landing-nav a:hover, -.landing-nav a:focus-visible { border-bottom-color: var(--landing-primary); color: var(--landing-primary); } - -.landing-header-login, -.landing-primary-action, -.landing-login-card form button { - border: 1px solid var(--landing-primary); - border-radius: 4px; +.landing-nav a::after { + position: absolute; + right: 0; + bottom: 0.25rem; + left: 0; + height: 2px; + border-radius: 999px; background: var(--landing-primary); - color: #fff; - font-weight: 700; + content: ''; + opacity: 0; + transform: scaleX(0.4); + transition: 160ms ease; } -.landing-header-login { min-height: 2.35rem; padding: 0.45rem 1.1rem; } -.landing-menu-toggle { display: none; } +.landing-nav a:hover, +.landing-nav a:focus-visible { + color: var(--landing-primary-strong); +} + +.landing-nav a:hover::after, +.landing-nav a:focus-visible::after { + opacity: 1; + transform: scaleX(1); +} + +.landing-header-actions { + display: flex; + gap: 0.55rem; + align-items: center; +} + +.landing-header-login, +.landing-header-guest { + min-height: 2.55rem; + border-radius: 0.62rem; + padding: 0.5rem 1rem; + font-size: 0.76rem; + font-weight: 750; + transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease; +} + +.landing-header-login { + border: 1px solid var(--landing-primary); + background: var(--landing-primary); + color: #fff; + box-shadow: 0 9px 22px rgba(8, 120, 108, 0.18); +} + +.landing-header-guest { + border: 1px solid #a9c8c2; + background: rgba(255, 255, 255, 0.72); + color: var(--landing-primary-strong); +} + +.landing-header-login:hover:not(:disabled), +.landing-header-guest:hover:not(:disabled) { + transform: translateY(-1px); +} + +.landing-header-login:hover:not(:disabled) { + background: var(--landing-primary-strong); +} + +.landing-header-guest:hover:not(:disabled) { + border-color: var(--landing-primary); + background: #fff; +} + +.landing-header-login:disabled, +.landing-header-guest:disabled { + cursor: wait; + opacity: 0.62; +} + +.landing-menu-toggle { + display: none; +} .landing-hero { position: relative; - display: grid; - min-height: min(58rem, 100dvh); - align-items: center; + min-height: 52rem; overflow: hidden; - padding: 6.8rem clamp(1.25rem, 6vw, 6rem) 4rem; + padding: clamp(7.5rem, 10vw, 9.25rem) clamp(1.25rem, 5vw, 5.5rem) clamp(4.25rem, 6vw, 6.5rem); isolation: isolate; } -.landing-hero-background, -.landing-workflow-map-image { - background-image: url('/landing-hero-belgium.webp'); - background-position: center; - background-size: cover; -} - -.landing-hero-background { - position: absolute; - z-index: -2; - inset: 0; - filter: saturate(0.58) contrast(0.82) brightness(1.13); - transform: scale(1.02); -} - .landing-hero::before { position: absolute; - z-index: -1; + z-index: -4; inset: 0; background: - linear-gradient(90deg, rgba(245, 250, 248, 0.98) 0%, rgba(245, 250, 248, 0.88) 43%, rgba(239, 248, 245, 0.62) 68%, rgba(238, 247, 244, 0.76) 100%), - linear-gradient(0deg, var(--landing-canvas) 0%, transparent 22%); + linear-gradient(112deg, rgba(244, 250, 248, 1) 8%, rgba(241, 249, 247, 0.96) 45%, rgba(225, 241, 237, 0.77) 100%), + url('/landing-hero-belgium.webp') center / cover; content: ''; } +.landing-hero::after { + position: absolute; + z-index: -3; + inset: auto 0 0; + height: 8rem; + background: linear-gradient(transparent, var(--landing-canvas)); + content: ''; +} + +.landing-hero-orbit { + position: absolute; + z-index: -2; + border: 1px solid rgba(68, 139, 128, 0.16); + border-radius: 50%; + pointer-events: none; +} + +.landing-hero-orbit::before, +.landing-hero-orbit::after { + position: absolute; + border-radius: 50%; + background: rgba(8, 120, 108, 0.2); + box-shadow: 0 0 0 7px rgba(8, 120, 108, 0.07); + content: ''; +} + +.landing-hero-orbit-one { + top: 3rem; + right: -14rem; + width: 42rem; + height: 42rem; +} + +.landing-hero-orbit-one::before { + top: 8rem; + left: 1.4rem; + width: 0.55rem; + height: 0.55rem; +} + +.landing-hero-orbit-one::after { + right: 8rem; + bottom: 2.4rem; + width: 0.4rem; + height: 0.4rem; +} + +.landing-hero-orbit-two { + bottom: -24rem; + left: -22rem; + width: 52rem; + height: 52rem; +} + .landing-hero-content { display: grid; - width: min(90rem, 100%); - grid-template-columns: minmax(0, 1.15fr) minmax(20rem, 27rem); - gap: clamp(3rem, 7vw, 8rem); + width: min(86rem, 100%); + grid-template-columns: minmax(0, 1.05fr) minmax(25rem, 0.82fr); + gap: clamp(3.2rem, 7vw, 7.5rem); align-items: center; margin: 0 auto; } -.landing-hero-copy { max-width: 47rem; } +.landing-hero-copy { + max-width: 45rem; + padding-block: 1rem; +} + .landing-kicker, -.landing-section-heading > p { +.landing-section-heading > p, +.landing-workflow-eyebrow { display: inline-flex; - gap: 0.45rem; + gap: 0.5rem; align-items: center; - margin: 0 0 1.25rem; + margin: 0; color: var(--landing-primary-strong); - font-size: 0.68rem; + font-size: 0.67rem; + font-weight: 800; + letter-spacing: 0.105em; + text-transform: uppercase; +} + +.landing-kicker { + border: 1px solid rgba(104, 182, 169, 0.52); + border-radius: 999px; + padding: 0.48rem 0.8rem; + background: rgba(225, 250, 245, 0.82); + box-shadow: 0 6px 18px rgba(48, 111, 101, 0.08); +} + +.landing-kicker svg { + width: 0.9rem; + height: 0.9rem; +} + +.landing-hero h1 { + max-width: 43rem; + margin: 1.45rem 0 0; + color: var(--landing-ink); + font-family: "Manrope", "Segoe UI", sans-serif; + font-size: clamp(3.1rem, 5.2vw, 5.35rem); + font-weight: 750; + letter-spacing: -0.06em; + line-height: 0.99; + text-wrap: balance; +} + +.landing-hero h1 span { + display: block; + color: var(--landing-primary); +} + +.landing-lead { + max-width: 42rem; + margin: 1.55rem 0 0; + color: var(--landing-ink-soft); + font-size: clamp(1rem, 1.25vw, 1.14rem); + line-height: 1.68; +} + +.landing-hero-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 2rem; +} + +.landing-primary-action, +.landing-secondary-action { + display: inline-flex; + min-height: 3.25rem; + gap: 0.6rem; + align-items: center; + justify-content: center; + border-radius: 0.72rem; + padding: 0.8rem 1.2rem; + font-size: 0.82rem; + font-weight: 750; + text-decoration: none; + transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease, box-shadow 160ms ease; +} + +.landing-primary-action { + border: 1px solid var(--landing-primary); + background: var(--landing-primary); + color: #fff; + box-shadow: 0 14px 30px rgba(8, 120, 108, 0.2); +} + +.landing-secondary-action { + border: 1px solid #a9c4bf; + background: rgba(255, 255, 255, 0.76); + color: var(--landing-ink); + backdrop-filter: blur(9px); +} + +.landing-primary-action:hover:not(:disabled), +.landing-secondary-action:hover { + transform: translateY(-2px); +} + +.landing-primary-action:hover:not(:disabled) { + background: var(--landing-primary-strong); + box-shadow: 0 18px 34px rgba(8, 120, 108, 0.24); +} + +.landing-secondary-action:hover { + border-color: var(--landing-primary); + background: #fff; + color: var(--landing-primary-strong); +} + +.landing-primary-action svg, +.landing-secondary-action svg { + width: 1.05rem; + height: 1.05rem; +} + +.landing-primary-action:disabled { + cursor: wait; + opacity: 0.72; +} + +.landing-proof-row { + display: grid; + max-width: 43rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.55rem; + margin-top: 2.25rem; +} + +.landing-proof-row > div { + display: grid; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.55rem; + align-items: start; + border-left: 1px solid rgba(99, 139, 132, 0.42); + padding: 0.18rem 0.75rem; +} + +.landing-proof-row > div:first-child { + border-left: 0; + padding-left: 0; +} + +.landing-proof-row svg { + width: 0.9rem; + height: 0.9rem; + margin-top: 0.08rem; + color: var(--landing-primary); +} + +.landing-proof-row span { + display: grid; + min-width: 0; + gap: 0.16rem; +} + +.landing-proof-row strong, +.landing-proof-row small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.landing-proof-row strong { + color: var(--landing-ink); + font-size: 0.71rem; +} + +.landing-proof-row small { + color: var(--landing-muted); + font-size: 0.62rem; +} + +.landing-access-card { + position: relative; + overflow: hidden; + border: 1px solid rgba(103, 147, 138, 0.48); + border-radius: 1.25rem; + background: rgba(255, 255, 255, 0.96); + box-shadow: var(--landing-shadow); + scroll-margin-top: 6rem; + backdrop-filter: blur(16px); +} + +.landing-access-card::before { + position: absolute; + z-index: 3; + top: 0; + right: 0; + left: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.96), transparent); + content: ''; +} + +.landing-access-preview { + position: relative; + display: grid; + min-height: 12.6rem; + overflow: hidden; + align-content: space-between; + padding: 1rem; + background: + linear-gradient(160deg, rgba(4, 39, 35, 0.28), rgba(3, 34, 31, 0.83)), + url('/landing-hero-belgium.webp') center 42% / cover; + color: #fff; + isolation: isolate; +} + +.landing-access-preview::before { + position: absolute; + z-index: -1; + inset: 0; + background-image: + linear-gradient(rgba(164, 228, 216, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(164, 228, 216, 0.08) 1px, transparent 1px); + background-size: 28px 28px; + content: ''; +} + +.landing-access-preview::after { + position: absolute; + z-index: -1; + top: 35%; + left: 37%; + width: 9.5rem; + height: 6rem; + border: 1px solid rgba(116, 224, 204, 0.72); + border-radius: 45% 55% 52% 48% / 48% 46% 54% 52%; + background: rgba(49, 198, 171, 0.08); + box-shadow: 0 0 0 8px rgba(44, 186, 160, 0.03); + content: ''; + transform: rotate(-8deg); +} + +.landing-preview-toolbar { + display: flex; + gap: 1rem; + align-items: center; + justify-content: space-between; + color: rgba(255, 255, 255, 0.78); + font-size: 0.61rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.landing-preview-toolbar span:first-child { + display: inline-flex; + gap: 0.4rem; + align-items: center; +} + +.landing-preview-toolbar i { + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: #65e3ca; + box-shadow: 0 0 0 5px rgba(101, 227, 202, 0.15); +} + +.landing-preview-focus { + display: grid; + width: fit-content; + max-width: calc(100% - 1rem); + grid-template-columns: auto minmax(0, 1fr); + gap: 0.75rem; + align-items: center; + margin: 1.4rem auto 1.1rem 0.7rem; + border: 1px solid rgba(189, 240, 230, 0.25); + border-radius: 0.8rem; + padding: 0.72rem 0.85rem; + background: rgba(5, 36, 32, 0.78); + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.22); + backdrop-filter: blur(8px); +} + +.landing-preview-pin { + display: grid; + width: 2.15rem; + height: 2.15rem; + place-items: center; + border-radius: 0.62rem; + background: rgba(83, 223, 196, 0.16); + color: #7be7d1; +} + +.landing-preview-pin svg { + width: 1.05rem; +} + +.landing-preview-focus > div { + display: grid; + gap: 0.08rem; +} + +.landing-preview-focus small, +.landing-preview-focus span { + color: rgba(255, 255, 255, 0.65); + font-size: 0.58rem; +} + +.landing-preview-focus strong { + overflow: hidden; + font-family: "Manrope", sans-serif; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.landing-preview-metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + border: 1px solid rgba(214, 246, 240, 0.18); + border-radius: 0.72rem; + background: rgba(5, 33, 30, 0.66); + backdrop-filter: blur(8px); +} + +.landing-preview-metrics > span { + display: grid; + gap: 0.1rem; + border-left: 1px solid rgba(214, 246, 240, 0.14); + padding: 0.55rem 0.65rem; +} + +.landing-preview-metrics > span:first-child { + border-left: 0; +} + +.landing-preview-metrics strong { + color: #fff; + font-family: "Manrope", sans-serif; + font-size: 0.73rem; +} + +.landing-preview-metrics small { + color: rgba(255, 255, 255, 0.61); + font-size: 0.51rem; + white-space: nowrap; +} + +.landing-access-body { + padding: clamp(1.3rem, 2.5vw, 1.75rem); +} + +.landing-access-heading { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.8rem; + align-items: start; +} + +.landing-login-icon, +.landing-guest-icon { + display: grid; + flex: 0 0 auto; + place-items: center; + background: var(--landing-mint); + color: var(--landing-primary); +} + +.landing-login-icon { + width: 2.45rem; + height: 2.45rem; + border-radius: 0.68rem; +} + +.landing-login-icon svg, +.landing-guest-icon svg { + width: 1.08rem; + height: 1.08rem; +} + +.landing-access-heading > div { + min-width: 0; +} + +.landing-access-heading p { + margin: 0 0 0.22rem; + color: var(--landing-primary); + font-size: 0.59rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; } -.landing-kicker { - border: 1px solid #a4d8cf; - border-radius: 999px; - padding: 0.42rem 0.7rem; - background: rgba(216, 250, 243, 0.82); -} - -.landing-kicker svg { width: 0.9rem; height: 0.9rem; } -.landing-hero h1 { - max-width: 44rem; +.landing-access-heading h2 { margin: 0; - color: #112f2a; - font-family: "Manrope", "Segoe UI", sans-serif; - font-size: clamp(3rem, 5.8vw, 6rem); + font-family: "Manrope", sans-serif; + font-size: clamp(1.15rem, 2vw, 1.38rem); + letter-spacing: -0.025em; + line-height: 1.2; +} + +.landing-access-heading > div > span { + display: block; + margin-top: 0.32rem; + color: var(--landing-muted); + font-size: 0.7rem; + line-height: 1.45; +} + +.landing-access-card form { + display: grid; + gap: 0.45rem; + margin-top: 1.35rem; +} + +.landing-access-card label { + margin-top: 0.35rem; + color: #49645f; + font-size: 0.58rem; font-weight: 800; - letter-spacing: -0.055em; - line-height: 0.98; + letter-spacing: 0.055em; + text-transform: uppercase; } -.landing-hero h1 span { color: var(--landing-primary); } -.landing-lead { - max-width: 40rem; - margin: 1.6rem 0 0; - color: #365a53; - font-size: clamp(1rem, 1.35vw, 1.2rem); - line-height: 1.65; +.landing-access-card input { + width: 100%; + min-height: 2.85rem; + border: 1px solid #b8d1cc; + border-radius: 0.62rem; + padding: 0.7rem 0.78rem; + background: #f4fbf9; + color: var(--landing-ink); + outline: none; + transition: border-color 150ms ease, background 150ms ease, box-shadow 150ms ease; } -.landing-hero-actions { display: flex; gap: 0.75rem; margin-top: 2rem; } -.landing-primary-action, -.landing-secondary-action { +.landing-access-card input:hover:not(:disabled) { + border-color: #86b9b0; + background: #f8fdfc; +} + +.landing-access-card input:focus { + border-color: var(--landing-primary); + background: #fff; + box-shadow: 0 0 0 3px rgba(8, 120, 108, 0.13); +} + +.landing-password-field { + position: relative; +} + +.landing-password-field input { + padding-right: 3rem; +} + +.landing-password-field > button { + position: absolute; + top: 50%; + right: 0.35rem; + display: grid; + width: 2.15rem; + height: 2.15rem; + place-items: center; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: #55716b; + transform: translateY(-50%); +} + +.landing-password-field > button:hover:not(:disabled), +.landing-password-field > button:focus-visible { + background: #e6f4f1; + color: var(--landing-primary-strong); +} + +.landing-password-field > button svg { + width: 1rem; + height: 1rem; +} + +.landing-operator-submit { display: inline-flex; min-height: 3rem; gap: 0.55rem; align-items: center; justify-content: center; - border-radius: 4px; - padding: 0.72rem 1.15rem; - font-size: 0.84rem; - text-decoration: none; + margin-top: 0.55rem; + border: 1px solid var(--landing-primary); + border-radius: 0.66rem; + padding: 0.72rem 1rem; + background: var(--landing-primary); + color: #fff; + font-size: 0.77rem; + font-weight: 750; + box-shadow: 0 10px 24px rgba(8, 120, 108, 0.16); + transition: background 160ms ease, transform 160ms ease, box-shadow 160ms ease; } -.landing-primary-action svg, -.landing-secondary-action svg { width: 1.05rem; height: 1.05rem; } -.landing-secondary-action { - border: 1px solid #9bbab4; - background: rgba(255, 255, 255, 0.86); - color: var(--landing-ink); - font-weight: 700; +.landing-operator-submit:hover:not(:disabled) { + background: var(--landing-primary-strong); + box-shadow: 0 14px 26px rgba(8, 120, 108, 0.22); + transform: translateY(-1px); } -.landing-trust-strip { - display: flex; - gap: 0; - margin: 2.5rem 0 0; +.landing-operator-submit:disabled { + cursor: not-allowed; + opacity: 0.5; } -.landing-trust-strip > div { border-left: 1px solid #9db8b2; padding: 0 1.25rem; } -.landing-trust-strip > div:first-child { padding-left: 0; border-left: 0; } -.landing-trust-strip dt { color: var(--landing-muted); font-size: 0.62rem; text-transform: uppercase; } -.landing-trust-strip dd { margin: 0.25rem 0 0; color: var(--landing-ink); font-size: 0.78rem; font-weight: 700; } - -.landing-login-card { - border: 1px solid rgba(111, 153, 144, 0.64); - border-radius: 6px; - padding: clamp(1.4rem, 3vw, 2rem); - background: rgba(255, 255, 255, 0.94); - box-shadow: 0 24px 60px rgba(25, 57, 50, 0.16); - backdrop-filter: blur(12px); +.landing-operator-submit svg { + width: 1rem; + height: 1rem; } -.landing-login-heading { display: flex; gap: 0.85rem; align-items: flex-start; } -.landing-login-icon { +.landing-login-error { + margin: 0.5rem 0 0; + border: 1px solid #efc4c1; + border-left: 3px solid #a23e38; + border-radius: 0.5rem; + padding: 0.62rem 0.72rem; + background: #fff3f2; + color: #7c2d29; + font-size: 0.68rem; + line-height: 1.45; +} + +.landing-access-divider { display: grid; - width: 2.45rem; - height: 2.45rem; - flex: 0 0 2.45rem; + grid-template-columns: 1fr auto 1fr; + gap: 0.65rem; + align-items: center; + margin: 1.15rem 0; + color: #78908b; + font-size: 0.58rem; + font-weight: 650; +} + +.landing-access-divider::before, +.landing-access-divider::after { + height: 1px; + background: var(--landing-line-soft); + content: ''; +} + +.landing-guest-access { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.7rem; + align-items: start; + border: 1px solid #bfe0d9; + border-radius: 0.75rem; + padding: 0.78rem; + background: linear-gradient(135deg, #f2fcfa, #ecf8f5); +} + +.landing-guest-icon { + width: 2.15rem; + height: 2.15rem; + border-radius: 0.58rem; +} + +.landing-guest-access > div { + min-width: 0; +} + +.landing-guest-access strong { + font-family: "Manrope", sans-serif; + font-size: 0.76rem; +} + +.landing-guest-access p { + margin: 0.25rem 0 0; + color: var(--landing-muted); + font-size: 0.63rem; + line-height: 1.5; +} + +.landing-guest-access > button { + grid-column: 1 / -1; + display: inline-flex; + min-height: 2.7rem; + gap: 0.45rem; + align-items: center; + justify-content: space-between; + border: 1px solid #90c8bd; + border-radius: 0.6rem; + padding: 0.62rem 0.78rem; + background: #fff; + color: var(--landing-primary-strong); + font-size: 0.71rem; + font-weight: 750; + transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; +} + +.landing-guest-access > button:hover:not(:disabled) { + border-color: var(--landing-primary); + background: var(--landing-mint); + transform: translateY(-1px); +} + +.landing-guest-access > button:disabled { + cursor: wait; + opacity: 0.65; +} + +.landing-guest-access > button svg { + width: 0.95rem; + height: 0.95rem; +} + +.landing-session-note { + display: flex; + gap: 0.42rem; + align-items: center; + justify-content: center; + margin: 1.05rem 0 0; + color: #708984; + font-size: 0.57rem; + text-align: center; +} + +.landing-session-note svg { + width: 0.78rem; + height: 0.78rem; + color: var(--landing-primary); +} + +.landing-capabilities { + padding: clamp(4.75rem, 8vw, 7rem) clamp(1.25rem, 5vw, 5.5rem); + background: var(--landing-surface); +} + +.landing-section-heading { + width: min(52rem, 100%); + margin: 0 auto 2.7rem; + text-align: center; +} + +.landing-section-heading > p { + display: block; +} + +.landing-section-heading h2 { + margin: 0.7rem 0 0; + color: var(--landing-ink); + font-family: "Manrope", sans-serif; + font-size: clamp(2rem, 3.5vw, 3.25rem); + letter-spacing: -0.045em; + line-height: 1.08; + text-wrap: balance; +} + +.landing-section-heading > span { + display: block; + max-width: 40rem; + margin: 1rem auto 0; + color: var(--landing-muted); + font-size: 0.9rem; + line-height: 1.65; +} + +.landing-capability-grid { + display: grid; + width: min(78rem, 100%); + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin: 0 auto; +} + +.landing-capability { + position: relative; + overflow: hidden; + min-height: 18rem; + border: 1px solid var(--landing-line-soft); + border-radius: 1rem; + padding: clamp(1.35rem, 2.5vw, 1.85rem); + background: + linear-gradient(180deg, rgba(242, 250, 248, 0.58), transparent 55%), + #fff; + box-shadow: 0 18px 45px rgba(19, 63, 56, 0.07); + transition: border-color 170ms ease, transform 170ms ease, box-shadow 170ms ease; +} + +.landing-capability::after { + position: absolute; + right: -2.5rem; + bottom: -3rem; + width: 9rem; + height: 9rem; + border: 1px solid rgba(100, 177, 164, 0.18); + border-radius: 50%; + content: ''; +} + +.landing-capability:hover { + border-color: #acd1ca; + box-shadow: 0 23px 52px rgba(19, 63, 56, 0.1); + transform: translateY(-3px); +} + +.landing-capability-topline { + display: flex; + align-items: center; + justify-content: space-between; +} + +.landing-capability-icon { + display: grid; + width: 2.85rem; + height: 2.85rem; place-items: center; - border-radius: 4px; + border-radius: 0.78rem; background: var(--landing-mint); color: var(--landing-primary); } -.landing-login-icon svg { width: 1.15rem; } -.landing-login-heading h2 { margin: 0; font-family: "Manrope", sans-serif; font-size: 1.35rem; } -.landing-login-heading p { margin: 0.3rem 0 0; color: var(--landing-muted); font-size: 0.78rem; } -.landing-login-card form { display: grid; gap: 0.5rem; margin-top: 1.8rem; } -.landing-login-card label { margin-top: 0.55rem; color: #4b6862; font-size: 0.62rem; font-weight: 800; letter-spacing: 0.045em; text-transform: uppercase; } -.landing-login-card input { - width: 100%; - min-height: 2.9rem; - border: 1px solid #aac9c2; - border-radius: 4px; - padding: 0.7rem 0.8rem; - background: #edf9f6; - color: var(--landing-ink); - outline: none; +.landing-capability-icon svg { + width: 1.25rem; + height: 1.25rem; } -.landing-login-card input:focus { border-color: var(--landing-primary); box-shadow: 0 0 0 3px rgba(8, 114, 102, 0.14); } -.landing-login-card form button { - display: inline-flex; - min-height: 3rem; - gap: 0.5rem; +.landing-capability-topline small { + color: #9aafaa; + font-family: "Manrope", sans-serif; + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.1em; +} + +.landing-capability h3 { + max-width: 18rem; + margin: 2.3rem 0 0; + font-family: "Manrope", sans-serif; + font-size: 1.25rem; + letter-spacing: -0.025em; + line-height: 1.25; +} + +.landing-capability p { + position: relative; + z-index: 1; + margin: 1rem 0 0; + color: var(--landing-muted); + font-size: 0.82rem; + line-height: 1.72; +} + +.landing-workflow { + display: grid; + width: min(90rem, 100%); + grid-template-columns: minmax(0, 1.06fr) minmax(25rem, 0.94fr); + gap: clamp(2rem, 5vw, 5rem); align-items: center; - justify-content: center; - margin-top: 0.75rem; + margin: 0 auto; + padding: clamp(4.5rem, 8vw, 7.5rem) clamp(1.25rem, 5vw, 5.5rem); } -.landing-login-card form button svg { width: 1rem; } -.landing-login-card form button:disabled { cursor: not-allowed; opacity: 0.55; } -.landing-login-error { margin: 0.45rem 0 0; border-left: 3px solid #a33c39; padding: 0.55rem 0.7rem; background: #fff0ef; color: #7b2825; font-size: 0.72rem; } -.landing-session-note { display: flex; gap: 0.45rem; align-items: center; justify-content: center; margin: 1.45rem 0 0; border-top: 1px solid #c5d6d2; padding-top: 1rem; color: var(--landing-muted); font-size: 0.68rem; } -.landing-session-note svg { width: 0.9rem; } +.landing-workflow-visual { + position: relative; + min-height: 37rem; + overflow: hidden; + border: 1px solid rgba(115, 151, 144, 0.45); + border-radius: 1.2rem; + background: #0a2824; + box-shadow: 0 28px 70px rgba(10, 48, 42, 0.17); + isolation: isolate; +} -.landing-capabilities, -.landing-workflow { padding: clamp(4rem, 8vw, 7.5rem) clamp(1.25rem, 6vw, 6rem); } -.landing-capabilities { background: #fff; } -.landing-section-heading { max-width: 50rem; margin: 0 auto 3rem; text-align: center; } -.landing-section-heading > p { display: block; margin-bottom: 0.7rem; } -.landing-section-heading h2 { margin: 0; font-family: "Manrope", sans-serif; font-size: clamp(2rem, 3.2vw, 3.2rem); letter-spacing: -0.035em; } -.landing-capability-grid { display: grid; width: min(80rem, 100%); grid-template-columns: repeat(3, 1fr); gap: clamp(1.5rem, 4vw, 4rem); margin: 0 auto; } -.landing-capability { border-top: 2px solid #aed4cc; padding-top: 1.6rem; } -.landing-capability-icon { display: grid; width: 2.9rem; height: 2.9rem; place-items: center; border-radius: 4px; background: var(--landing-mint); color: var(--landing-primary); } -.landing-capability-secondary { border-top-color: #b8d4e3; } -.landing-capability-secondary .landing-capability-icon { background: #e7f2f8; color: var(--landing-blue); } -.landing-capability-attention { border-top-color: #e4c1aa; } -.landing-capability-attention .landing-capability-icon { background: #fff0e6; color: var(--landing-amber); } -.landing-capability-icon svg { width: 1.35rem; } -.landing-capability h3 { margin: 1.2rem 0 0.7rem; font-family: "Manrope", sans-serif; font-size: 1.1rem; } -.landing-capability p { min-height: 6.5rem; margin: 0; color: var(--landing-muted); font-size: 0.85rem; line-height: 1.7; } -.landing-capability > div { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 1rem; } -.landing-capability > div span { border-radius: 2px; padding: 0.3rem 0.42rem; background: #eaf7f4; color: #315b54; font-size: 0.58rem; font-weight: 800; text-transform: uppercase; } +.landing-workflow-map-image { + position: absolute; + z-index: -2; + inset: 0; + background-image: url('/landing-hero-belgium.webp'); + background-position: center; + background-size: cover; + filter: brightness(0.54) saturate(0.7) contrast(1.05); + transform: scale(1.02); +} -.landing-workflow { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(20rem, 0.85fr); gap: 1rem; background: #f0f5f3; } -.landing-workflow-map { position: relative; display: grid; min-height: 36rem; align-items: end; overflow: hidden; border-radius: 6px; background: #09201d; color: #fff; } -.landing-workflow-map-image { position: absolute; inset: 0; filter: brightness(0.48) saturate(0.78) hue-rotate(2deg); } -.landing-workflow-map::after { position: absolute; inset: 35% 0 0; background: linear-gradient(transparent, rgba(3, 17, 15, 0.94)); content: ''; } -.landing-workflow-map > div:last-child { position: relative; z-index: 2; padding: clamp(1.5rem, 4vw, 3rem); } -.landing-workflow-map p { margin: 0 0 0.6rem; color: #9ce5d8; font-size: 0.68rem; font-weight: 800; text-transform: uppercase; } -.landing-workflow-map h2 { max-width: 34rem; margin: 0; font-family: "Manrope", sans-serif; font-size: clamp(1.8rem, 3.5vw, 3.3rem); line-height: 1.05; } -.landing-workflow-map span { display: block; margin-top: 1rem; color: rgba(255, 255, 255, 0.74); font-size: 0.75rem; } -.landing-workflow-details { display: grid; border: 1px solid var(--landing-line); border-radius: 6px; background: #fff; } -.landing-workflow-details article { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 1rem; align-items: start; border-bottom: 1px solid #d7e2df; padding: clamp(1.4rem, 3vw, 2rem); } -.landing-workflow-details article:last-child { border-bottom: 0; } -.landing-workflow-details article > svg { width: 1.35rem; color: var(--landing-primary); } -.landing-workflow-details h3 { margin: 0; font-family: "Manrope", sans-serif; font-size: 1rem; } -.landing-workflow-details p { margin: 0.55rem 0 0; color: var(--landing-muted); font-size: 0.8rem; line-height: 1.6; } -.landing-footer { display: flex; gap: 2rem; align-items: center; justify-content: space-between; padding: 2rem clamp(1.25rem, 4vw, 4.5rem); background: #061815; color: #fff; } -.landing-footer strong { font-family: "Manrope", sans-serif; } -.landing-footer p { margin: 0.35rem 0 0; color: rgba(255, 255, 255, 0.58); font-size: 0.7rem; } -.landing-footer-ownership { display: grid; gap: 0.65rem; justify-items: end; text-align: right; } -.landing-footer .itworx-signature { display: flex; gap: 0.7rem; align-items: center; } -.landing-footer .itworx-signature > span { color: rgba(255, 255, 255, 0.48); font-size: 0.58rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; } -.landing-footer .itworx-signature img { width: 7.7rem; height: auto; filter: drop-shadow(0 3px 8px rgba(0, 0, 0, 0.25)); } +.landing-workflow-visual::before { + position: absolute; + z-index: -1; + inset: 0; + background: + linear-gradient(180deg, rgba(2, 30, 27, 0.12), rgba(3, 27, 24, 0.92)), + radial-gradient(circle at 65% 35%, rgba(56, 198, 171, 0.17), transparent 12rem); + content: ''; +} + +.landing-workflow-visual::after { + position: absolute; + top: 20%; + right: 17%; + width: 11rem; + height: 8rem; + border: 1px solid rgba(93, 226, 201, 0.66); + border-radius: 48% 52% 61% 39% / 41% 45% 55% 59%; + background: rgba(46, 187, 161, 0.09); + box-shadow: 0 0 0 12px rgba(46, 187, 161, 0.025); + content: ''; + transform: rotate(7deg); +} + +.landing-workflow-overlay { + position: absolute; + right: 0; + bottom: 0; + left: 0; + padding: clamp(1.7rem, 4vw, 3rem); + color: #fff; +} + +.landing-workflow-overlay > p { + margin: 0 0 0.7rem; + color: #7be6d1; + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.landing-workflow-overlay h2 { + max-width: 33rem; + margin: 0; + font-family: "Manrope", sans-serif; + font-size: clamp(2rem, 3.5vw, 3.2rem); + letter-spacing: -0.045em; + line-height: 1.06; + text-wrap: balance; +} + +.landing-workflow-overlay > span { + display: block; + max-width: 32rem; + margin-top: 0.9rem; + color: rgba(255, 255, 255, 0.66); + font-size: 0.75rem; + line-height: 1.55; +} + +.landing-workflow-floating { + position: absolute; + top: 9%; + left: 7%; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.65rem; + align-items: center; + border: 1px solid rgba(202, 244, 236, 0.28); + border-radius: 0.72rem; + padding: 0.68rem 0.78rem; + background: rgba(4, 38, 34, 0.76); + color: #fff; + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.23); + backdrop-filter: blur(10px); +} + +.landing-workflow-floating > svg { + width: 1rem; + height: 1rem; + color: #7be6d1; +} + +.landing-workflow-floating > span { + display: grid; + gap: 0.12rem; +} + +.landing-workflow-floating small { + color: rgba(255, 255, 255, 0.61); + font-size: 0.55rem; +} + +.landing-workflow-floating strong { + font-size: 0.67rem; +} + +.landing-workflow-content > h2 { + margin: 0.7rem 0 0; + font-family: "Manrope", sans-serif; + font-size: clamp(1.9rem, 3vw, 2.85rem); + letter-spacing: -0.04em; + line-height: 1.1; + text-wrap: balance; +} + +.landing-workflow-steps { + display: grid; + gap: 0; + margin-top: 2rem; + border-top: 1px solid var(--landing-line); +} + +.landing-workflow-steps article { + display: grid; + grid-template-columns: 2.35rem minmax(0, 1fr); + gap: 0.95rem; + align-items: start; + border-bottom: 1px solid var(--landing-line); + padding: 1.1rem 0; +} + +.landing-workflow-steps article > span { + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + border-radius: 0.55rem; + background: var(--landing-mint); + color: var(--landing-primary-strong); + font-family: "Manrope", sans-serif; + font-size: 0.61rem; + font-weight: 800; +} + +.landing-workflow-steps h3 { + margin: 0; + font-family: "Manrope", sans-serif; + font-size: 0.95rem; +} + +.landing-workflow-steps p { + margin: 0.28rem 0 0; + color: var(--landing-muted); + font-size: 0.72rem; + line-height: 1.55; +} + +.landing-quality-callout { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.9rem; + align-items: start; + margin-top: 1.35rem; + border: 1px solid #c2ddd7; + border-radius: 0.8rem; + padding: 1rem; + background: linear-gradient(135deg, #effbf8, #f7fcfb); + scroll-margin-top: 6rem; +} + +.landing-quality-callout > svg { + width: 1.15rem; + height: 1.15rem; + margin-top: 0.08rem; + color: var(--landing-primary); +} + +.landing-quality-callout strong { + font-family: "Manrope", sans-serif; + font-size: 0.8rem; +} + +.landing-quality-callout p { + margin: 0.35rem 0 0; + color: var(--landing-muted); + font-size: 0.68rem; + line-height: 1.55; +} + +.landing-footer { + display: flex; + gap: 2rem; + align-items: center; + justify-content: space-between; + border-top: 1px solid rgba(134, 190, 179, 0.12); + padding: 2.3rem clamp(1.25rem, 4vw, 4.5rem); + background: + radial-gradient(circle at 85% 0%, rgba(26, 118, 103, 0.22), transparent 18rem), + #051b18; + color: #fff; +} + +.landing-footer-brand { + display: flex; + gap: 0.85rem; + align-items: center; +} + +.landing-footer-mark { + width: 2.25rem; + height: 2.25rem; + border-radius: 0.65rem; +} + +.landing-footer strong { + font-family: "Manrope", sans-serif; + font-size: 0.82rem; +} + +.landing-footer p { + margin: 0.3rem 0 0; + color: rgba(255, 255, 255, 0.55); + font-size: 0.62rem; +} + +.landing-footer-ownership { + display: grid; + gap: 0.55rem; + justify-items: end; + text-align: right; +} + +.landing-footer .itworx-signature { + display: flex; + gap: 0.7rem; + align-items: center; +} + +.landing-footer .itworx-signature > span { + color: rgba(255, 255, 255, 0.45); + font-size: 0.53rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.landing-footer .itworx-signature img { + width: 7.8rem; + height: auto; + filter: drop-shadow(0 3px 8px rgba(0, 0, 0, 0.25)); +} .landing-auth-loading { display: grid; min-height: 100dvh; place-items: center; - background: #eef8f5; + background: + radial-gradient(circle at 50% 40%, rgba(142, 220, 206, 0.25), transparent 18rem), + #eef8f5; color: var(--landing-primary-strong); font-family: "Manrope", sans-serif; } -.landing-auth-loading > div { display: grid; gap: 0.75rem; justify-items: center; } -.landing-auth-loading span { width: 2rem; height: 2rem; border: 3px solid #b8dcd5; border-top-color: var(--landing-primary); border-radius: 50%; animation: landing-spin 0.8s linear infinite; } -@keyframes landing-spin { to { transform: rotate(360deg); } } - -@media (max-width: 900px) { - .landing-header { grid-template-columns: auto auto auto; gap: 0.7rem; } - .landing-menu-toggle { display: grid; width: 2.35rem; height: 2.35rem; place-items: center; border: 1px solid var(--landing-line); border-radius: 4px; background: #fff; color: var(--landing-ink); } - .landing-menu-toggle svg { width: 1.15rem; } - .landing-nav { position: absolute; top: 100%; right: 0; left: 0; display: none; flex-direction: column; align-items: stretch; border-bottom: 1px solid var(--landing-line); padding: 0.75rem 1rem; background: #fff; } - .landing-nav-open { display: flex; } - .landing-hero { min-height: auto; } - .landing-hero-content { grid-template-columns: 1fr; gap: 3rem; } - .landing-hero-copy { max-width: 44rem; } - .landing-login-card { width: min(32rem, 100%); } - .landing-capability-grid { grid-template-columns: 1fr; } - .landing-capability p { min-height: 0; } - .landing-workflow { grid-template-columns: 1fr; } +.landing-auth-loading > div { + display: grid; + gap: 0.8rem; + justify-items: center; } -@media (max-width: 560px) { - .landing-header { padding-inline: 0.75rem; } - .landing-brand span:last-child { display: none; } - .landing-hero { padding: 6.3rem 1rem 3rem; } - .landing-hero h1 { font-size: clamp(2.8rem, 16vw, 4.2rem); } - .landing-hero-actions { align-items: stretch; flex-direction: column; } - .landing-trust-strip { display: grid; grid-template-columns: 1fr; gap: 0.75rem; } - .landing-trust-strip > div, - .landing-trust-strip > div:first-child { border-left: 2px solid #9db8b2; padding: 0 0 0 0.75rem; } +.landing-auth-loading span { + width: 2.15rem; + height: 2.15rem; + border: 3px solid #b8dcd5; + border-top-color: var(--landing-primary); + border-radius: 50%; + animation: landing-spin 0.8s linear infinite; +} + +@keyframes landing-spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 1050px) { + .landing-hero-content { + grid-template-columns: minmax(0, 1fr) minmax(23rem, 0.82fr); + gap: 2.5rem; + } + + .landing-hero h1 { + font-size: clamp(3rem, 5.5vw, 4.35rem); + } + + .landing-proof-row { + grid-template-columns: 1fr; + gap: 0.55rem; + } + + .landing-proof-row > div, + .landing-proof-row > div:first-child { + border-left: 2px solid rgba(99, 139, 132, 0.42); + padding-left: 0.7rem; + } + + .landing-proof-row strong, + .landing-proof-row small { + white-space: normal; + } +} + +@media (max-width: 900px) { + .landing-header { + grid-template-columns: auto auto auto; + gap: 0.65rem; + } + + .landing-menu-toggle { + display: grid; + width: 2.45rem; + height: 2.45rem; + place-items: center; + border: 1px solid var(--landing-line); + border-radius: 0.62rem; + background: rgba(255, 255, 255, 0.9); + color: var(--landing-ink); + } + + .landing-menu-toggle svg { + width: 1.08rem; + height: 1.08rem; + } + + .landing-nav { + position: absolute; + top: calc(100% + 0.35rem); + right: 1rem; + left: 1rem; + display: none; + flex-direction: column; + gap: 0; + align-items: stretch; + overflow: hidden; + border: 1px solid var(--landing-line); + border-radius: 0.8rem; + padding: 0.35rem; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 18px 45px rgba(10, 55, 48, 0.15); + } + + .landing-nav-open { + display: flex; + } + + .landing-nav a { + border-radius: 0.55rem; + padding: 0.75rem 0.8rem; + } + + .landing-nav a:hover, + .landing-nav a:focus-visible { + background: #eff8f6; + } + + .landing-nav a::after { + display: none; + } + + .landing-hero { + min-height: auto; + padding-top: 7rem; + } + + .landing-hero-content { + width: min(44rem, 100%); + grid-template-columns: 1fr; + gap: 2.5rem; + } + + .landing-hero-copy { + max-width: none; + } + + .landing-proof-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .landing-proof-row > div, + .landing-proof-row > div:first-child { + border-left-width: 1px; + padding-left: 0.65rem; + } + + .landing-proof-row > div:first-child { + border-left: 0; + padding-left: 0; + } + + .landing-access-card { + width: min(34rem, 100%); + margin-inline: auto; + } + + .landing-capability-grid { + grid-template-columns: 1fr; + } + + .landing-capability { + min-height: 0; + } + + .landing-capability h3 { + margin-top: 1.5rem; + } + + .landing-workflow { + width: min(46rem, 100%); + grid-template-columns: 1fr; + } + + .landing-workflow-visual { + min-height: 31rem; + } +} + +@media (max-width: 640px) { + .landing-header { + min-height: 4.1rem; + grid-template-columns: minmax(0, 1fr) auto auto; + padding-inline: 0.75rem; + } + + .landing-brand-mark { + width: 2rem; + height: 2rem; + } + + .landing-brand-copy strong { + font-size: 0.85rem; + } + + .landing-brand-copy small { + display: none; + } + + .landing-header-actions { + gap: 0.35rem; + } + + .landing-header-guest { + display: none; + } + + .landing-header-login { + min-height: 2.35rem; + padding: 0.45rem 0.75rem; + font-size: 0.69rem; + } + + .landing-menu-toggle { + width: 2.35rem; + height: 2.35rem; + } + + .landing-hero { + padding: 6.25rem 1rem 3.75rem; + } + + .landing-hero-orbit-one { + right: -24rem; + } + + .landing-kicker { + width: fit-content; + max-width: 100%; + padding: 0.42rem 0.65rem; + font-size: 0.58rem; + line-height: 1.35; + } + + .landing-hero h1 { + margin-top: 1.2rem; + font-size: clamp(2.65rem, 13.5vw, 3.75rem); + letter-spacing: -0.055em; + } + + .landing-lead { + margin-top: 1.25rem; + font-size: 0.92rem; + line-height: 1.65; + } + + .landing-hero-actions { + align-items: stretch; + flex-direction: column; + margin-top: 1.55rem; + } + + .landing-primary-action, + .landing-secondary-action { + width: 100%; + min-height: 3.15rem; + } + + .landing-proof-row { + grid-template-columns: 1fr; + gap: 0.48rem; + margin-top: 1.6rem; + } + + .landing-proof-row > div, + .landing-proof-row > div:first-child { + border-left: 2px solid rgba(99, 139, 132, 0.42); + padding: 0.12rem 0 0.12rem 0.65rem; + } + + .landing-access-preview { + min-height: 11.5rem; + } + + .landing-preview-focus { + margin-left: 0; + } + + .landing-preview-metrics small { + overflow: hidden; + text-overflow: ellipsis; + } + + .landing-access-body { + padding: 1.15rem; + } + + .landing-access-heading { + grid-template-columns: 2.25rem minmax(0, 1fr); + gap: 0.65rem; + } + + .landing-login-icon { + width: 2.25rem; + height: 2.25rem; + } + + .landing-access-heading h2 { + font-size: 1.12rem; + } + + .landing-access-heading > div > span { + font-size: 0.65rem; + } + .landing-capabilities, - .landing-workflow { padding: 3.5rem 1rem; } - .landing-workflow-map { min-height: 28rem; } - .landing-footer { align-items: flex-start; flex-direction: column; } - .landing-footer-ownership { justify-items: start; text-align: left; } + .landing-workflow { + padding: 3.75rem 1rem; + } + + .landing-section-heading { + margin-bottom: 2rem; + text-align: left; + } + + .landing-section-heading > p { + display: inline-flex; + } + + .landing-section-heading h2 { + font-size: 2.05rem; + } + + .landing-section-heading > span { + margin-left: 0; + font-size: 0.82rem; + } + + .landing-capability { + padding: 1.25rem; + } + + .landing-workflow { + gap: 2.5rem; + } + + .landing-workflow-visual { + min-height: 28rem; + } + + .landing-workflow-visual::after { + top: 20%; + right: 8%; + width: 8rem; + height: 6rem; + } + + .landing-workflow-floating { + top: 1rem; + right: 1rem; + left: 1rem; + } + + .landing-workflow-overlay { + padding: 1.35rem; + } + + .landing-workflow-overlay h2 { + font-size: 2rem; + } + + .landing-workflow-content > h2 { + font-size: 2rem; + } + + .landing-footer { + align-items: flex-start; + flex-direction: column; + padding: 2rem 1rem; + } + + .landing-footer-ownership { + justify-items: start; + text-align: left; + } +} + +@media (max-width: 390px) { + .landing-brand-copy strong { + font-size: 0.78rem; + } + + .landing-preview-toolbar span:last-child { + display: none; + } + + .landing-preview-metrics > span { + padding-inline: 0.45rem; + } + + .landing-preview-metrics small { + font-size: 0.46rem; + } } @media (prefers-reduced-motion: reduce) { + html:has(body.landing-body) { + scroll-behavior: auto; + } + .landing-page *, .landing-page *::before, - .landing-page *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } + .landing-page *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } } diff --git a/frontend/src/styles/professionalization.css b/frontend/src/styles/professionalization.css new file mode 100644 index 00000000..c677e7d3 --- /dev/null +++ b/frontend/src/styles/professionalization.css @@ -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; + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 14bc4c62..9765b614 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -9,7 +9,7 @@ "noEmit": true, "strict": true, "esModuleInterop": true, - "types": ["vite/client"], + "types": ["vite/client", "geojson"], "skipLibCheck": true, "resolveJsonModule": true, "isolatedModules": true,