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

This commit is contained in:
Codex
2026-07-22 20:10:21 +02:00
parent 36d137e224
commit 115f9850a7
27 changed files with 1448 additions and 8 deletions
+10
View File
@@ -9,6 +9,16 @@
## Unreleased - Post-V1 capability completion (2026-07-19) ## Unreleased - Post-V1 capability completion (2026-07-19)
- 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
a direct transition into the existing map-first workbench.
- Added an optional single-operator login gate with PBKDF2-SHA256 password
verification, signed HttpOnly/SameSite sessions, expiry, brute-force
throttling, logout and API middleware enforcement. Plaintext credentials are
never committed or returned to the browser; trusted direct-loopback operator
scripts remain available without introducing multi-user persistence.
- Added the official WALOUS 2018 GeoTIFF as a third live-provisioned Walloon - Added the official WALOUS 2018 GeoTIFF as a third live-provisioned Walloon
land-cover epoch. Its stable SPW artifact, archive/raster checksums, EPSG:3812 land-cover epoch. Its stable SPW artifact, archive/raster checksums, EPSG:3812
identity and published stacked class codes are validated fail-closed. The identity and published stacked class codes are validated fail-closed. The
+1 -1
View File
@@ -1 +1 @@
__all__ = ["analysis", "areas", "assistant", "datasets", "health", "projects", "exports", "jobs", "external", "qa", "temporal"] __all__ = ["analysis", "areas", "assistant", "auth", "datasets", "health", "projects", "exports", "jobs", "external", "qa", "temporal"]
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Request, Response, status
from app.core.config import get_settings
from app.core.errors import AppError
from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope
from app.services.auth_service import AuthService
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)
return AuthSession(
authentication_required=True,
authenticated=True,
username=principal.username,
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
)
@router.get("/session", response_model=AuthSessionEnvelope)
def session(request: Request) -> AuthSessionEnvelope:
return AuthSessionEnvelope(data=_session_payload(request))
@router.post("/login", response_model=AuthSessionEnvelope)
def login(payload: AuthLoginRequest, request: Request, response: Response) -> AuthSessionEnvelope:
settings = get_settings()
if not settings.auth_enabled:
raise AppError(
code="AUTHENTICATION_DISABLED",
message="Operator authentication is not enabled on this runtime",
status_code=status.HTTP_409_CONFLICT,
)
client_host = request.client.host if request.client else "unknown"
throttle_key = f"{client_host}:{payload.username.casefold()}"
retry_after = AuthService.retry_after_seconds(throttle_key)
if retry_after:
raise AppError(
code="LOGIN_RATE_LIMITED",
message="Te veel mislukte aanmeldpogingen. Probeer later opnieuw.",
details={"retry_after_seconds": retry_after},
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
)
if not AuthService.credentials_match(payload.username, payload.password, settings):
AuthService.record_failure(throttle_key)
raise AppError(
code="INVALID_CREDENTIALS",
message="Gebruikersnaam of wachtwoord is onjuist.",
status_code=status.HTTP_401_UNAUTHORIZED,
)
AuthService.clear_failures(throttle_key)
token = AuthService.create_session_token(payload.username, settings)
principal = AuthService.verify_session_token(token, settings)
if principal is None: # pragma: no cover - defensive invariant
raise AppError(
code="SESSION_CREATION_FAILED",
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,
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),
)
)
@router.post("/logout", response_model=AuthSessionEnvelope)
def logout(response: Response) -> AuthSessionEnvelope:
response.delete_cookie(key=COOKIE_NAME, path="/", httponly=True, samesite="strict")
return AuthSessionEnvelope(
data=AuthSession(authentication_required=True, authenticated=False)
)
+23 -1
View File
@@ -1,4 +1,4 @@
from pydantic import Field, field_validator from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -18,6 +18,16 @@ class Settings(BaseSettings):
build_sha: str | None = Field(default=None, validation_alias="GEOINTEL_BUILD_SHA") build_sha: str | None = Field(default=None, validation_alias="GEOINTEL_BUILD_SHA")
build_time: str | None = Field(default=None, validation_alias="GEOINTEL_BUILD_TIME") build_time: str | None = Field(default=None, validation_alias="GEOINTEL_BUILD_TIME")
api_prefix: str = Field(default="/api/v1", validation_alias="GEOINTEL_API_PREFIX") api_prefix: str = Field(default="/api/v1", validation_alias="GEOINTEL_API_PREFIX")
auth_enabled: bool = Field(default=False, validation_alias="GEOINTEL_AUTH_ENABLED")
auth_username: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_USERNAME")
auth_password_hash: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_PASSWORD_HASH")
auth_session_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_SESSION_SECRET")
auth_session_ttl_seconds: int = Field(
default=43_200,
ge=900,
le=604_800,
validation_alias="GEOINTEL_AUTH_SESSION_TTL_SECONDS",
)
database_url: str = Field( database_url: str = Field(
default="postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1", default="postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1",
validation_alias="DATABASE_URL", validation_alias="DATABASE_URL",
@@ -380,6 +390,18 @@ class Settings(BaseSettings):
raise ValueError("OLLAMA_BASE_URL must use http or https") raise ValueError("OLLAMA_BASE_URL must use http or https")
return normalized return normalized
@model_validator(mode="after")
def validate_operator_auth(self) -> "Settings":
if not self.auth_enabled:
return self
if not (self.auth_username or "").strip():
raise ValueError("GEOINTEL_AUTH_USERNAME is required when authentication is enabled")
if not (self.auth_password_hash or "").startswith("pbkdf2_sha256$"):
raise ValueError("GEOINTEL_AUTH_PASSWORD_HASH must be a PBKDF2-SHA256 hash")
if len(self.auth_session_secret or "") < 32:
raise ValueError("GEOINTEL_AUTH_SESSION_SECRET must contain at least 32 characters")
return self
def get_settings() -> Settings: def get_settings() -> Settings:
return Settings() return Settings()
+36 -1
View File
@@ -11,13 +11,14 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.api.routes import analysis, areas, assistant, datasets, demo, detection, exports, external, health, jobs, projects, qa, quality_checks, segmentation, selection_partitions, temporal from app.api.routes import analysis, areas, assistant, auth, datasets, demo, detection, exports, external, health, jobs, projects, qa, quality_checks, segmentation, selection_partitions, temporal
from app.core.config import get_settings from app.core.config import get_settings
from app.core.errors import AppError from app.core.errors import AppError
from app.core.logging import configure_logging from app.core.logging import configure_logging
from app.core.request_context import reset_request_id, set_request_id from app.core.request_context import reset_request_id, set_request_id
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.services.runtime_reconciliation_service import RuntimeReconciliationService from app.services.runtime_reconciliation_service import RuntimeReconciliationService
from app.services.auth_service import AuthService
logger = logging.getLogger("geointel") logger = logging.getLogger("geointel")
@@ -79,6 +80,7 @@ def create_app() -> FastAPI:
) )
app.include_router(health.router) app.include_router(health.router)
app.include_router(auth.router, prefix=settings.api_prefix)
app.include_router(analysis.router, prefix=settings.api_prefix) app.include_router(analysis.router, prefix=settings.api_prefix)
app.include_router(projects.router, prefix=settings.api_prefix) app.include_router(projects.router, prefix=settings.api_prefix)
app.include_router(areas.router, prefix=settings.api_prefix) app.include_router(areas.router, prefix=settings.api_prefix)
@@ -128,6 +130,39 @@ def create_app() -> FastAPI:
) )
response.headers["x-request-id"] = request_id response.headers["x-request-id"] = request_id
return response return response
public_auth_paths = {
f"{settings.api_prefix}/auth/session",
f"{settings.api_prefix}/auth/login",
f"{settings.api_prefix}/auth/logout",
}
direct_loopback_request = (
request.client is not None
and request.client.host in {"127.0.0.1", "::1"}
and not request.headers.get("x-real-ip")
and not request.headers.get("x-forwarded-for")
)
if (
settings.auth_enabled
and raw_path.startswith(f"{settings.api_prefix}/")
and raw_path not in public_auth_paths
and not direct_loopback_request
):
principal = AuthService.verify_session_token(
request.cookies.get(auth.COOKIE_NAME),
settings,
)
if principal is None:
response = JSONResponse(
status_code=401,
content=_to_error_payload(
"AUTHENTICATION_REQUIRED",
"Meld u aan om de GeoIntel API te gebruiken.",
request_id=request_id,
),
)
response.headers["x-request-id"] = request_id
return response
request.state.auth_principal = principal
response = await call_next(request) response = await call_next(request)
response.headers["x-request-id"] = request_id response.headers["x-request-id"] = request_id
logger.info( logger.info(
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.common import Envelope
class AuthLoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=128)
password: str = Field(min_length=1, max_length=1024)
class AuthSession(BaseModel):
authentication_required: bool
authenticated: bool
username: str | None = None
expires_at: datetime | None = None
class AuthSessionEnvelope(Envelope[AuthSession]):
pass
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import threading
import time
from collections import deque
from dataclasses import dataclass
from app.core.config import Settings
@dataclass(frozen=True)
class AuthPrincipal:
username: str
expires_at: int
class AuthService:
HASH_NAME = "pbkdf2_sha256"
HASH_ITERATIONS = 600_000
MAX_FAILURES = 5
FAILURE_WINDOW_SECONDS = 300
_failures: dict[str, deque[float]] = {}
_failure_lock = threading.Lock()
@staticmethod
def _b64_encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
@staticmethod
def _b64_decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
@classmethod
def hash_password(
cls,
password: str,
*,
salt: bytes | None = None,
iterations: int | None = None,
) -> str:
resolved_salt = salt or secrets.token_bytes(18)
resolved_iterations = iterations or cls.HASH_ITERATIONS
digest = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
resolved_salt,
resolved_iterations,
)
return "$".join(
(
cls.HASH_NAME,
str(resolved_iterations),
cls._b64_encode(resolved_salt),
cls._b64_encode(digest),
)
)
@classmethod
def verify_password(cls, password: str, encoded: str) -> bool:
try:
algorithm, iterations_raw, salt_raw, expected_raw = encoded.split("$", 3)
if algorithm != cls.HASH_NAME:
return False
iterations = int(iterations_raw)
if iterations < 100_000 or iterations > 2_000_000:
return False
salt = cls._b64_decode(salt_raw)
expected = cls._b64_decode(expected_raw)
actual = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
iterations,
)
return hmac.compare_digest(actual, expected)
except (TypeError, ValueError):
return False
@classmethod
def credentials_match(cls, username: str, password: str, settings: Settings) -> bool:
expected_username = settings.auth_username or ""
expected_password_hash = settings.auth_password_hash or ""
username_matches = hmac.compare_digest(
username.encode("utf-8"),
expected_username.encode("utf-8"),
)
password_matches = cls.verify_password(password, expected_password_hash)
return username_matches and password_matches
@classmethod
def create_session_token(cls, username: str, settings: Settings, *, now: int | None = None) -> str:
issued_at = int(time.time() if now is None else now)
payload = {
"exp": issued_at + settings.auth_session_ttl_seconds,
"iat": issued_at,
"jti": secrets.token_urlsafe(12),
"sub": username,
"v": 1,
}
encoded_payload = cls._b64_encode(
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
)
signature = hmac.new(
(settings.auth_session_secret or "").encode("utf-8"),
encoded_payload.encode("ascii"),
hashlib.sha256,
).digest()
return f"{encoded_payload}.{cls._b64_encode(signature)}"
@classmethod
def verify_session_token(
cls,
token: str | None,
settings: Settings,
*,
now: int | None = None,
) -> AuthPrincipal | None:
if not token:
return None
try:
encoded_payload, encoded_signature = token.split(".", 1)
expected_signature = hmac.new(
(settings.auth_session_secret or "").encode("utf-8"),
encoded_payload.encode("ascii"),
hashlib.sha256,
).digest()
supplied_signature = cls._b64_decode(encoded_signature)
if not hmac.compare_digest(expected_signature, supplied_signature):
return None
payload = json.loads(cls._b64_decode(encoded_payload))
username = str(payload.get("sub") or "")
expires_at = int(payload.get("exp") or 0)
issued_at = int(payload.get("iat") or 0)
current = int(time.time() if now is None else now)
if payload.get("v") != 1 or username != settings.auth_username:
return None
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:
return None
return AuthPrincipal(username=username, expires_at=expires_at)
except (TypeError, ValueError, json.JSONDecodeError, UnicodeDecodeError):
return None
@classmethod
def retry_after_seconds(cls, key: str, *, now: float | None = None) -> int:
current = time.monotonic() if now is None else now
with cls._failure_lock:
attempts = cls._failures.setdefault(key, deque())
while attempts and current - attempts[0] >= cls.FAILURE_WINDOW_SECONDS:
attempts.popleft()
if len(attempts) < cls.MAX_FAILURES:
if not attempts:
cls._failures.pop(key, None)
return 0
return max(1, int(cls.FAILURE_WINDOW_SECONDS - (current - attempts[0])))
@classmethod
def record_failure(cls, key: str, *, now: float | None = None) -> None:
current = time.monotonic() if now is None else now
with cls._failure_lock:
attempts = cls._failures.setdefault(key, deque())
while attempts and current - attempts[0] >= cls.FAILURE_WINDOW_SECONDS:
attempts.popleft()
attempts.append(current)
@classmethod
def clear_failures(cls, key: str) -> None:
with cls._failure_lock:
cls._failures.pop(key, None)
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import create_app
from app.services.auth_service import AuthService
def auth_client(monkeypatch) -> TestClient:
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")
return TestClient(create_app())
def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) -> None:
client = auth_client(monkeypatch)
session = client.get("/api/v1/auth/session")
protected = client.get("/api/v1/protected-probe")
health = client.get("/health/live")
assert session.status_code == 200
assert session.json()["data"] == {
"authentication_required": True,
"authenticated": False,
"username": None,
"expires_at": None,
}
assert protected.status_code == 401
assert protected.json()["error"] == "AUTHENTICATION_REQUIRED"
assert health.status_code == 200
def test_login_uses_http_only_session_cookie_and_logout_revokes_browser_access(monkeypatch) -> None:
client = auth_client(monkeypatch)
invalid = client.post(
"/api/v1/auth/login",
json={"username": "operator", "password": "wrong"},
)
login = client.post(
"/api/v1/auth/login",
json={"username": "operator", "password": "correct horse battery staple"},
)
authenticated = client.get("/api/v1/auth/session")
protected_after_login = client.get("/api/v1/protected-probe")
logout = client.post("/api/v1/auth/logout")
protected_after_logout = client.get("/api/v1/protected-probe")
assert invalid.status_code == 401
assert invalid.json()["error"] == "INVALID_CREDENTIALS"
assert login.status_code == 200
assert login.json()["data"]["username"] == "operator"
cookie = login.headers["set-cookie"].lower()
assert "httponly" in cookie
assert "samesite=strict" in cookie
assert authenticated.json()["data"]["authenticated"] is True
assert protected_after_login.status_code == 404
assert logout.status_code == 200
assert protected_after_logout.status_code == 401
def test_password_hash_and_session_signatures_fail_closed(monkeypatch) -> None:
client = auth_client(monkeypatch)
login = client.post(
"/api/v1/auth/login",
json={"username": "operator", "password": "correct horse battery staple"},
)
token = login.cookies.get("geointel_session")
assert token
client.cookies.set("geointel_session", f"{token}tampered")
session = client.get("/api/v1/auth/session")
assert session.status_code == 200
assert session.json()["data"]["authenticated"] is False
def test_unraid_runtime_carries_only_hashed_operator_credentials() -> None:
root = Path(__file__).resolve().parents[2]
runner = (root / "deploy/unraid/run-dockerman-container.sh").read_text(encoding="utf-8")
example = (root / "deploy/unraid/geointel.env.example").read_text(encoding="utf-8")
browser_smoke = (root / "scripts/verify_browser_runtime.sh").read_text(encoding="utf-8")
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 "/api/v1/auth/session" in browser_smoke
+7
View File
@@ -76,6 +76,13 @@ bash deploy/unraid/deploy-release.sh
``` ```
Set `GEOINTEL_POSTGRES_PASSWORD` to a unique value before that first start. Set `GEOINTEL_POSTGRES_PASSWORD` to a unique value before that first start.
For a browser login, set `GEOINTEL_AUTH_ENABLED=true`, configure one exact
`GEOINTEL_AUTH_USERNAME`, a `pbkdf2_sha256` password hash and an independent
random `GEOINTEL_AUTH_SESSION_SECRET` of at least 32 characters. The plaintext
password is never stored in the repository or container configuration. Browser
API calls require the signed HttpOnly session cookie; direct loopback calls to
the backend remain available to trusted in-container operator scripts.
Production startup fails before replacing the active container when the Production startup fails before replacing the active container when the
password is empty or one of the documented defaults. password is empty or one of the documented defaults.
@@ -32,6 +32,11 @@
<Config Name="Postgres Password" Target="GEOINTEL_POSTGRES_PASSWORD" Default="change-me-before-shared-use" Mode="" Description="Embedded PostGIS database password. Change before shared use." Type="Variable" Display="advanced" Required="true" Mask="true">change-me-before-shared-use</Config> <Config Name="Postgres Password" Target="GEOINTEL_POSTGRES_PASSWORD" Default="change-me-before-shared-use" Mode="" Description="Embedded PostGIS database password. Change before shared use." Type="Variable" Display="advanced" Required="true" Mask="true">change-me-before-shared-use</Config>
<Config Name="CORS Origins" Target="GEOINTEL_CORS_ORIGINS" Default="http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202" Mode="" Description="Comma-separated browser origins allowed to call the backend directly." Type="Variable" Display="advanced" Required="false" Mask="false">http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202</Config> <Config Name="CORS Origins" Target="GEOINTEL_CORS_ORIGINS" Default="http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202" Mode="" Description="Comma-separated browser origins allowed to call the backend directly." Type="Variable" Display="advanced" Required="false" Mask="false">http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202</Config>
<Config Name="Max Upload MB" Target="GEOINTEL_MAX_UPLOAD_MB" Default="500" Mode="" Description="Maximum upload size in MiB enforced consistently by nginx and the backend (1-2048)." Type="Variable" Display="advanced" Required="true" Mask="false">500</Config> <Config Name="Max Upload MB" Target="GEOINTEL_MAX_UPLOAD_MB" Default="500" Mode="" Description="Maximum upload size in MiB enforced consistently by nginx and the backend (1-2048)." Type="Variable" Display="advanced" Required="true" Mask="false">500</Config>
<Config Name="Operator Login Enabled" Target="GEOINTEL_AUTH_ENABLED" Default="false" Mode="" Description="Require the single configured operator login before the browser may access workbench APIs." Type="Variable" Display="advanced" Required="true" Mask="false">false</Config>
<Config Name="Operator Username" Target="GEOINTEL_AUTH_USERNAME" Default="" Mode="" Description="Exact username for the single operator account." Type="Variable" Display="advanced" Required="false" Mask="false"></Config>
<Config Name="Operator Password Hash" Target="GEOINTEL_AUTH_PASSWORD_HASH" Default="" Mode="" Description="PBKDF2-SHA256 password hash. Never enter a plaintext password." Type="Variable" Display="advanced" Required="false" Mask="true"></Config>
<Config Name="Operator Session Secret" Target="GEOINTEL_AUTH_SESSION_SECRET" Default="" Mode="" Description="Random secret of at least 32 characters used only to sign browser sessions." Type="Variable" Display="advanced" Required="false" Mask="true"></Config>
<Config Name="Operator Session TTL" Target="GEOINTEL_AUTH_SESSION_TTL_SECONDS" Default="43200" Mode="" Description="Session lifetime in seconds (900-604800)." Type="Variable" Display="advanced" Required="true" Mask="false">43200</Config>
<Config Name="Official Orthophoto Acquisition" Target="ORTHOPHOTO_ENABLED" Default="true" Mode="" Description="Allow explicit bounded map selections to request the official Digitaal Vlaanderen orthophoto WMS." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config> <Config Name="Official Orthophoto Acquisition" Target="ORTHOPHOTO_ENABLED" Default="true" Mode="" Description="Allow explicit bounded map selections to request the official Digitaal Vlaanderen orthophoto WMS." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
<Config Name="Orthophoto WMS URL" Target="ORTHOPHOTO_WMS_URL" Default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms" Mode="" Description="Official Digitaal Vlaanderen most-recent winter orthophoto WMS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geo.api.vlaanderen.be/OMWRGBMRVL/wms</Config> <Config Name="Orthophoto WMS URL" Target="ORTHOPHOTO_WMS_URL" Default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms" Mode="" Description="Official Digitaal Vlaanderen most-recent winter orthophoto WMS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geo.api.vlaanderen.be/OMWRGBMRVL/wms</Config>
<Config Name="Orthophoto WMS Layer" Target="ORTHOPHOTO_WMS_LAYER" Default="Ortho" Mode="" Description="Allowlisted official orthophoto WMS layer identifier." Type="Variable" Display="advanced" Required="true" Mask="false">Ortho</Config> <Config Name="Orthophoto WMS Layer" Target="ORTHOPHOTO_WMS_LAYER" Default="Ortho" Mode="" Description="Allowlisted official orthophoto WMS layer identifier." Type="Variable" Display="advanced" Required="true" Mask="false">Ortho</Config>
+9
View File
@@ -29,6 +29,15 @@ GEOINTEL_CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202,http://192.168
# Upload guard in MiB. The same 1-2048 limit is applied by nginx and FastAPI. # Upload guard in MiB. The same 1-2048 limit is applied by nginx and FastAPI.
GEOINTEL_MAX_UPLOAD_MB=500 GEOINTEL_MAX_UPLOAD_MB=500
# Optional single-operator access gate. Never store a plaintext password here.
# Generate the password hash with AuthService.hash_password and use a unique,
# random session secret of at least 32 characters.
GEOINTEL_AUTH_ENABLED=false
GEOINTEL_AUTH_USERNAME=
GEOINTEL_AUTH_PASSWORD_HASH=
GEOINTEL_AUTH_SESSION_SECRET=
GEOINTEL_AUTH_SESSION_TTL_SECONDS=43200
# Explicit, bounded acquisition from the official Digitaal Vlaanderen WMS. # Explicit, bounded acquisition from the official Digitaal Vlaanderen WMS.
ORTHOPHOTO_ENABLED=true ORTHOPHOTO_ENABLED=true
ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms
+33
View File
@@ -33,6 +33,11 @@ GEOINTEL_POSTGRES_USER="${GEOINTEL_POSTGRES_USER:-geointel}"
GEOINTEL_POSTGRES_PASSWORD="${GEOINTEL_POSTGRES_PASSWORD:-}" GEOINTEL_POSTGRES_PASSWORD="${GEOINTEL_POSTGRES_PASSWORD:-}"
GEOINTEL_CORS_ORIGINS="${GEOINTEL_CORS_ORIGINS:-http://localhost:${GEOINTEL_FRONTEND_PORT},http://127.0.0.1:${GEOINTEL_FRONTEND_PORT},http://192.168.10.150:${GEOINTEL_FRONTEND_PORT}}" GEOINTEL_CORS_ORIGINS="${GEOINTEL_CORS_ORIGINS:-http://localhost:${GEOINTEL_FRONTEND_PORT},http://127.0.0.1:${GEOINTEL_FRONTEND_PORT},http://192.168.10.150:${GEOINTEL_FRONTEND_PORT}}"
GEOINTEL_MAX_UPLOAD_MB="${GEOINTEL_MAX_UPLOAD_MB:-500}" 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}"
ORTHOPHOTO_ENABLED="${ORTHOPHOTO_ENABLED:-true}" ORTHOPHOTO_ENABLED="${ORTHOPHOTO_ENABLED:-true}"
ORTHOPHOTO_WMS_URL="${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms}" ORTHOPHOTO_WMS_URL="${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms}"
ORTHOPHOTO_WMS_LAYER="${ORTHOPHOTO_WMS_LAYER:-Ortho}" ORTHOPHOTO_WMS_LAYER="${ORTHOPHOTO_WMS_LAYER:-Ortho}"
@@ -171,6 +176,29 @@ validate_runtime_config() {
return 2 return 2
fi fi
case "$GEOINTEL_AUTH_ENABLED" in
true|false) ;;
*)
echo "GEOINTEL_AUTH_ENABLED must be true or false." >&2
return 2
;;
esac
if [ "$GEOINTEL_AUTH_ENABLED" = "true" ]; then
if [ -z "$GEOINTEL_AUTH_USERNAME" ] \
|| [ -z "$GEOINTEL_AUTH_PASSWORD_HASH" ] \
|| [ "${#GEOINTEL_AUTH_SESSION_SECRET}" -lt 32 ]; then
echo "Enabled operator authentication requires username, password hash and a 32+ character session secret." >&2
return 2
fi
case "$GEOINTEL_AUTH_PASSWORD_HASH" in
pbkdf2_sha256\$*) ;;
*)
echo "GEOINTEL_AUTH_PASSWORD_HASH must use the pbkdf2_sha256 format." >&2
return 2
;;
esac
fi
case "$GEOINTEL_POSTGRES_PASSWORD" in case "$GEOINTEL_POSTGRES_PASSWORD" in
''|geointel|postgres|password|changeme|change-me-before-shared-use) ''|geointel|postgres|password|changeme|change-me-before-shared-use)
echo "Refusing deployment with an empty or known-default PostGIS password." >&2 echo "Refusing deployment with an empty or known-default PostGIS password." >&2
@@ -241,6 +269,11 @@ docker run -d \
-e GEOINTEL_STORAGE_ROOT=/app/storage \ -e GEOINTEL_STORAGE_ROOT=/app/storage \
-e GEOINTEL_CORS_ORIGINS="$GEOINTEL_CORS_ORIGINS" \ -e GEOINTEL_CORS_ORIGINS="$GEOINTEL_CORS_ORIGINS" \
-e GEOINTEL_MAX_UPLOAD_MB="$GEOINTEL_MAX_UPLOAD_MB" \ -e GEOINTEL_MAX_UPLOAD_MB="$GEOINTEL_MAX_UPLOAD_MB" \
-e GEOINTEL_AUTH_ENABLED="$GEOINTEL_AUTH_ENABLED" \
-e GEOINTEL_AUTH_USERNAME="$GEOINTEL_AUTH_USERNAME" \
-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 ORTHOPHOTO_ENABLED="$ORTHOPHOTO_ENABLED" \ -e ORTHOPHOTO_ENABLED="$ORTHOPHOTO_ENABLED" \
-e ORTHOPHOTO_WMS_URL="$ORTHOPHOTO_WMS_URL" \ -e ORTHOPHOTO_WMS_URL="$ORTHOPHOTO_WMS_URL" \
-e ORTHOPHOTO_WMS_LAYER="$ORTHOPHOTO_WMS_LAYER" \ -e ORTHOPHOTO_WMS_LAYER="$ORTHOPHOTO_WMS_LAYER" \
+51
View File
@@ -45,6 +45,57 @@ Any valid GeoJSON geometry object. V1 primarily expects `Polygon` and `MultiPoly
} }
``` ```
## Operator authentication
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.
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`.
### 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.
```json
{
"data": {
"authentication_required": true,
"authenticated": false,
"username": null,
"expires_at": null
}
}
```
### POST `/api/v1/auth/login`
```json
{
"username": "operator",
"password": "user-supplied secret"
}
```
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/logout`
Clears the browser cookie and returns an unauthenticated session. Logout is
idempotent and remains callable when the current cookie is missing or expired.
## Health ## Health
### GET `/health/live` ### GET `/health/live`
+29
View File
@@ -11215,3 +11215,32 @@ Pre-release evidence:
coverage 1.0, mean 159.3529 m DNG, minimum 51.7473 m DNG, maximum 223.4930 coverage 1.0, mean 159.3529 m DNG, minimum 51.7473 m DNG, maximum 223.4930
m DNG and mean slope 5.2786 degrees. Water depth and volume remain explicitly m DNG and mean slope 5.2786 degrees. Water depth and volume remain explicitly
unsupported because an MNT cannot establish either quantity. unsupported because an MNT cannot establish either quantity.
## 2026-07-22 - Stitch landing page and operator login
Implemented:
- translated the supplied `stitch_geointel_complete_workbench_redesign.zip`
into a native responsive React landing page rather than embedding its static
Tailwind mockup or temporary external image URLs;
- added a project-owned optimized Belgium/North Sea hero asset and kept the
existing operational workbench unchanged behind the access boundary;
- added public session probing, server-side PBKDF2-SHA256 credential checks,
signed HttpOnly/SameSite session cookies, expiry, failed-login throttling and
idempotent logout;
- protected proxied browser API requests while retaining trusted direct
loopback access for in-container operator scripts; no account table,
registration, role system or other multi-user scope was introduced;
- extended the Unraid environment/template and release runtime smoke to carry
the login configuration without storing a plaintext password.
Pre-deployment validation:
- the complete readiness gate passed with 1,107 backend tests and 36 frontend
tests, backend compile, API/documentation contract audit, frontend typecheck,
production build, Alembic head `202607160001` and all script checks;
- the authentication tests cover unauthenticated API rejection, successful
login with an HttpOnly/SameSite cookie, logout, signature tampering and the
deployment guarantee that only a password hash reaches the container.
Live deployment and login-journey evidence are appended after rollout.
+5
View File
@@ -6,6 +6,11 @@ These limitations are explicit, bounded and non-deceptive. None is a hidden
CRITICAL or HIGH release defect. Coverage and capability responses remain the CRITICAL or HIGH release defect. Coverage and capability responses remain the
runtime source of truth. 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.
## Source coverage ## Source coverage
- National administrative land and maritime scope is operational from - National administrative land and maritime scope is operational from
+5
View File
@@ -2,6 +2,11 @@
## Actieve post-RC datadekkingsfase ## Actieve post-RC datadekkingsfase
- [x] Implementeer de aangeleverde Stitch-landingspagina als echte React-
toegangspoort en bescherm de workbench met één veilig geconfigureerd
operatoraccount, sessieverval, uitloggen en zichtbare foutstatussen. Dit is
geen multi-user- of tenantbeheersysteem.
`docs/POST_RC_DATA_COVERAGE_ROADMAP_BELGIUM_NORTH_SEA.md` was het autonome `docs/POST_RC_DATA_COVERAGE_ROADMAP_BELGIUM_NORTH_SEA.md` was het autonome
uitvoeringsbord na `v1.0.0-rc.1` en is nu afgesloten voor `v1.0.0`. Dit is geen uitvoeringsbord na `v1.0.0-rc.1` en is nu afgesloten voor `v1.0.0`. Dit is geen
RC-12. De definitieve release blijft immutable bewijs; verdere databronnen RC-12. De definitieve release blijft immutable bewijs; verdere databronnen
+3 -1
View File
@@ -1,9 +1,11 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="nl">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/geointel-icon.svg" /> <link rel="icon" type="image/svg+xml" href="/geointel-icon.svg" />
<meta name="theme-color" content="#087266" />
<meta name="description" content="GeoIntel Atlas is de operationele GeoAI-workbench voor België en de Belgische Noordzee." />
<title>GeoIntel</title> <title>GeoIntel</title>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

+49 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { LogOut, UserRound } from 'lucide-react'
import '@fontsource/manrope/latin-500.css' import '@fontsource/manrope/latin-500.css'
import '@fontsource/manrope/latin-600.css' import '@fontsource/manrope/latin-600.css'
import '@fontsource/manrope/latin-700.css' import '@fontsource/manrope/latin-700.css'
@@ -8,6 +9,7 @@ import '@fontsource/public-sans/latin-600.css'
import './styles/app.css' import './styles/app.css'
import './styles/premium.css' import './styles/premium.css'
import './styles/atlas-workbench.css' import './styles/atlas-workbench.css'
import { LandingPage } from './components/auth/LandingPage'
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel' import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
import { GeoAssistantPanel } from './components/assistant/GeoAssistantPanel' import { GeoAssistantPanel } from './components/assistant/GeoAssistantPanel'
import { DatasetPanel } from './components/datasets/DatasetPanel' import { DatasetPanel } from './components/datasets/DatasetPanel'
@@ -38,6 +40,7 @@ import { useExportWorkflow } from './hooks/useExportWorkflow'
import { useMapSelectionDataset } from './hooks/useMapSelectionDataset' import { useMapSelectionDataset } from './hooks/useMapSelectionDataset'
import { useMapSelectionQa } from './hooks/useMapSelectionQa' import { useMapSelectionQa } from './hooks/useMapSelectionQa'
import { useMapWorkspaceState } from './hooks/useMapWorkspaceState' import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
import { useOperatorSession } from './hooks/useOperatorSession'
import { useMapSelectionExtract } from './hooks/useMapSelectionExtract' import { useMapSelectionExtract } from './hooks/useMapSelectionExtract'
import { useMapOrthophotoAnalysis } from './hooks/useMapOrthophotoAnalysis' import { useMapOrthophotoAnalysis } from './hooks/useMapOrthophotoAnalysis'
import { isDetectionImageryDataset, isMapRasterDataset } from './lib/datasetCapabilities' import { isDetectionImageryDataset, isMapRasterDataset } from './lib/datasetCapabilities'
@@ -81,7 +84,13 @@ const workspaceNavGroups: WorkspaceNavigationGroup[] = [
{ label: 'Beheer', keys: ['overview', 'system'] }, { label: 'Beheer', keys: ['overview', 'system'] },
] ]
function App(): JSX.Element { interface WorkbenchAppProps {
username: string | null
loggingOut: boolean
onLogout: () => void
}
function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JSX.Element {
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map') const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')
const [inspectorOpen, setInspectorOpen] = useState(false) const [inspectorOpen, setInspectorOpen] = useState(false)
const [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset') const [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset')
@@ -818,6 +827,16 @@ function App(): JSX.Element {
<span aria-hidden="true" /> <span aria-hidden="true" />
<strong>{workspaceDataLoading ? 'Laden' : errorMessage ? 'Aandacht' : 'Gereed'}</strong> <strong>{workspaceDataLoading ? 'Laden' : errorMessage ? 'Aandacht' : 'Gereed'}</strong>
</div> </div>
{username ? (
<div className="context-account" aria-label="Aangemelde gebruiker">
<UserRound aria-hidden="true" />
<span title={username}>{username}</span>
<button type="button" onClick={onLogout} disabled={loggingOut}>
<LogOut aria-hidden="true" />
<span>{loggingOut ? 'Uitloggen…' : 'Uitloggen'}</span>
</button>
</div>
) : null}
</header> </header>
<div className="workbench-content"> <div className="workbench-content">
@@ -1300,4 +1319,33 @@ function App(): JSX.Element {
) )
} }
function App(): JSX.Element {
const { session, sessionError, loggingOut, handleAuthenticated, handleLogout } = useOperatorSession()
if (session === null) {
return (
<div className="landing-auth-loading" role="status" aria-live="polite">
<div><span aria-hidden="true" /><strong>GeoIntel wordt voorbereid</strong></div>
</div>
)
}
if (!session.authenticated) {
return (
<LandingPage
serviceError={sessionError}
onAuthenticated={handleAuthenticated}
/>
)
}
return (
<WorkbenchApp
username={session.authentication_required ? session.username : null}
loggingOut={loggingOut}
onLogout={handleLogout}
/>
)
}
export default App export default App
@@ -0,0 +1,48 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LandingPage } from './LandingPage'
import { login } from '../../services/api/auth'
vi.mock('../../services/api/auth', () => ({
login: vi.fn(),
}))
describe('LandingPage', () => {
beforeEach(() => {
vi.mocked(login).mockReset()
})
afterEach(() => cleanup())
it('shows the Stitch-derived landing content and submits the real login flow', async () => {
const onAuthenticated = vi.fn()
vi.mocked(login).mockResolvedValue({
authentication_required: true,
authenticated: true,
username: 'operator',
expires_at: '2026-07-22T20:00:00Z',
})
render(<LandingPage onAuthenticated={onAuthenticated} />)
expect(screen.getByRole('heading', { name: /Operationele GIS-analyse/i })).toBeTruthy()
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: 'operator' } })
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'correct' } })
fireEvent.click(screen.getAllByRole('button', { name: 'Inloggen' })[1])
await waitFor(() => expect(login).toHaveBeenCalledWith('operator', 'correct'))
expect(onAuthenticated).toHaveBeenCalledWith(expect.objectContaining({ authenticated: true }))
})
it('surfaces an authentication error without entering the workbench', async () => {
vi.mocked(login).mockRejectedValue(new Error('Gebruikersnaam of wachtwoord is onjuist.'))
render(<LandingPage onAuthenticated={vi.fn()} />)
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: 'operator' } })
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'wrong' } })
fireEvent.click(screen.getAllByRole('button', { name: 'Inloggen' })[1])
expect((await screen.findByRole('alert')).textContent).toContain('Gebruikersnaam of wachtwoord is onjuist.')
})
})
@@ -0,0 +1,229 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'
import {
ArrowRight,
BrainCircuit,
CheckCircle2,
Database,
Layers3,
LockKeyhole,
LogIn,
MapPinned,
Menu,
ShieldCheck,
X,
} from 'lucide-react'
import { login } from '../../services/api/auth'
import type { AuthSession } from '../../services/api/auth'
import { formatError } from '../../lib/formatError'
import '../../styles/landing.css'
interface LandingPageProps {
onAuthenticated: (session: AuthSession) => void
serviceError?: string | null
}
const capabilityItems = [
{
icon: MapPinned,
title: 'Heel België in beeld',
description:
'Werk met officiële bronnen voor Vlaanderen, Wallonië, Brussel en de Belgische Noordzee, zonder regionale semantiek te vermengen.',
tags: ['NGI', 'SPW', 'Digitaal Vlaanderen'],
tone: 'primary',
},
{
icon: BrainCircuit,
title: 'AI met bewijsgrenzen',
description:
'Voer objectdetectie uit op geschikte luchtbeelden en beoordeel resultaten tegen referentiedata met zichtbare model- en validatiegrenzen.',
tags: ['Objectdetectie', 'Lokale assistent'],
tone: 'secondary',
},
{
icon: ShieldCheck,
title: 'Operationele kwaliteit',
description:
'Elke analyse bewaart bron, meetmoment, CRS, eenheid en beperkingen. Resultaten blijven inspecteerbaar vóór export of besluitvorming.',
tags: ['QA/QC', 'Herleidbaar'],
tone: 'attention',
},
]
export function LandingPage({ onAuthenticated, serviceError = null }: LandingPageProps): JSX.Element {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [loginError, setLoginError] = useState<string | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const usernameRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
document.body.classList.add('landing-body')
return () => document.body.classList.remove('landing-body')
}, [])
const submitLogin = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setSubmitting(true)
setLoginError(null)
try {
const session = await login(username.trim(), password)
onAuthenticated(session)
} catch (error) {
setLoginError(formatError(error, 'Aanmelden is niet gelukt. Probeer het opnieuw.'))
} finally {
setSubmitting(false)
}
}
const focusLogin = () => {
setMenuOpen(false)
window.requestAnimationFrame(() => usernameRef.current?.focus())
}
return (
<div className="landing-page">
<a className="landing-skip-link" href="#login-panel">Ga naar aanmelden</a>
<header className="landing-header">
<a className="landing-brand" href="#top" aria-label="GeoIntel Atlas startpagina">
<span className="landing-brand-mark" aria-hidden="true">GI</span>
<span>GeoIntel Atlas</span>
</a>
<button
className="landing-menu-toggle"
type="button"
aria-label={menuOpen ? 'Navigatie sluiten' : 'Navigatie openen'}
aria-expanded={menuOpen}
onClick={() => setMenuOpen((current) => !current)}
>
{menuOpen ? <X aria-hidden="true" /> : <Menu aria-hidden="true" />}
</button>
<nav className={menuOpen ? 'landing-nav landing-nav-open' : 'landing-nav'} aria-label="Landingspagina">
<a href="#mogelijkheden" onClick={() => setMenuOpen(false)}>Verkennen</a>
<a href="#werkproces" onClick={() => setMenuOpen(false)}>Analyseren</a>
<a href="#kwaliteit" onClick={() => setMenuOpen(false)}>Kwaliteit</a>
</nav>
<button className="landing-header-login" type="button" onClick={focusLogin}>
Inloggen
</button>
</header>
<main id="top">
<section className="landing-hero" aria-labelledby="landing-title">
<div className="landing-hero-background" aria-hidden="true" />
<div className="landing-hero-content">
<div className="landing-hero-copy">
<p className="landing-kicker"><CheckCircle2 aria-hidden="true" /> Operationele GeoAI-workbench</p>
<h1 id="landing-title">Operationele GIS-analyse <span>op topniveau.</span></h1>
<p className="landing-lead">
De kaartgerichte workbench voor professionals die werken met gegevens van België en de Belgische Noordzee. Selecteer een gebied, meet officiële bronnen en controleer ieder resultaat.
</p>
<div className="landing-hero-actions">
<a className="landing-primary-action" href="#mogelijkheden">
<MapPinned aria-hidden="true" /> Bekijk mogelijkheden
</a>
<button className="landing-secondary-action" type="button" onClick={focusLogin}>
Naar inloggen <ArrowRight aria-hidden="true" />
</button>
</div>
<dl className="landing-trust-strip" aria-label="Platformbereik">
<div><dt>Geografie</dt><dd>België + Noordzee</dd></div>
<div><dt>Bronnen</dt><dd>Officieel per regio</dd></div>
<div><dt>Uitvoer</dt><dd>GIS-herleidbaar</dd></div>
</dl>
</div>
<div className="landing-login-card" id="login-panel">
<div className="landing-login-heading">
<span className="landing-login-icon" aria-hidden="true"><LockKeyhole /></span>
<div>
<h2>Toegang Workbench</h2>
<p>Log in met uw GeoIntel-account.</p>
</div>
</div>
<form onSubmit={submitLogin} aria-busy={submitting}>
<label htmlFor="login-username">Gebruikersnaam</label>
<input
ref={usernameRef}
id="login-username"
name="username"
type="text"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={submitting}
required
/>
<label htmlFor="login-password">Wachtwoord</label>
<input
id="login-password"
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={submitting}
required
/>
{loginError || serviceError ? (
<p className="landing-login-error" role="alert">{loginError ?? serviceError}</p>
) : null}
<button type="submit" disabled={submitting || !username.trim() || !password}>
<LogIn aria-hidden="true" /> {submitting ? 'Aanmelden…' : 'Inloggen'}
</button>
</form>
<p className="landing-session-note"><ShieldCheck aria-hidden="true" /> Beveiligde, tijdelijke operatorsessie</p>
</div>
</div>
</section>
<section className="landing-capabilities" id="mogelijkheden" aria-labelledby="capabilities-title">
<div className="landing-section-heading">
<p>Van bron tot besluit</p>
<h2 id="capabilities-title">Eén werkbank, controle over de hele keten</h2>
</div>
<div className="landing-capability-grid">
{capabilityItems.map(({ icon: Icon, title, description, tags, tone }) => (
<article key={title} className={`landing-capability landing-capability-${tone}`}>
<span className="landing-capability-icon" aria-hidden="true"><Icon /></span>
<h3>{title}</h3>
<p>{description}</p>
<div>{tags.map((tag) => <span key={tag}>{tag}</span>)}</div>
</article>
))}
</div>
</section>
<section className="landing-workflow" id="werkproces" aria-labelledby="workflow-title">
<div className="landing-workflow-map">
<div className="landing-workflow-map-image" aria-hidden="true" />
<div>
<p>Kaart als werkomgeving</p>
<h2 id="workflow-title">Van selectie naar aantoonbaar inzicht</h2>
<span>Kies thema teken gebied controleer bron analyseer exporteer</span>
</div>
</div>
<div className="landing-workflow-details">
<article>
<Database aria-hidden="true" />
<div><h3>Bronnen per rechtsgebied</h3><p>Vlaamse, Waalse, Brusselse en maritieme bronnen blijven herkenbaar gescheiden.</p></div>
</article>
<article>
<Layers3 aria-hidden="true" />
<div><h3>Toestand en evolutie</h3><p>Vergelijk alleen meetmomenten die inhoudelijk en ruimtelijk verenigbaar zijn.</p></div>
</article>
<article id="kwaliteit">
<ShieldCheck aria-hidden="true" />
<div><h3>Kwaliteit vóór export</h3><p>CRS, eenheid, dekking, herkomst en beperkingen blijven naast het resultaat zichtbaar.</p></div>
</article>
</div>
</section>
</main>
<footer className="landing-footer">
<div><strong>GeoIntel Atlas Workbench</strong><p>Operationele GIS-analyse voor België en de Belgische Noordzee.</p></div>
<p>© {new Date().getFullYear()} GeoIntel · Interne operatoromgeving</p>
</footer>
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react'
import { formatError } from '../lib/formatError'
import { getAuthSession, logout, type AuthSession } from '../services/api/auth'
const signedOutSession: AuthSession = {
authentication_required: true,
authenticated: false,
username: null,
expires_at: null,
}
export function useOperatorSession() {
const [session, setSession] = useState<AuthSession | null>(null)
const [sessionError, setSessionError] = useState<string | null>(null)
const [loggingOut, setLoggingOut] = useState(false)
useEffect(() => {
let active = true
getAuthSession()
.then((value) => {
if (active) {
setSession(value)
setSessionError(null)
}
})
.catch((error) => {
if (active) {
setSession(signedOutSession)
setSessionError(formatError(error, 'De aanmeldservice is tijdelijk niet bereikbaar.'))
}
})
return () => {
active = false
}
}, [])
useEffect(() => {
const expireSession = () => {
setSession(signedOutSession)
setSessionError('Uw sessie is verlopen. Meld u opnieuw aan.')
}
window.addEventListener('geointel:session-expired', expireSession)
return () => window.removeEventListener('geointel:session-expired', expireSession)
}, [])
const handleLogout = async () => {
setLoggingOut(true)
try {
setSession(await logout())
setSessionError(null)
} catch (error) {
setSessionError(formatError(error, 'Uitloggen is niet gelukt.'))
} finally {
setLoggingOut(false)
}
}
const handleAuthenticated = (authenticatedSession: AuthSession) => {
setSession(authenticatedSession)
setSessionError(null)
}
return { session, sessionError, loggingOut, handleAuthenticated, handleLogout }
}
+20
View File
@@ -0,0 +1,20 @@
import { apiGet, apiPost } from './client'
export interface AuthSession {
authentication_required: boolean
authenticated: boolean
username: string | null
expires_at: string | null
}
export function getAuthSession(): Promise<AuthSession> {
return apiGet<AuthSession>('/api/v1/auth/session')
}
export function login(username: string, password: string): Promise<AuthSession> {
return apiPost<AuthSession>('/api/v1/auth/login', { username, password })
}
export function logout(): Promise<AuthSession> {
return apiPost<AuthSession>('/api/v1/auth/logout')
}
+8 -1
View File
@@ -23,19 +23,23 @@ async function parseResponse<T>(response: Response): Promise<T> {
const code = typeof payload?.error === "string" ? payload.error : legacyError?.code ?? "REQUEST_ERROR"; const code = typeof payload?.error === "string" ? payload.error : legacyError?.code ?? "REQUEST_ERROR";
const message = payload?.message ?? legacyError?.message ?? `Request failed (${response.status})`; const message = payload?.message ?? legacyError?.message ?? `Request failed (${response.status})`;
const details = payload?.details ?? legacyError?.details; const details = payload?.details ?? legacyError?.details;
if (response.status === 401 && code === "AUTHENTICATION_REQUIRED") {
window.dispatchEvent(new CustomEvent("geointel:session-expired"));
}
throw new ApiHttpError(message, code, details); throw new ApiHttpError(message, code, details);
} }
return payload.data as T; return payload.data as T;
} }
export async function apiGet<T>(path: string): Promise<T> { export async function apiGet<T>(path: string): Promise<T> {
const response = await fetch(apiUrl(path)); const response = await fetch(apiUrl(path), { credentials: "same-origin" });
return parseResponse<T>(response); return parseResponse<T>(response);
} }
export async function apiPost<T>(path: string, body?: object): Promise<T> { export async function apiPost<T>(path: string, body?: object): Promise<T> {
const response = await fetch(apiUrl(path), { const response = await fetch(apiUrl(path), {
method: "POST", method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
@@ -45,6 +49,7 @@ export async function apiPost<T>(path: string, body?: object): Promise<T> {
export async function apiPatch<T>(path: string, body?: object): Promise<T> { export async function apiPatch<T>(path: string, body?: object): Promise<T> {
const response = await fetch(apiUrl(path), { const response = await fetch(apiUrl(path), {
method: "PATCH", method: "PATCH",
credentials: "same-origin",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
@@ -54,6 +59,7 @@ export async function apiPatch<T>(path: string, body?: object): Promise<T> {
export async function apiDelete<T>(path: string): Promise<T> { export async function apiDelete<T>(path: string): Promise<T> {
const response = await fetch(apiUrl(path), { const response = await fetch(apiUrl(path), {
method: "DELETE", method: "DELETE",
credentials: "same-origin",
}); });
return parseResponse<T>(response); return parseResponse<T>(response);
} }
@@ -61,6 +67,7 @@ export async function apiDelete<T>(path: string): Promise<T> {
export async function apiMultipart<T>(path: string, form: FormData): Promise<T> { export async function apiMultipart<T>(path: string, form: FormData): Promise<T> {
const response = await fetch(apiUrl(path), { const response = await fetch(apiUrl(path), {
method: "POST", method: "POST",
credentials: "same-origin",
body: form, body: form,
}); });
return parseResponse<T>(response); return parseResponse<T>(response);
+38 -1
View File
@@ -217,7 +217,7 @@ textarea {
position: relative; position: relative;
z-index: 30; z-index: 30;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto auto;
min-width: 0; min-width: 0;
min-height: var(--atlas-topbar-height); min-height: var(--atlas-topbar-height);
align-items: stretch; align-items: stretch;
@@ -225,6 +225,34 @@ textarea {
background: #eefdf9; background: #eefdf9;
} }
.context-account {
display: flex;
min-width: 0;
gap: 0.45rem;
align-items: center;
border-left: 1px solid var(--atlas-line);
padding: 0 0.65rem;
color: var(--atlas-ink-soft);
font-size: 0.62rem;
}
.context-account > svg { width: 0.95rem; height: 0.95rem; color: var(--atlas-primary); }
.context-account > span { max-width: 8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.context-account button {
display: inline-flex;
min-height: 1.85rem;
gap: 0.35rem;
align-items: center;
border: 1px solid var(--atlas-line-strong);
border-radius: var(--atlas-radius);
padding: 0.3rem 0.48rem;
background: var(--atlas-surface);
color: var(--atlas-ink-soft);
font-size: 0.59rem;
}
.context-account button svg { width: 0.8rem; height: 0.8rem; }
.context-account button:disabled { opacity: 0.55; }
.mobile-brand { .mobile-brand {
display: none; display: none;
} }
@@ -1739,6 +1767,15 @@ textarea {
display: none; display: none;
} }
.context-account {
border-left: 0;
}
.context-account > span,
.context-account button > span {
display: none;
}
.workbench-content { .workbench-content {
display: block; display: block;
overflow: visible; overflow: visible;
+372
View File
@@ -0,0 +1,372 @@
:root {
--landing-canvas: #f5f7f6;
--landing-surface: #ffffff;
--landing-ink: #102f2a;
--landing-muted: #56706a;
--landing-primary: #087266;
--landing-primary-strong: #05574f;
--landing-mint: #dff7f2;
--landing-blue: #315d73;
--landing-amber: #b46432;
--landing-line: #b9d0ca;
}
body.landing-body {
overflow: auto;
background: var(--landing-canvas);
}
.landing-page {
min-width: 20rem;
min-height: 100dvh;
overflow-x: hidden;
background: var(--landing-canvas);
color: var(--landing-ink);
font-family: "Public Sans", "Segoe UI", sans-serif;
}
.landing-page *,
.landing-page *::before,
.landing-page *::after {
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-skip-link:focus { top: 0.75rem; }
.landing-header {
position: fixed;
z-index: 100;
top: 0;
right: 0;
left: 0;
display: grid;
min-height: 3.65rem;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 2rem;
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);
}
.landing-brand {
display: inline-flex;
gap: 0.65rem;
align-items: center;
color: var(--landing-primary-strong);
font-family: "Manrope", "Segoe UI", sans-serif;
font-weight: 800;
text-decoration: none;
}
.landing-brand-mark {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border: 1px solid #8ec7bc;
border-radius: 4px;
background: #e7faf6;
font-size: 0.76rem;
}
.landing-nav {
display: flex;
gap: 1.75rem;
align-items: center;
}
.landing-nav a {
border-bottom: 2px solid transparent;
padding: 0.35rem 0;
color: #34534d;
font-size: 0.82rem;
font-weight: 600;
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;
background: var(--landing-primary);
color: #fff;
font-weight: 700;
}
.landing-header-login { min-height: 2.35rem; padding: 0.45rem 1.1rem; }
.landing-menu-toggle { display: none; }
.landing-hero {
position: relative;
display: grid;
min-height: min(58rem, 100dvh);
align-items: center;
overflow: hidden;
padding: 6.8rem clamp(1.25rem, 6vw, 6rem) 4rem;
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;
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%);
content: '';
}
.landing-hero-content {
display: grid;
width: min(90rem, 100%);
grid-template-columns: minmax(0, 1.15fr) minmax(20rem, 27rem);
gap: clamp(3rem, 7vw, 8rem);
align-items: center;
margin: 0 auto;
}
.landing-hero-copy { max-width: 47rem; }
.landing-kicker,
.landing-section-heading > p {
display: inline-flex;
gap: 0.45rem;
align-items: center;
margin: 0 0 1.25rem;
color: var(--landing-primary-strong);
font-size: 0.68rem;
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;
margin: 0;
color: #112f2a;
font-family: "Manrope", "Segoe UI", sans-serif;
font-size: clamp(3rem, 5.8vw, 6rem);
font-weight: 800;
letter-spacing: -0.055em;
line-height: 0.98;
}
.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-hero-actions { display: flex; gap: 0.75rem; margin-top: 2rem; }
.landing-primary-action,
.landing-secondary-action {
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;
}
.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-trust-strip {
display: flex;
gap: 0;
margin: 2.5rem 0 0;
}
.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-login-heading { display: flex; gap: 0.85rem; align-items: flex-start; }
.landing-login-icon {
display: grid;
width: 2.45rem;
height: 2.45rem;
flex: 0 0 2.45rem;
place-items: center;
border-radius: 4px;
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-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;
align-items: center;
justify-content: center;
margin-top: 0.75rem;
}
.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-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 { 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-auth-loading {
display: grid;
min-height: 100dvh;
place-items: center;
background: #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; }
}
@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-capabilities,
.landing-workflow { padding: 3.5rem 1rem; }
.landing-workflow-map { min-height: 28rem; }
.landing-footer { align-items: flex-start; flex-direction: column; }
}
@media (prefers-reduced-motion: reduce) {
.landing-page *,
.landing-page *::before,
.landing-page *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
+11 -1
View File
@@ -3,7 +3,8 @@ set -euo pipefail
FRONTEND_URL="${1:-http://localhost:1202}" FRONTEND_URL="${1:-http://localhost:1202}"
BACKEND_HEALTH_URL="${2:-}" BACKEND_HEALTH_URL="${2:-}"
API_URL="${FRONTEND_URL%/}/api/v1/projects" API_URL="${FRONTEND_URL%/}/api/v1/auth/session"
PROJECTS_API_URL="${FRONTEND_URL%/}/api/v1/projects"
ICON_URL="${FRONTEND_URL%/}/geointel-icon.png" ICON_URL="${FRONTEND_URL%/}/geointel-icon.png"
if ! command -v curl >/dev/null 2>&1; then if ! command -v curl >/dev/null 2>&1; then
@@ -14,19 +15,23 @@ fi
echo "== GeoIntel browser runtime verification ==" echo "== GeoIntel browser runtime verification =="
echo "Frontend: ${FRONTEND_URL}" echo "Frontend: ${FRONTEND_URL}"
echo "API through frontend proxy: ${API_URL}" echo "API through frontend proxy: ${API_URL}"
echo "Protected API through frontend proxy: ${PROJECTS_API_URL}"
echo "Icon: ${ICON_URL}" echo "Icon: ${ICON_URL}"
frontend_status="" frontend_status=""
api_response="" api_response=""
projects_status=""
icon_status="" icon_status=""
for attempt in $(seq 1 60); do for attempt in $(seq 1 60); do
frontend_status="$(curl -fsS -o /dev/null -w "%{http_code}" "${FRONTEND_URL}" 2>/dev/null || true)" frontend_status="$(curl -fsS -o /dev/null -w "%{http_code}" "${FRONTEND_URL}" 2>/dev/null || true)"
icon_status="$(curl -fsS -o /dev/null -w "%{http_code}" "${ICON_URL}" 2>/dev/null || true)" icon_status="$(curl -fsS -o /dev/null -w "%{http_code}" "${ICON_URL}" 2>/dev/null || true)"
api_response="$(curl -fsS "${API_URL}" 2>/dev/null || true)" api_response="$(curl -fsS "${API_URL}" 2>/dev/null || true)"
projects_status="$(curl -sS -o /dev/null -w "%{http_code}" "${PROJECTS_API_URL}" 2>/dev/null || true)"
if [ "${frontend_status}" = "200" ] \ if [ "${frontend_status}" = "200" ] \
&& [ "${icon_status}" = "200" ] \ && [ "${icon_status}" = "200" ] \
&& { [ "${projects_status}" = "200" ] || [ "${projects_status}" = "401" ]; } \
&& printf '%s' "${api_response}" | grep -q '"data"'; then && printf '%s' "${api_response}" | grep -q '"data"'; then
break break
fi fi
@@ -40,6 +45,11 @@ if [ "${frontend_status}" != "200" ]; then
exit 1 exit 1
fi fi
if [ "${projects_status}" != "200" ] && [ "${projects_status}" != "401" ]; then
echo "Protected API returned HTTP ${projects_status:-none}, expected 200 (auth disabled) or 401 (auth enabled)" >&2
exit 1
fi
if [ "${icon_status}" != "200" ]; then if [ "${icon_status}" != "200" ]; then
echo "Icon returned HTTP ${icon_status:-none}, expected 200" >&2 echo "Icon returned HTTP ${icon_status:-none}, expected 200" >&2
exit 1 exit 1