Update
This commit is contained in:
@@ -5,6 +5,18 @@ DATABASE_URL=postgresql+psycopg://geointel:geointel@localhost:5432/geointel?conn
|
|||||||
STORAGE_ROOT=./storage
|
STORAGE_ROOT=./storage
|
||||||
MAX_UPLOAD_MB=500
|
MAX_UPLOAD_MB=500
|
||||||
CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202
|
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_ENABLED=true
|
||||||
ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms
|
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
|
SPW_ORTHOPHOTO_WMS_URL=https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
## Unreleased - Post-V1 capability completion (2026-07-19)
|
## 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
|
- Implemented the supplied Stitch landing-page direction as the real React
|
||||||
entry surface, with responsive navigation, accurate Belgian/North Sea
|
entry surface, with responsive navigation, accurate Belgian/North Sea
|
||||||
product copy, a project-owned optimized hero asset, loading/error states and
|
product copy, a project-owned optimized hero asset, loading/error states and
|
||||||
|
|||||||
@@ -105,6 +105,28 @@ GEOINTEL_STORAGE_PATH=/mnt/user/appdata/geointel/storage
|
|||||||
GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data
|
GEOINTEL_POSTGIS_DATA_PATH=/mnt/user/appdata/geointel/postgres-data
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Guest demonstration access
|
||||||
|
|
||||||
|
Guest access is enabled by default whenever the operator login gate is active.
|
||||||
|
No additional guest toggle is required for a new authenticated installation:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GEOINTEL_AUTH_ENABLED=true
|
||||||
|
GEOINTEL_AUTH_USERNAME=operator
|
||||||
|
GEOINTEL_AUTH_PASSWORD_HASH=pbkdf2_sha256$...
|
||||||
|
GEOINTEL_AUTH_SESSION_SECRET=<independent-random-secret-of-at-least-32-characters>
|
||||||
|
GEOINTEL_GUEST_ACCESS_ENABLED=true
|
||||||
|
GEOINTEL_GUEST_DISPLAY_NAME=Gast
|
||||||
|
GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200
|
||||||
|
```
|
||||||
|
|
||||||
|
The login page offers **Als gast verkennen**. A guest receives a short-lived,
|
||||||
|
read-only session scoped to the seeded demo project and sees only the map and
|
||||||
|
existing quality evidence. Set `GEOINTEL_GUEST_ACCESS_ENABLED=false` to hide and
|
||||||
|
disable this route. This is not multi-user authorization or tenant isolation;
|
||||||
|
use a separate demo instance when the installation contains private or
|
||||||
|
operational datasets.
|
||||||
|
|
||||||
The backend and PostGIS ports are intentionally not exposed to the LAN in the all-in-one runtime. See `deploy/unraid/README.md` for full setup, port-change and cleanup notes.
|
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.
|
On Tower/Unraid, `scripts/deploy_tower.ps1` and `scripts/deploy_tower.sh` validate the Compose reference but build with plain `docker build`, then automatically install the editable DockerMan template as `/boot/config/plugins/dockerMan/templates-user/my-geointel.xml`, install the PNG icon as `/boot/config/plugins/dockerMan/images/geointel-icon.png`, remove any old Compose-owned `geointel` container and start the final container with DockerMan labels.
|
||||||
|
|||||||
+106
-23
@@ -2,30 +2,71 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from 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.config import get_settings
|
||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
|
from app.db.session import get_db
|
||||||
from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope
|
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"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
COOKIE_NAME = "geointel_session"
|
COOKIE_NAME = "geointel_session"
|
||||||
|
|
||||||
|
|
||||||
def _session_payload(request: Request) -> AuthSession:
|
def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: bool) -> 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(
|
return AuthSession(
|
||||||
authentication_required=True,
|
authentication_required=True,
|
||||||
authenticated=True,
|
authenticated=True,
|
||||||
username=principal.username,
|
username=principal.username,
|
||||||
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
|
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.",
|
message="De beveiligde sessie kon niet worden aangemaakt.",
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
|
_set_session_cookie(
|
||||||
response.set_cookie(
|
request=request,
|
||||||
key=COOKIE_NAME,
|
response=response,
|
||||||
value=token,
|
token=token,
|
||||||
max_age=settings.auth_session_ttl_seconds,
|
max_age=settings.auth_session_ttl_seconds,
|
||||||
httponly=True,
|
|
||||||
secure=forwarded_proto == "https" or request.url.scheme == "https",
|
|
||||||
samesite="strict",
|
|
||||||
path="/",
|
|
||||||
)
|
)
|
||||||
return AuthSessionEnvelope(
|
return AuthSessionEnvelope(
|
||||||
data=AuthSession(
|
data=_session_from_principal(
|
||||||
authentication_required=True,
|
principal,
|
||||||
authenticated=True,
|
guest_access_enabled=settings.guest_access_enabled,
|
||||||
username=principal.username,
|
)
|
||||||
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
@router.post("/logout", response_model=AuthSessionEnvelope)
|
||||||
def logout(response: Response) -> AuthSessionEnvelope:
|
def logout(response: Response) -> AuthSessionEnvelope:
|
||||||
|
settings = get_settings()
|
||||||
response.delete_cookie(key=COOKIE_NAME, path="/", httponly=True, samesite="strict")
|
response.delete_cookie(key=COOKIE_NAME, path="/", httponly=True, samesite="strict")
|
||||||
return AuthSessionEnvelope(
|
return AuthSessionEnvelope(
|
||||||
data=AuthSession(authentication_required=True, authenticated=False)
|
data=AuthSession(
|
||||||
|
authentication_required=settings.auth_enabled,
|
||||||
|
authenticated=not settings.auth_enabled,
|
||||||
|
guest_access_enabled=settings.auth_enabled and settings.guest_access_enabled,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.errors import AppError
|
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)
|
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]:
|
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()]
|
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])
|
@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(
|
result = CoverageRegistryService.resolve(
|
||||||
db,
|
db,
|
||||||
project_id=payload.project_id,
|
project_id=payload.project_id,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
from uuid import UUID
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
@@ -17,12 +17,34 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
|||||||
|
|
||||||
@router.get("", response_model=Envelope[ProjectList])
|
@router.get("", response_model=Envelope[ProjectList])
|
||||||
def list_projects(
|
def list_projects(
|
||||||
|
request: Request,
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
name: str | None = Query(default=None, min_length=1, max_length=255),
|
name: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
project_status: Literal["active", "archived", "all"] = Query(default="active", alias="status"),
|
project_status: Literal["active", "archived", "all"] = Query(default="active", alias="status"),
|
||||||
db: Session = Depends(get_db),
|
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(
|
projects, total = ProjectService.list_projects(
|
||||||
db,
|
db,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
|||||||
@@ -28,6 +28,22 @@ class Settings(BaseSettings):
|
|||||||
le=604_800,
|
le=604_800,
|
||||||
validation_alias="GEOINTEL_AUTH_SESSION_TTL_SECONDS",
|
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(
|
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",
|
||||||
@@ -406,6 +422,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_operator_auth(self) -> "Settings":
|
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:
|
if not self.auth_enabled:
|
||||||
return self
|
return self
|
||||||
if not (self.auth_username or "").strip():
|
if not (self.auth_username or "").strip():
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ def create_app() -> FastAPI:
|
|||||||
public_auth_paths = {
|
public_auth_paths = {
|
||||||
f"{settings.api_prefix}/auth/session",
|
f"{settings.api_prefix}/auth/session",
|
||||||
f"{settings.api_prefix}/auth/login",
|
f"{settings.api_prefix}/auth/login",
|
||||||
|
f"{settings.api_prefix}/auth/guest",
|
||||||
f"{settings.api_prefix}/auth/logout",
|
f"{settings.api_prefix}/auth/logout",
|
||||||
}
|
}
|
||||||
direct_loopback_request = (
|
direct_loopback_request = (
|
||||||
@@ -177,6 +178,91 @@ def create_app() -> FastAPI:
|
|||||||
response.headers["x-request-id"] = request_id
|
response.headers["x-request-id"] = request_id
|
||||||
return response
|
return response
|
||||||
request.state.auth_principal = principal
|
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 = await call_next(request)
|
||||||
response.headers["x-request-id"] = request_id
|
response.headers["x-request-id"] = request_id
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -17,6 +19,9 @@ class AuthSession(BaseModel):
|
|||||||
authenticated: bool
|
authenticated: bool
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
expires_at: datetime | 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]):
|
class AuthSessionEnvelope(Envelope[AuthSession]):
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal, cast
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ from app.core.config import Settings
|
|||||||
class AuthPrincipal:
|
class AuthPrincipal:
|
||||||
username: str
|
username: str
|
||||||
expires_at: int
|
expires_at: int
|
||||||
|
role: Literal["operator", "guest"] = "operator"
|
||||||
|
project_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
@@ -93,15 +97,32 @@ class AuthService:
|
|||||||
return username_matches and password_matches
|
return username_matches and password_matches
|
||||||
|
|
||||||
@classmethod
|
@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)
|
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 = {
|
payload = {
|
||||||
"exp": issued_at + settings.auth_session_ttl_seconds,
|
"exp": issued_at + resolved_ttl,
|
||||||
"iat": issued_at,
|
"iat": issued_at,
|
||||||
"jti": secrets.token_urlsafe(12),
|
"jti": secrets.token_urlsafe(12),
|
||||||
|
"role": role,
|
||||||
"sub": username,
|
"sub": username,
|
||||||
"v": 1,
|
"v": 2,
|
||||||
}
|
}
|
||||||
|
if project_id is not None:
|
||||||
|
payload["project_id"] = str(project_id)
|
||||||
encoded_payload = cls._b64_encode(
|
encoded_payload = cls._b64_encode(
|
||||||
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
)
|
)
|
||||||
@@ -136,14 +157,35 @@ class AuthService:
|
|||||||
username = str(payload.get("sub") or "")
|
username = str(payload.get("sub") or "")
|
||||||
expires_at = int(payload.get("exp") or 0)
|
expires_at = int(payload.get("exp") or 0)
|
||||||
issued_at = int(payload.get("iat") 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)
|
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
|
return None
|
||||||
|
role = cast(Literal["operator", "guest"], role_value)
|
||||||
if issued_at <= 0 or issued_at > current + 60 or expires_at <= current:
|
if issued_at <= 0 or issued_at > current + 60 or expires_at <= current:
|
||||||
return None
|
return None
|
||||||
if expires_at - issued_at > settings.auth_session_ttl_seconds:
|
if role == "operator":
|
||||||
|
if username != settings.auth_username:
|
||||||
return None
|
return None
|
||||||
return AuthPrincipal(username=username, expires_at=expires_at)
|
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,
|
||||||
|
role=role,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
except (TypeError, ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
except (TypeError, ValueError, json.JSONDecodeError, UnicodeDecodeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+126
-3
@@ -1,14 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
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.main import create_app
|
||||||
|
from app.schemas.demo import DemoWorkflowResponse
|
||||||
from app.services.auth_service import AuthService
|
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(
|
password_hash = AuthService.hash_password(
|
||||||
"correct horse battery staple",
|
"correct horse battery staple",
|
||||||
salt=b"geointel-test-salt",
|
salt=b"geointel-test-salt",
|
||||||
@@ -18,9 +23,48 @@ def auth_client(monkeypatch) -> TestClient:
|
|||||||
monkeypatch.setenv("GEOINTEL_AUTH_USERNAME", "operator")
|
monkeypatch.setenv("GEOINTEL_AUTH_USERNAME", "operator")
|
||||||
monkeypatch.setenv("GEOINTEL_AUTH_PASSWORD_HASH", password_hash)
|
monkeypatch.setenv("GEOINTEL_AUTH_PASSWORD_HASH", password_hash)
|
||||||
monkeypatch.setenv("GEOINTEL_AUTH_SESSION_SECRET", "test-session-secret-that-is-long-enough")
|
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())
|
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:
|
def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) -> None:
|
||||||
client = auth_client(monkeypatch)
|
client = auth_client(monkeypatch)
|
||||||
|
|
||||||
@@ -34,6 +78,9 @@ def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) ->
|
|||||||
"authenticated": False,
|
"authenticated": False,
|
||||||
"username": None,
|
"username": None,
|
||||||
"expires_at": None,
|
"expires_at": None,
|
||||||
|
"role": None,
|
||||||
|
"guest_access_enabled": False,
|
||||||
|
"guest_project_id": None,
|
||||||
}
|
}
|
||||||
assert protected.status_code == 401
|
assert protected.status_code == 401
|
||||||
assert protected.json()["error"] == "AUTHENTICATION_REQUIRED"
|
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:
|
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(
|
invalid = client.post(
|
||||||
"/api/v1/auth/login",
|
"/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.status_code == 401
|
||||||
assert invalid.json()["error"] == "INVALID_CREDENTIALS"
|
assert invalid.json()["error"] == "INVALID_CREDENTIALS"
|
||||||
assert login.status_code == 200
|
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()
|
cookie = login.headers["set-cookie"].lower()
|
||||||
assert "httponly" in cookie
|
assert "httponly" in cookie
|
||||||
assert "samesite=strict" in cookie
|
assert "samesite=strict" in cookie
|
||||||
assert authenticated.json()["data"]["authenticated"] is True
|
assert authenticated.json()["data"]["authenticated"] is True
|
||||||
|
assert authenticated.json()["data"]["role"] == "operator"
|
||||||
assert protected_after_login.status_code == 404
|
assert protected_after_login.status_code == 404
|
||||||
assert logout.status_code == 200
|
assert logout.status_code == 200
|
||||||
|
assert logout.json()["data"]["guest_access_enabled"] is True
|
||||||
assert protected_after_logout.status_code == 401
|
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:
|
def test_password_hash_and_session_signatures_fail_closed(monkeypatch) -> None:
|
||||||
client = auth_client(monkeypatch)
|
client = auth_client(monkeypatch)
|
||||||
login = client.post(
|
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 '-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH"' in runner
|
||||||
assert "GEOINTEL_AUTH_PASSWORD_HASH=" in example
|
assert "GEOINTEL_AUTH_PASSWORD_HASH=" in example
|
||||||
assert "GEOINTEL_AUTH_PASSWORD=" not in runner
|
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
|
assert "/api/v1/auth/session" in browser_smoke
|
||||||
|
|||||||
@@ -86,6 +86,24 @@ the backend remain available to trusted in-container operator scripts.
|
|||||||
Production startup fails before replacing the active container when the
|
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.
|
||||||
|
|
||||||
|
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 repository deploy scripts run the same flow automatically. They validate
|
||||||
the Compose reference, preserve the current image as
|
the Compose reference, preserve the current image as
|
||||||
`geointel-all-in-one:previous`, build an immutable `<commit-sha>-ai` or
|
`geointel-all-in-one:previous`, build an immutable `<commit-sha>-ai` or
|
||||||
|
|||||||
@@ -37,6 +37,9 @@
|
|||||||
<Config Name="Operator Password Hash" Target="GEOINTEL_AUTH_PASSWORD_HASH" Default="" Mode="" Description="PBKDF2-SHA256 password hash. Never enter a plaintext password." Type="Variable" Display="advanced" Required="false" Mask="true"></Config>
|
<Config Name="Operator 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 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="Operator Session TTL" Target="GEOINTEL_AUTH_SESSION_TTL_SECONDS" Default="43200" Mode="" Description="Session lifetime in seconds (900-604800)." Type="Variable" Display="advanced" Required="true" Mask="false">43200</Config>
|
||||||
|
<Config Name="Guest Demo Enabled" Target="GEOINTEL_GUEST_ACCESS_ENABLED" Default="true" Mode="" Description="Show a guest button that opens only the seeded, restricted demo workspace. Enabled by default when operator login is active; set false on non-demo instances." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
|
||||||
|
<Config Name="Guest Display Name" Target="GEOINTEL_GUEST_DISPLAY_NAME" Default="Gast" Mode="" Description="Label shown for the temporary guest session." Type="Variable" Display="advanced" Required="true" Mask="false">Gast</Config>
|
||||||
|
<Config Name="Guest Session TTL" Target="GEOINTEL_GUEST_SESSION_TTL_SECONDS" Default="7200" Mode="" Description="Temporary guest session lifetime in seconds (900-86400)." Type="Variable" Display="advanced" Required="true" Mask="false">7200</Config>
|
||||||
<Config Name="Official Orthophoto Acquisition" Target="ORTHOPHOTO_ENABLED" Default="true" Mode="" Description="Allow explicit bounded map selections to request the official Digitaal Vlaanderen orthophoto WMS." Type="Variable" Display="advanced" Required="true" Mask="false">true</Config>
|
<Config Name="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="SPW Orthophoto WMS URL" Target="SPW_ORTHOPHOTO_WMS_URL" Default="https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer" Mode="" Description="Official SPW latest Walloon orthophoto WMS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer</Config>
|
<Config Name="SPW Orthophoto WMS URL" Target="SPW_ORTHOPHOTO_WMS_URL" Default="https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer" Mode="" Description="Official SPW latest Walloon orthophoto WMS endpoint." Type="Variable" Display="advanced" Required="true" Mask="false">https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer</Config>
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ GEOINTEL_AUTH_PASSWORD_HASH=
|
|||||||
GEOINTEL_AUTH_SESSION_SECRET=
|
GEOINTEL_AUTH_SESSION_SECRET=
|
||||||
GEOINTEL_AUTH_SESSION_TTL_SECONDS=43200
|
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.
|
# 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
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ GEOINTEL_AUTH_USERNAME="${GEOINTEL_AUTH_USERNAME:-}"
|
|||||||
GEOINTEL_AUTH_PASSWORD_HASH="${GEOINTEL_AUTH_PASSWORD_HASH:-}"
|
GEOINTEL_AUTH_PASSWORD_HASH="${GEOINTEL_AUTH_PASSWORD_HASH:-}"
|
||||||
GEOINTEL_AUTH_SESSION_SECRET="${GEOINTEL_AUTH_SESSION_SECRET:-}"
|
GEOINTEL_AUTH_SESSION_SECRET="${GEOINTEL_AUTH_SESSION_SECRET:-}"
|
||||||
GEOINTEL_AUTH_SESSION_TTL_SECONDS="${GEOINTEL_AUTH_SESSION_TTL_SECONDS:-43200}"
|
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_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}"
|
||||||
SPW_ORTHOPHOTO_WMS_URL="${SPW_ORTHOPHOTO_WMS_URL:-https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer}"
|
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
|
esac
|
||||||
fi
|
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
|
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
|
||||||
@@ -285,6 +310,9 @@ docker run -d \
|
|||||||
-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH" \
|
-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH" \
|
||||||
-e GEOINTEL_AUTH_SESSION_SECRET="$GEOINTEL_AUTH_SESSION_SECRET" \
|
-e GEOINTEL_AUTH_SESSION_SECRET="$GEOINTEL_AUTH_SESSION_SECRET" \
|
||||||
-e GEOINTEL_AUTH_SESSION_TTL_SECONDS="$GEOINTEL_AUTH_SESSION_TTL_SECONDS" \
|
-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_ENABLED="$ORTHOPHOTO_ENABLED" \
|
||||||
-e ORTHOPHOTO_WMS_URL="$ORTHOPHOTO_WMS_URL" \
|
-e ORTHOPHOTO_WMS_URL="$ORTHOPHOTO_WMS_URL" \
|
||||||
-e SPW_ORTHOPHOTO_WMS_URL="$SPW_ORTHOPHOTO_WMS_URL" \
|
-e SPW_ORTHOPHOTO_WMS_URL="$SPW_ORTHOPHOTO_WMS_URL" \
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ services:
|
|||||||
GEOINTEL_AOI_WORKER_POLL_SECONDS: ${GEOINTEL_AOI_WORKER_POLL_SECONDS:-2}
|
GEOINTEL_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_CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}
|
||||||
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}
|
||||||
|
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_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}
|
||||||
SPW_ORTHOPHOTO_WMS_URL: ${SPW_ORTHOPHOTO_WMS_URL:-https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer}
|
SPW_ORTHOPHOTO_WMS_URL: ${SPW_ORTHOPHOTO_WMS_URL:-https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer}
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ services:
|
|||||||
STORAGE_ROOT: /app/storage
|
STORAGE_ROOT: /app/storage
|
||||||
CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}
|
CORS_ORIGINS: ${GEOINTEL_CORS_ORIGINS:-http://localhost:1202,http://127.0.0.1:1202}
|
||||||
MAX_UPLOAD_MB: ${GEOINTEL_MAX_UPLOAD_MB:-500}
|
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_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}
|
||||||
SPW_ORTHOPHOTO_WMS_URL: ${SPW_ORTHOPHOTO_WMS_URL:-https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer}
|
SPW_ORTHOPHOTO_WMS_URL: ${SPW_ORTHOPHOTO_WMS_URL:-https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer}
|
||||||
|
|||||||
+48
-14
@@ -45,27 +45,37 @@ Any valid GeoJSON geometry object. V1 primarily expects `Polygon` and `MultiPoly
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Operator authentication
|
## Operator authentication and guest demo
|
||||||
|
|
||||||
Authentication is an optional single-operator access gate, not multi-user
|
Authentication remains an optional single-operator access gate, not multi-user
|
||||||
account management. When `GEOINTEL_AUTH_ENABLED=true`, every `/api/v1/*`
|
account management or tenant isolation. When `GEOINTEL_AUTH_ENABLED=true`,
|
||||||
request except the three authentication endpoints below requires a valid
|
every `/api/v1/*` request except the four authentication endpoints below
|
||||||
signed `geointel_session` cookie. Missing, expired or modified sessions return
|
requires a valid signed `geointel_session` cookie. Missing, expired or modified
|
||||||
HTTP 401 with `AUTHENTICATION_REQUIRED`. Direct loopback calls to the backend
|
sessions return HTTP 401 with `AUTHENTICATION_REQUIRED`. Direct loopback calls
|
||||||
without proxy headers remain available to trusted in-container operator tools;
|
to the backend without proxy headers remain available to trusted in-container
|
||||||
the backend is bound to loopback in the all-in-one runtime.
|
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
|
The runtime stores only a PBKDF2-SHA256 operator password hash and an
|
||||||
session-signing secret. The browser receives an HttpOnly, SameSite=Strict,
|
independent session-signing secret. The browser receives an HttpOnly,
|
||||||
time-limited cookie. Five failed attempts for one client/username combination
|
SameSite=Strict, time-limited cookie. Five failed operator-login attempts for
|
||||||
within five minutes temporarily return HTTP 429 `LOGIN_RATE_LIMITED`.
|
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`
|
### GET `/api/v1/auth/session`
|
||||||
|
|
||||||
Public session probe used by the frontend before it mounts the workbench.
|
Public session probe used by the frontend before it mounts the workbench.
|
||||||
When authentication is disabled, `authenticated` is true and
|
When authentication is disabled, `authenticated` is true and
|
||||||
`authentication_required` is false so local development retains its existing
|
`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
|
```json
|
||||||
{
|
{
|
||||||
@@ -73,11 +83,18 @@ direct workflow.
|
|||||||
"authentication_required": true,
|
"authentication_required": true,
|
||||||
"authenticated": false,
|
"authenticated": false,
|
||||||
"username": null,
|
"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`
|
### POST `/api/v1/auth/login`
|
||||||
|
|
||||||
```json
|
```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
|
shape. Invalid credentials return HTTP 401 `INVALID_CREDENTIALS`; username
|
||||||
existence is not disclosed.
|
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`
|
### POST `/api/v1/auth/logout`
|
||||||
|
|
||||||
Clears the browser cookie and returns an unauthenticated session. Logout is
|
Clears the browser cookie and returns an unauthenticated session. Logout is
|
||||||
|
|||||||
@@ -1,3 +1,34 @@
|
|||||||
|
## 2026-07-27 - Guest demo and product professionalization
|
||||||
|
|
||||||
|
- Audited the access experience, workbench information density, responsive
|
||||||
|
layout and accumulated frontend styling; recorded findings in
|
||||||
|
`docs/PROJECT_PROFESSIONALIZATION_AUDIT_2026-07-27.md`.
|
||||||
|
- Added configuration-gated guest access with a short signed session, explicit
|
||||||
|
guest role, demo-project scope, filtered project listing and backend-enforced
|
||||||
|
read-only/cross-project restrictions.
|
||||||
|
- Rebuilt the landing and login hierarchy, added **Als gast verkennen**, mapped
|
||||||
|
authentication failures to user-facing Dutch messages and improved mobile
|
||||||
|
navigation and accessibility states.
|
||||||
|
- Reduced the guest workbench to map exploration and existing quality evidence,
|
||||||
|
added persistent demo context and removed operator-only controls from the
|
||||||
|
guest surface.
|
||||||
|
- Added targeted final layout overrides instead of destructively rewriting the
|
||||||
|
four historical workbench stylesheets without a complete visual-regression
|
||||||
|
baseline.
|
||||||
|
- Added Compose, Unraid, DockerMan and runtime validation settings for guest
|
||||||
|
enablement. A later packaging follow-up changed the default to enabled whenever
|
||||||
|
operator authentication is active; installations can still opt out explicitly.
|
||||||
|
- Validation: 6/6 targeted backend auth/guest tests and 2/2 direct frontend
|
||||||
|
interaction smokes passed; Python compile, complete frontend TypeScript
|
||||||
|
typecheck, CSS parsing, Compose YAML, Unraid XML, DockerMan shell syntax and
|
||||||
|
scoped diff-whitespace checks passed.
|
||||||
|
- Environment boundary: the supplied frontend dependency tree contains only
|
||||||
|
Windows-native Rollup/esbuild packages. Vitest and Vite therefore could not
|
||||||
|
start in this Linux review container, and the available package proxy returned
|
||||||
|
503 responses/time-outs while fetching Linux replacements. Re-run unit tests
|
||||||
|
and the production bundle after a clean `npm ci` in the normal Windows or
|
||||||
|
Linux CI/Docker environment.
|
||||||
|
|
||||||
## 2026-07-26 - Complete Belgium PyTorch training roadmap
|
## 2026-07-26 - Complete Belgium PyTorch training roadmap
|
||||||
|
|
||||||
- Added `docs/PYTORCH_TRAINING_ROADMAP_BELGIUM.md` as the executable programme board.
|
- Added `docs/PYTORCH_TRAINING_ROADMAP_BELGIUM.md` as the executable programme board.
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ CRITICAL or HIGH release defect. Coverage and capability responses remain the
|
|||||||
runtime source of truth.
|
runtime source of truth.
|
||||||
|
|
||||||
- The access gate intentionally supports one environment-configured operator
|
- The access gate intentionally supports one environment-configured operator
|
||||||
account. There is no registration, password-recovery email, role model,
|
account. There is no registration, password-recovery email, organisation
|
||||||
organisation management or multi-user database. Password rotation is an
|
management or multi-user database. Optional guest access is a short-lived,
|
||||||
operator configuration action followed by a runtime restart.
|
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
|
## Source coverage
|
||||||
|
|
||||||
@@ -72,8 +74,8 @@ runtime source of truth.
|
|||||||
- Long AI/GIS work still uses the existing synchronous job abstraction rather
|
- Long AI/GIS work still uses the existing synchronous job abstraction rather
|
||||||
than a distributed durable queue. Interrupted synchronous work is marked
|
than a distributed durable queue. Interrupted synchronous work is marked
|
||||||
failed on restart and must be retried explicitly.
|
failed on restart and must be retried explicitly.
|
||||||
- GeoIntel RC is a controlled single-operator deployment. Authentication,
|
- GeoIntel remains a controlled single-operator product. The optional guest
|
||||||
multi-user authorization and tenant isolation are outside the frozen RC
|
demo adds bounded presentation access only; multi-user authorization and
|
||||||
scope.
|
tenant isolation remain outside the product scope.
|
||||||
- Cleanup remains manual, dry-run-first and confirmation-gated. No automatic
|
- Cleanup remains manual, dry-run-first and confirmation-gated. No automatic
|
||||||
retention schedule is installed.
|
retention schedule is installed.
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# GeoIntel professionaliseringsaudit — 27 juli 2026
|
||||||
|
|
||||||
|
## Managementsamenvatting
|
||||||
|
|
||||||
|
GeoIntel is inhoudelijk veel sterker dan de eerste visuele indruk deed
|
||||||
|
vermoeden. De repository bevat een volwassen, documentatiegestuurde
|
||||||
|
GIS-architectuur, expliciete bron- en provenancecontracten, uitgebreide
|
||||||
|
kwaliteitscontrole en een product dat bewust geen resultaten fabriceert wanneer
|
||||||
|
brondata of modellen ontbreken. De grootste productrisico's zaten niet in de
|
||||||
|
GIS-kern, maar in de toegangservaring, de presentatie van de functiedichtheid
|
||||||
|
en de gegroeide frontend-stijllagen.
|
||||||
|
|
||||||
|
Deze pass professionaliseert de eerste gebruikerservaring en voegt een veilige
|
||||||
|
gastdemonstratie toe. Een bezoeker kan nu rechtstreeks vanaf de landingspagina
|
||||||
|
een tijdelijke demowerkruimte openen. Die sessie is server-side aan één
|
||||||
|
voorbeeldproject gebonden, heeft een kortere levensduur en kan geen
|
||||||
|
operatorwijzigingen uitvoeren. De interface toont in gastmodus alleen de kaart
|
||||||
|
en bestaand kwaliteitsbewijs.
|
||||||
|
|
||||||
|
## Wat al sterk was
|
||||||
|
|
||||||
|
- **Inhoudelijke geloofwaardigheid.** Officiële bronnen, meeteenheden, CRS,
|
||||||
|
dekking, beperkingen en provenance worden als productgegevens behandeld en
|
||||||
|
niet als decoratieve metadata.
|
||||||
|
- **Fail-closed gedrag.** Niet-geconfigureerde bronnen en modellen worden niet
|
||||||
|
stilzwijgend vervangen door fixtures of gesimuleerd succes.
|
||||||
|
- **Map-first productmodel.** Project, gebied, dataset, analyse en QA delen een
|
||||||
|
ruimtelijke context, wat veel sterker is dan een verzameling losse dashboards.
|
||||||
|
- **Operationele discipline.** De repository bevat releasegates, Unraid-assets,
|
||||||
|
migraties, herstelpaden, tests en expliciete scope-/beperkingsdocumentatie.
|
||||||
|
- **Bestaande demofundering.** Het idempotente demoworkflowcontract maakte een
|
||||||
|
gecontroleerde gastbeleving mogelijk zonder een tweede fictieve applicatie te
|
||||||
|
bouwen.
|
||||||
|
|
||||||
|
## Belangrijkste bevindingen
|
||||||
|
|
||||||
|
### P0 — Er ontbrak een toegankelijke productdemo
|
||||||
|
|
||||||
|
De oorspronkelijke ingang bood alleen een operatorlogin. Voor een recruiter,
|
||||||
|
stakeholder of eerste beoordelaar was daardoor niet zichtbaar wat het platform
|
||||||
|
kan zonder vooraf accounts of wachtwoorden uit te wisselen. Een onbegrensde
|
||||||
|
“login zonder wachtwoord” zou echter toegang tot operationele functies hebben
|
||||||
|
gegeven.
|
||||||
|
|
||||||
|
**Oplossing:** een config-gated `POST /api/v1/auth/guest`, een gesigneerde
|
||||||
|
gastrol met projectscope, server-side mutatieblokkering en een expliciete knop
|
||||||
|
**Als gast verkennen**. De demo wordt bij openen idempotent voorbereid.
|
||||||
|
|
||||||
|
### P1 — De landingspagina communiceerde de productwaarde onvoldoende snel
|
||||||
|
|
||||||
|
De informatie was aanwezig, maar de primaire actie, productbelofte,
|
||||||
|
betrouwbaarheidssignalen en demonstratiemogelijkheid concurreerden visueel met
|
||||||
|
elkaar. Op kleinere schermen voelde de ingang langer en minder doelgericht.
|
||||||
|
|
||||||
|
**Oplossing:** nieuwe hero- en loginhiërarchie, heldere keuze tussen operator en
|
||||||
|
gast, compactere capability-sectie, concreter vierstappenproces, betere mobiele
|
||||||
|
navigatie en begrijpelijke foutmeldingen in plaats van ruwe servicefouten.
|
||||||
|
|
||||||
|
### P1 — De workbench was voor een gast te breed en te technisch
|
||||||
|
|
||||||
|
De volledige operatornavigatie bevat projectbeheer, imports, AI-taken, exports
|
||||||
|
en geavanceerde analyses. Dat is gepast voor een beheerder, maar werkt tegen een
|
||||||
|
snelle demonstratie.
|
||||||
|
|
||||||
|
**Oplossing:** de gastrol ziet alleen **Kaart** en **Kwaliteit**, krijgt een
|
||||||
|
blijvende alleen-lezen contextbanner en ziet geen creatie-, import-, export-,
|
||||||
|
AI- of beheeracties. De backend blijft de autoritatieve grens.
|
||||||
|
|
||||||
|
### P1 — De visuele laag is historisch gegroeid
|
||||||
|
|
||||||
|
Vier opeenvolgende workbench-stijlbestanden bevatten samen 13.818 regels CSS:
|
||||||
|
`app.css`, `premium.css`, `atlas-workbench.css` en `atlas-premium-v2.css`. Over
|
||||||
|
de volledige actieve stijllaag zijn tientallen mediaqueries aanwezig. Dat
|
||||||
|
verhoogt de kans op cascadeconflicten, onverwachte responsive afwijkingen en
|
||||||
|
onnodig moeilijke toekomstige aanpassingen.
|
||||||
|
|
||||||
|
**Oplossing in deze pass:** een kleine, als laatste geladen
|
||||||
|
`professionalization.css` met gerichte correcties voor navigatierail, contextbalk,
|
||||||
|
werkruimtehoogte, gaststatus, truncation en responsive gedrag. De historische
|
||||||
|
lagen zijn bewust niet massaal herschreven zonder volledige visuele
|
||||||
|
regressiebaseline.
|
||||||
|
|
||||||
|
**Aanbevolen vervolgstap:** component voor component consolideren naar tokens,
|
||||||
|
layout primitives en één stylesheet per functioneel domein, telkens beschermd
|
||||||
|
door desktop-, ultrawide- en mobiele screenshots.
|
||||||
|
|
||||||
|
### P2 — Twee frontendcomponenten dragen te veel verantwoordelijkheid
|
||||||
|
|
||||||
|
`App.tsx` telt circa 1.400 regels en `MapWorkspace.tsx` circa 3.900 regels. Dat
|
||||||
|
is nog werkbaar, maar maakt layout-, permissie- en interactiewijzigingen
|
||||||
|
risicovoller dan nodig.
|
||||||
|
|
||||||
|
**Aanbevolen vervolgstap:** splits shell/navigatie, workspace-routing,
|
||||||
|
gastsessiecontext, kaartselectie, bronresolutie en analysepresentatie in
|
||||||
|
afzonderlijke domeincomponenten en hooks. Doe dit pas na de huidige
|
||||||
|
regressietests, zodat gedrag niet tegelijk met structuur wordt gewijzigd.
|
||||||
|
|
||||||
|
### P2 — Gastmodus is geen tenantisolatie
|
||||||
|
|
||||||
|
De sessie is cryptografisch gesigneerd, kort geldig, projectgebonden en
|
||||||
|
alleen-lezen. Toch blijft GeoIntel architecturaal een single-operatorproduct. De
|
||||||
|
gastrol is bedoeld voor een aparte demo-installatie, niet om operationele en
|
||||||
|
publieke gebruikers veilig in dezelfde datastore te mengen.
|
||||||
|
|
||||||
|
## Geleverde wijzigingen
|
||||||
|
|
||||||
|
| Domein | Professionalisering |
|
||||||
|
|---|---|
|
||||||
|
| Toegang | Nieuwe gastactie, wachtwoordzichtbaarheid, heldere operator/gastkeuze en bruikbare foutmeldingen |
|
||||||
|
| Sessies | Versie 2-sessietoken met expliciete `operator`/`guest`-rol, TTL en optionele projectscope |
|
||||||
|
| Backendgrens | Positieve read-allowlist, gastprojectfilter, cross-projectblokkering en mutatieblokkering met stabiele foutcodes |
|
||||||
|
| Demo | Canonieke demoworkflow wordt idempotent voorbereid bij gastlogin |
|
||||||
|
| Workbench | Gereduceerde gastnavigatie, alleen-lezen statusbanner en verborgen beheerfuncties |
|
||||||
|
| Kaart | Alleen-lezen variant zonder on-demand acquisitie of geavanceerde operatorcontrole |
|
||||||
|
| Layout | Rustigere desktop-shell, betere truncation, responsieve gaststatus en reduced-motion ondersteuning |
|
||||||
|
| Deployment | Gastvariabelen in Compose, Unraid-env, DockerMan-template en runtimevalidatie |
|
||||||
|
| Documentatie | API-contract, README, Unraid-instructies, beperkingen, TODO en uitvoeringslog bijgewerkt |
|
||||||
|
|
||||||
|
## Configuratie
|
||||||
|
|
||||||
|
Gasttoegang staat standaard ingeschakeld zodra de operator-login actief is.
|
||||||
|
Voor een afzonderlijke demo-installatie:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GEOINTEL_AUTH_ENABLED=true
|
||||||
|
GEOINTEL_AUTH_USERNAME=operator
|
||||||
|
GEOINTEL_AUTH_PASSWORD_HASH=pbkdf2_sha256$...
|
||||||
|
GEOINTEL_AUTH_SESSION_SECRET=<minstens-32-willekeurige-tekens>
|
||||||
|
GEOINTEL_GUEST_ACCESS_ENABLED=true
|
||||||
|
GEOINTEL_GUEST_DISPLAY_NAME=Gast
|
||||||
|
GEOINTEL_GUEST_SESSION_TTL_SECONDS=7200
|
||||||
|
```
|
||||||
|
|
||||||
|
Zet `GEOINTEL_GUEST_ACCESS_ENABLED=false` om gasttoegang expliciet uit te schakelen.
|
||||||
|
Gebruik een afzonderlijke container, database en storage-root wanneer de demo
|
||||||
|
van buiten het vertrouwde LAN bereikbaar wordt. Plaats geen private, klant- of
|
||||||
|
operationele datasets in die omgeving.
|
||||||
|
|
||||||
|
## Validatie
|
||||||
|
|
||||||
|
De volgende controles zijn op 27 juli 2026 uitgevoerd:
|
||||||
|
|
||||||
|
| Controle | Resultaat |
|
||||||
|
|---|---|
|
||||||
|
| Gerichte backend auth-/gastbeveiligingstests | **Geslaagd — 6/6** |
|
||||||
|
| Python compile van `backend/app` en de nieuwe authtests | **Geslaagd** |
|
||||||
|
| Volledige frontend TypeScript-typecheck | **Geslaagd** |
|
||||||
|
| Gerichte frontend-interactiesmokes: gast/operator-login en beperkte bootstrap | **Geslaagd — 2/2** |
|
||||||
|
| CSS-syntax van de vernieuwde landing en professionaliseringslaag | **Geslaagd** |
|
||||||
|
| Compose YAML, Unraid XML en DockerMan-shellsyntax | **Geslaagd** |
|
||||||
|
| Whitespacecontrole op alle in deze pass gewijzigde bestanden | **Geslaagd** |
|
||||||
|
|
||||||
|
De gerichte backendtests draaiden met SQLite en een tijdelijke minimale
|
||||||
|
`geoalchemy2`-importstub buiten de repository, omdat de reviewcontainer de
|
||||||
|
PostGIS-runtimepackages niet bevatte. Daarmee zijn tokenvalidatie, cookies,
|
||||||
|
login/logout, gastscope, cross-projectblokkering en route-/mutatieblokkering
|
||||||
|
wel rechtstreeks getest; het is geen vervanging voor de bestaande volledige
|
||||||
|
PostgreSQL/PostGIS-integratiegate.
|
||||||
|
|
||||||
|
De nieuwe logincomponent en de beperkte workbench-bootstrap zijn aanvullend
|
||||||
|
rechtstreeks in JSDOM uitgevoerd via een tijdelijke TypeScript-loader buiten de
|
||||||
|
repository. Daarmee zijn de zichtbaarheid van gastacties, de `POST` naar de
|
||||||
|
gastendpoint, de operatorlogin en het uitschakelen van operator-only
|
||||||
|
bootstrapcalls interactief gecontroleerd.
|
||||||
|
|
||||||
|
De aangeleverde `node_modules` bevat alleen Windows-native Rollup- en
|
||||||
|
esbuildpakketten. Daardoor konden de normale Vitest-runner en de
|
||||||
|
Vite-productiebundel in deze Linux-reviewcontainer niet starten. Een schone
|
||||||
|
dependency-installatie was niet mogelijk doordat de beschikbare packageproxy
|
||||||
|
tijdens de controle 503-responses en time-outs gaf. De TypeScript-compiler voltooide wel zonder fouten. De
|
||||||
|
frontend-unit- en productiebuildgates moeten daarom na `npm ci` op Windows of
|
||||||
|
in de normale Linux CI-/Dockeromgeving nogmaals worden uitgevoerd.
|
||||||
|
|
||||||
|
## Aanbevolen roadmap
|
||||||
|
|
||||||
|
1. Leg visuele regressiesnapshots vast voor login, kaart, kwaliteit en alle
|
||||||
|
primaire workspaces op mobiel, desktop en ultrawide.
|
||||||
|
2. Consolideer de vier historische workbench-CSS-lagen incrementeel; verwijder
|
||||||
|
pas selectors nadat screenshots en interactietests gelijkwaardig zijn.
|
||||||
|
3. Splits `App.tsx` en `MapWorkspace.tsx` langs domeingrenzen, zonder API- of
|
||||||
|
analysegedrag te wijzigen.
|
||||||
|
4. Voeg een expliciete demo-reset/refreshstrategie en misbruiktelemetrie toe
|
||||||
|
wanneer de demo publiek wordt blootgesteld.
|
||||||
|
5. Bouw alleen bij echte multi-userbehoefte een afzonderlijk identiteits-,
|
||||||
|
autorisatie- en tenantmodel; breid gastmodus daar niet ad hoc voor uit.
|
||||||
@@ -13,6 +13,21 @@ Uitvoeringsbord: `docs/PYTORCH_TRAINING_ROADMAP_BELGIUM.md`.
|
|||||||
|
|
||||||
## Actieve post-RC datadekkingsfase
|
## 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] 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.
|
- [x] Geef alle primaire werkruimtes een eigen geanimeerde signaalillustratie, verbeter lege toestanden en verwijder de dubbele kaartfoutmelding bij sessieverval.
|
||||||
|
|
||||||
|
|||||||
+84
-17
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
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-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'
|
||||||
@@ -10,6 +10,7 @@ import './styles/app.css'
|
|||||||
import './styles/premium.css'
|
import './styles/premium.css'
|
||||||
import './styles/atlas-workbench.css'
|
import './styles/atlas-workbench.css'
|
||||||
import './styles/atlas-premium-v2.css'
|
import './styles/atlas-premium-v2.css'
|
||||||
|
import './styles/professionalization.css'
|
||||||
import { LandingPage } from './components/auth/LandingPage'
|
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'
|
||||||
@@ -86,20 +87,39 @@ const workspaceNavGroups: WorkspaceNavigationGroup[] = [
|
|||||||
{ label: 'Beheer', keys: ['overview', 'system'] },
|
{ label: 'Beheer', keys: ['overview', 'system'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const guestWorkspaceKeys = new Set<WorkspaceKey>(['map', 'analysis'])
|
||||||
|
const guestWorkspaceGroups: WorkspaceNavigationGroup[] = [
|
||||||
|
{ label: 'Demowerkruimte', keys: ['map', 'analysis'] },
|
||||||
|
]
|
||||||
|
|
||||||
interface WorkbenchAppProps {
|
interface WorkbenchAppProps {
|
||||||
username: string | null
|
username: string | null
|
||||||
|
accessMode: 'open' | 'operator' | 'guest'
|
||||||
loggingOut: boolean
|
loggingOut: boolean
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JSX.Element {
|
function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchAppProps): JSX.Element {
|
||||||
|
const isGuest = accessMode === 'guest'
|
||||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')
|
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')
|
||||||
const [inspectorOpen, setInspectorOpen] = useState(false)
|
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 [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset')
|
||||||
const [mapContextSourceLabel, setMapContextSourceLabel] = useState<string | null>(null)
|
const [mapContextSourceLabel, setMapContextSourceLabel] = useState<string | null>(null)
|
||||||
const [mapContextLayerLabel, setMapContextLayerLabel] = useState<string | null>(null)
|
const [mapContextLayerLabel, setMapContextLayerLabel] = useState<string | null>(null)
|
||||||
const workbenchMainRef = useRef<HTMLElement | null>(null)
|
const workbenchMainRef = useRef<HTMLElement | null>(null)
|
||||||
const previousWorkspaceRef = useRef<WorkspaceKey>(activeWorkspace)
|
const previousWorkspaceRef = useRef<WorkspaceKey>(activeWorkspace)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isGuest && !guestWorkspaceKeys.has(activeWorkspace)) {
|
||||||
|
setActiveWorkspace('map')
|
||||||
|
}
|
||||||
|
}, [activeWorkspace, isGuest])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })
|
workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })
|
||||||
if (previousWorkspaceRef.current !== activeWorkspace) {
|
if (previousWorkspaceRef.current !== activeWorkspace) {
|
||||||
@@ -566,6 +586,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
demoWorkflowMessage,
|
demoWorkflowMessage,
|
||||||
loadDemoWorkflow,
|
loadDemoWorkflow,
|
||||||
} = useDemoWorkflow({
|
} = useDemoWorkflow({
|
||||||
|
restrictedMode: isGuest,
|
||||||
loadProjects,
|
loadProjects,
|
||||||
loadProjectData,
|
loadProjectData,
|
||||||
loadDatasetDetails,
|
loadDatasetDetails,
|
||||||
@@ -586,7 +607,15 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
setErrorMessage,
|
setErrorMessage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGuest || guestDemoStartedRef.current) return
|
||||||
|
guestDemoStartedRef.current = true
|
||||||
|
setGuestDemoReady(false)
|
||||||
|
void loadDemoWorkflow().finally(() => setGuestDemoReady(true))
|
||||||
|
}, [isGuest, loadDemoWorkflow])
|
||||||
|
|
||||||
useWorkbenchBootstrap({
|
useWorkbenchBootstrap({
|
||||||
|
restrictedMode: isGuest,
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
selectedDetectionRunId,
|
selectedDetectionRunId,
|
||||||
detectionClassFilter,
|
detectionClassFilter,
|
||||||
@@ -613,7 +642,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
})
|
})
|
||||||
|
|
||||||
const selectedArea = areas.find((area) => area.id === selectedMapAreaId) ?? null
|
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(() => {
|
const mapLayerSourceLabel = useMemo(() => {
|
||||||
if (analysisMapLayerActive && changeDetectionResult?.geojson) {
|
if (analysisMapLayerActive && changeDetectionResult?.geojson) {
|
||||||
return 'Veranderingsanalyse'
|
return 'Veranderingsanalyse'
|
||||||
@@ -687,6 +716,10 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
setActiveWorkspace('exports')
|
setActiveWorkspace('exports')
|
||||||
}
|
}
|
||||||
const openWorkflowGuidanceStep = (target: WorkspaceKey) => {
|
const openWorkflowGuidanceStep = (target: WorkspaceKey) => {
|
||||||
|
if (isGuest && !guestWorkspaceKeys.has(target)) {
|
||||||
|
setActiveWorkspace(target === 'exports' ? 'analysis' : 'map')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (target === 'map' && availableMapDatasets.length > 0 && !mapFeatureCollection) {
|
if (target === 'map' && availableMapDatasets.length > 0 && !mapFeatureCollection) {
|
||||||
openDatasetInMap(availableMapDatasets[0])
|
openDatasetInMap(availableMapDatasets[0])
|
||||||
return
|
return
|
||||||
@@ -781,7 +814,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
: 'Geen actieve laag'
|
: 'Geen actieve laag'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell workbench-shell">
|
<div className={isGuest ? "app-shell workbench-shell workbench-shell-guest" : "app-shell workbench-shell"}>
|
||||||
<a
|
<a
|
||||||
className="skip-link"
|
className="skip-link"
|
||||||
href="#workspace-main"
|
href="#workspace-main"
|
||||||
@@ -805,8 +838,8 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
<div className="workbench-layout">
|
<div className="workbench-layout">
|
||||||
<WorkbenchNavigation
|
<WorkbenchNavigation
|
||||||
activeWorkspace={activeWorkspace}
|
activeWorkspace={activeWorkspace}
|
||||||
groups={workspaceNavGroups}
|
groups={visibleWorkspaceGroups}
|
||||||
items={workspaceNavItems}
|
items={visibleWorkspaceItems}
|
||||||
onSelect={setActiveWorkspace}
|
onSelect={setActiveWorkspace}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -838,18 +871,39 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
<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 ? (
|
{username || isGuest ? (
|
||||||
<div className="context-account" aria-label="Aangemelde gebruiker">
|
<div
|
||||||
<UserRound aria-hidden="true" />
|
className={isGuest ? 'context-account context-account-guest' : 'context-account'}
|
||||||
<span title={username}>{username}</span>
|
aria-label={isGuest ? 'Tijdelijke gastensessie' : 'Aangemelde gebruiker'}
|
||||||
|
>
|
||||||
|
{isGuest ? <ShieldCheck aria-hidden="true" /> : <UserRound aria-hidden="true" />}
|
||||||
|
<span title={username ?? undefined}>{isGuest ? 'Gastmodus' : username}</span>
|
||||||
<button type="button" onClick={onLogout} disabled={loggingOut}>
|
<button type="button" onClick={onLogout} disabled={loggingOut}>
|
||||||
<LogOut aria-hidden="true" />
|
<LogOut aria-hidden="true" />
|
||||||
<span>{loggingOut ? 'Uitloggen…' : 'Uitloggen'}</span>
|
<span>{loggingOut ? 'Afsluiten…' : isGuest ? 'Demo afsluiten' : 'Uitloggen'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{isGuest ? (
|
||||||
|
<div className="guest-mode-banner" role="status">
|
||||||
|
<ShieldCheck aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>Tijdelijke demowerkruimte</strong>
|
||||||
|
<span>U verkent vooraf geladen voorbeelddata. Wijzigingen, nieuwe analyses en operatorfuncties zijn uitgeschakeld.</span>
|
||||||
|
</div>
|
||||||
|
<span className="guest-mode-badge">Alleen-lezen</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isGuest && (!guestDemoReady || loadingDemoWorkflow) ? (
|
||||||
|
<div className="guest-demo-loading" role="status" aria-live="polite">
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
<strong>Demodata en kwaliteitsbewijs worden voorbereid…</strong>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="workbench-content">
|
<div className="workbench-content">
|
||||||
|
|
||||||
<main
|
<main
|
||||||
@@ -867,7 +921,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
<WorkspaceSignal workspace={activeWorkspace} />
|
<WorkspaceSignal workspace={activeWorkspace} />
|
||||||
<div className="workspace-heading-actions">
|
<div className="workspace-heading-actions">
|
||||||
<p>{activeWorkspaceItem.description}</p>
|
<p>{activeWorkspaceItem.description}</p>
|
||||||
{activeWorkspace !== 'overview' ? (
|
{!isGuest && activeWorkspace !== 'overview' ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={inspectorOpen ? 'inspector-toggle inspector-toggle-active' : 'inspector-toggle'}
|
className={inspectorOpen ? 'inspector-toggle inspector-toggle-active' : 'inspector-toggle'}
|
||||||
@@ -950,6 +1004,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
|
|
||||||
<div className="workspace-persistent-map" hidden={activeWorkspace !== 'map'}>
|
<div className="workspace-persistent-map" hidden={activeWorkspace !== 'map'}>
|
||||||
<MapWorkspace
|
<MapWorkspace
|
||||||
|
readOnly={isGuest}
|
||||||
selectedProjectId={selectedProjectId}
|
selectedProjectId={selectedProjectId}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
areas={areas}
|
areas={areas}
|
||||||
@@ -1045,8 +1100,8 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
onRefreshProjectData={() => (
|
onRefreshProjectData={() => (
|
||||||
selectedProjectId ? loadProjectData(selectedProjectId) : Promise.resolve(null)
|
selectedProjectId ? loadProjectData(selectedProjectId) : Promise.resolve(null)
|
||||||
)}
|
)}
|
||||||
onOpenAssistant={() => setActiveWorkspace('assistant')}
|
onOpenAssistant={() => setActiveWorkspace(isGuest ? 'analysis' : 'assistant')}
|
||||||
onOpenExports={() => setActiveWorkspace('exports')}
|
onOpenExports={() => setActiveWorkspace(isGuest ? 'analysis' : 'exports')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1063,8 +1118,9 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
evidenceLoading={qualityEvidenceLoading}
|
evidenceLoading={qualityEvidenceLoading}
|
||||||
evidenceError={qualityEvidenceError}
|
evidenceError={qualityEvidenceError}
|
||||||
onOpenMapWorkspace={() => setActiveWorkspace('map')}
|
onOpenMapWorkspace={() => setActiveWorkspace('map')}
|
||||||
onOpenAnalysisWorkspace={() => setActiveWorkspace('ai')}
|
onOpenAnalysisWorkspace={() => setActiveWorkspace(isGuest ? 'map' : 'ai')}
|
||||||
/>
|
/>
|
||||||
|
{!isGuest ? (
|
||||||
<details className="secondary-analysis-disclosure">
|
<details className="secondary-analysis-disclosure">
|
||||||
<summary>
|
<summary>
|
||||||
<span>Historische vectorlagen vergelijken</span>
|
<span>Historische vectorlagen vergelijken</span>
|
||||||
@@ -1086,6 +1142,15 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
onRun={runChangeDetection}
|
onRun={runChangeDetection}
|
||||||
/>
|
/>
|
||||||
</details>
|
</details>
|
||||||
|
) : (
|
||||||
|
<div className="guest-readonly-card">
|
||||||
|
<ShieldCheck aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>Controleerbaar voorbeeldresultaat</strong>
|
||||||
|
<span>Deze kwaliteitsweergave gebruikt de vooraf berekende demo. Nieuwe vergelijkingen zijn alleen beschikbaar voor operators.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -1250,7 +1315,7 @@ function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JS
|
|||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{inspectorOpen ? (
|
{inspectorOpen && !isGuest ? (
|
||||||
<aside className="workbench-inspector" id="workbench-inspector" aria-label="Details van de huidige selectie">
|
<aside className="workbench-inspector" id="workbench-inspector" aria-label="Details van de huidige selectie">
|
||||||
<WorkbenchInspector
|
<WorkbenchInspector
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
@@ -1357,6 +1422,7 @@ function App(): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<LandingPage
|
<LandingPage
|
||||||
serviceError={sessionError}
|
serviceError={sessionError}
|
||||||
|
guestAccessEnabled={session.guest_access_enabled}
|
||||||
onAuthenticated={handleAuthenticated}
|
onAuthenticated={handleAuthenticated}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -1364,7 +1430,8 @@ function App(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkbenchApp
|
<WorkbenchApp
|
||||||
username={session.authentication_required ? session.username : null}
|
username={session.username}
|
||||||
|
accessMode={session.role ?? (session.authentication_required ? 'operator' : 'open')}
|
||||||
loggingOut={loggingOut}
|
loggingOut={loggingOut}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import maplibregl from 'maplibre-gl'
|
import maplibregl from 'maplibre-gl'
|
||||||
|
import type { ExpressionSpecification } from '@maplibre/maplibre-gl-style-spec'
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus'
|
import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus'
|
||||||
import { featureCollectionBounds } from '../lib/geojsonBounds'
|
import { featureCollectionBounds } from '../lib/geojsonBounds'
|
||||||
@@ -53,7 +54,7 @@ const DEFAULT_ROAD_BASEMAP_STYLE: maplibregl.StyleSpecification = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
function datasetFillColor(fallbackColor: string): maplibregl.ExpressionSpecification {
|
function datasetFillColor(fallbackColor: string): ExpressionSpecification {
|
||||||
return [
|
return [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'layer_type'], 'municipality_boundary'],
|
['==', ['get', 'layer_type'], 'municipality_boundary'],
|
||||||
@@ -74,7 +75,7 @@ function datasetFillColor(fallbackColor: string): maplibregl.ExpressionSpecifica
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function datasetLineColor(fallbackColor: string): maplibregl.ExpressionSpecification {
|
function datasetLineColor(fallbackColor: string): ExpressionSpecification {
|
||||||
return [
|
return [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'layer_type'], 'municipality_boundary'],
|
['==', ['get', 'layer_type'], 'municipality_boundary'],
|
||||||
@@ -689,7 +690,7 @@ function GeoMap({
|
|||||||
'false_negative',
|
'false_negative',
|
||||||
'#d97706',
|
'#d97706',
|
||||||
'#475569',
|
'#475569',
|
||||||
] as maplibregl.ExpressionSpecification
|
] as ExpressionSpecification
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'qa-evidence-fill',
|
id: 'qa-evidence-fill',
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cleanup, render, screen } from '@testing-library/react'
|
import { cleanup, render } from '@testing-library/react'
|
||||||
|
import { screen } from '@testing-library/dom'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import type { AreaRead, DatasetCreateResponse, ProjectRead } from '../types'
|
import type { AreaRead, DatasetCreateResponse, ProjectRead } from '../types'
|
||||||
import { WorkbenchStatusStrip } from './WorkbenchStatusStrip'
|
import { WorkbenchStatusStrip } from './WorkbenchStatusStrip'
|
||||||
|
|||||||
@@ -1,48 +1,83 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, render } from '@testing-library/react'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/dom'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { LandingPage } from './LandingPage'
|
import { LandingPage } from './LandingPage'
|
||||||
import { login } from '../../services/api/auth'
|
import { login, loginAsGuest } from '../../services/api/auth'
|
||||||
|
|
||||||
|
|
||||||
vi.mock('../../services/api/auth', () => ({
|
vi.mock('../../services/api/auth', () => ({
|
||||||
login: vi.fn(),
|
login: vi.fn(),
|
||||||
|
loginAsGuest: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const operatorSession = {
|
||||||
|
authentication_required: true,
|
||||||
|
authenticated: true,
|
||||||
|
username: 'operator',
|
||||||
|
expires_at: '2026-07-27T20:00:00Z',
|
||||||
|
role: 'operator' as const,
|
||||||
|
guest_access_enabled: true,
|
||||||
|
guest_project_id: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const guestSession = {
|
||||||
|
authentication_required: true,
|
||||||
|
authenticated: true,
|
||||||
|
username: 'Gast',
|
||||||
|
expires_at: '2026-07-27T20:00:00Z',
|
||||||
|
role: 'guest' as const,
|
||||||
|
guest_access_enabled: true,
|
||||||
|
guest_project_id: '00000000-0000-0000-0000-000000000123',
|
||||||
|
}
|
||||||
|
|
||||||
describe('LandingPage', () => {
|
describe('LandingPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(login).mockReset()
|
vi.mocked(login).mockReset()
|
||||||
|
vi.mocked(loginAsGuest).mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => cleanup())
|
afterEach(() => cleanup())
|
||||||
|
|
||||||
it('shows the Stitch-derived landing content and submits the real login flow', async () => {
|
it('presents the professionalised landing page and submits the operator login', async () => {
|
||||||
const onAuthenticated = vi.fn()
|
const onAuthenticated = vi.fn()
|
||||||
vi.mocked(login).mockResolvedValue({
|
vi.mocked(login).mockResolvedValue(operatorSession)
|
||||||
authentication_required: true,
|
render(<LandingPage onAuthenticated={onAuthenticated} guestAccessEnabled />)
|
||||||
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()
|
expect(screen.getByRole('heading', { name: /Van kaartlaag naar aantoonbaar inzicht/i })).toBeTruthy()
|
||||||
expect(screen.getByRole('img', { name: 'ITWorx.tech' })).toBeTruthy()
|
expect(screen.getByRole('img', { name: 'ITWorx.tech' })).toBeTruthy()
|
||||||
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: ' operator ' } })
|
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: ' operator ' } })
|
||||||
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'correct' } })
|
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'correct' } })
|
||||||
fireEvent.click(screen.getAllByRole('button', { name: 'Inloggen' })[1])
|
fireEvent.click(screen.getByRole('button', { name: 'Inloggen als operator' }))
|
||||||
|
|
||||||
await waitFor(() => expect(login).toHaveBeenCalledWith('operator', 'correct'))
|
await waitFor(() => expect(login).toHaveBeenCalledWith('operator', 'correct'))
|
||||||
expect(onAuthenticated).toHaveBeenCalledWith(expect.objectContaining({ authenticated: true }))
|
expect(onAuthenticated).toHaveBeenCalledWith(operatorSession)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('surfaces an authentication error without entering the workbench', async () => {
|
it('opens a guest session without asking for credentials', async () => {
|
||||||
|
const onAuthenticated = vi.fn()
|
||||||
|
vi.mocked(loginAsGuest).mockResolvedValue(guestSession)
|
||||||
|
render(<LandingPage onAuthenticated={onAuthenticated} guestAccessEnabled />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Als gast verkennen/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(loginAsGuest).toHaveBeenCalledOnce())
|
||||||
|
expect(login).not.toHaveBeenCalled()
|
||||||
|
expect(onAuthenticated).toHaveBeenCalledWith(guestSession)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not advertise guest access when the runtime has it disabled', () => {
|
||||||
|
render(<LandingPage onAuthenticated={vi.fn()} guestAccessEnabled={false} />)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: /gast/i })).toBeNull()
|
||||||
|
expect(screen.getByRole('button', { name: 'Open de workbench' })).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces a useful authentication error without entering the workbench', async () => {
|
||||||
vi.mocked(login).mockRejectedValue(new Error('Gebruikersnaam of wachtwoord is onjuist.'))
|
vi.mocked(login).mockRejectedValue(new Error('Gebruikersnaam of wachtwoord is onjuist.'))
|
||||||
render(<LandingPage onAuthenticated={vi.fn()} />)
|
render(<LandingPage onAuthenticated={vi.fn()} />)
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: 'operator' } })
|
fireEvent.change(screen.getByLabelText('Gebruikersnaam'), { target: { value: 'operator' } })
|
||||||
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'wrong' } })
|
fireEvent.change(screen.getByLabelText('Wachtwoord'), { target: { value: 'wrong' } })
|
||||||
fireEvent.click(screen.getAllByRole('button', { name: 'Inloggen' })[1])
|
fireEvent.click(screen.getByRole('button', { name: 'Inloggen als operator' }))
|
||||||
|
|
||||||
expect((await screen.findByRole('alert')).textContent).toContain('Gebruikersnaam of wachtwoord is onjuist.')
|
expect((await screen.findByRole('alert')).textContent).toContain('Gebruikersnaam of wachtwoord is onjuist.')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,19 +2,23 @@ import { useEffect, useRef, useState, type FormEvent } from 'react'
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
BrainCircuit,
|
BrainCircuit,
|
||||||
CheckCircle2,
|
Check,
|
||||||
|
ChevronRight,
|
||||||
Database,
|
Database,
|
||||||
Layers3,
|
Eye,
|
||||||
|
EyeOff,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
LogIn,
|
LogIn,
|
||||||
MapPinned,
|
MapPinned,
|
||||||
Menu,
|
Menu,
|
||||||
|
MousePointer2,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
UserRound,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { login } from '../../services/api/auth'
|
import { login, loginAsGuest, type AuthSession } from '../../services/api/auth'
|
||||||
import type { AuthSession } from '../../services/api/auth'
|
import { formatAuthError } from '../../lib/authError'
|
||||||
import { formatError } from '../../lib/formatError'
|
|
||||||
import '../../styles/landing.css'
|
import '../../styles/landing.css'
|
||||||
import { GeoIntelMark } from '../brand/GeoIntelBrand'
|
import { GeoIntelMark } from '../brand/GeoIntelBrand'
|
||||||
import { ItWorxSignature } from '../brand/ItWorxSignature'
|
import { ItWorxSignature } from '../brand/ItWorxSignature'
|
||||||
@@ -22,42 +26,54 @@ import { ItWorxSignature } from '../brand/ItWorxSignature'
|
|||||||
interface LandingPageProps {
|
interface LandingPageProps {
|
||||||
onAuthenticated: (session: AuthSession) => void
|
onAuthenticated: (session: AuthSession) => void
|
||||||
serviceError?: string | null
|
serviceError?: string | null
|
||||||
|
guestAccessEnabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const capabilityItems = [
|
const capabilityItems = [
|
||||||
{
|
{
|
||||||
icon: MapPinned,
|
icon: MapPinned,
|
||||||
title: 'Heel België in beeld',
|
number: '01',
|
||||||
|
title: 'Eén kaartgerichte werkruimte',
|
||||||
description:
|
description:
|
||||||
'Werk met officiële bronnen voor Vlaanderen, Wallonië, Brussel en de Belgische Noordzee, zonder regionale semantiek te vermengen.',
|
'Selecteer een gebied in België of de Belgische Noordzee en werk verder vanuit dezelfde ruimtelijke context.',
|
||||||
tags: ['NGI', 'SPW', 'Digitaal Vlaanderen'],
|
|
||||||
tone: 'primary',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: BrainCircuit,
|
icon: Database,
|
||||||
title: 'AI met bewijsgrenzen',
|
number: '02',
|
||||||
|
title: 'Bronnen blijven herkenbaar',
|
||||||
description:
|
description:
|
||||||
'Voer objectdetectie uit op geschikte luchtbeelden en beoordeel resultaten tegen referentiedata met zichtbare model- en validatiegrenzen.',
|
'Autoriteit, meetmoment, dekking, CRS en beperkingen blijven zichtbaar in plaats van achter één generieke kaartlaag te verdwijnen.',
|
||||||
tags: ['Objectdetectie', 'Lokale assistent'],
|
|
||||||
tone: 'secondary',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
title: 'Operationele kwaliteit',
|
number: '03',
|
||||||
|
title: 'Kwaliteit vóór resultaat',
|
||||||
description:
|
description:
|
||||||
'Elke analyse bewaart bron, meetmoment, CRS, eenheid en beperkingen. Resultaten blijven inspecteerbaar vóór export of besluitvorming.',
|
'Vergelijk referentie- en kandidaatgegevens, controleer bewijs en exporteer pas wanneer de context klopt.',
|
||||||
tags: ['QA/QC', 'Herleidbaar'],
|
|
||||||
tone: 'attention',
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function LandingPage({ onAuthenticated, serviceError = null }: LandingPageProps): JSX.Element {
|
const workflowSteps = [
|
||||||
|
['Selecteer', 'Kies een officiële grens of teken zelf een gebied.'],
|
||||||
|
['Controleer', 'Bekijk dekking, bronkwaliteit en toepasselijke beperkingen.'],
|
||||||
|
['Analyseer', 'Meet toestand, evolutie of modelresultaten op de kaart.'],
|
||||||
|
['Onderbouw', 'Bewaar controleerbare resultaten en provenance.'],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function LandingPage({
|
||||||
|
onAuthenticated,
|
||||||
|
serviceError = null,
|
||||||
|
guestAccessEnabled = false,
|
||||||
|
}: LandingPageProps): JSX.Element {
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [pendingAction, setPendingAction] = useState<'operator' | 'guest' | null>(null)
|
||||||
const [loginError, setLoginError] = useState<string | null>(null)
|
const [authError, setAuthError] = useState<string | null>(null)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const usernameRef = useRef<HTMLInputElement | null>(null)
|
const usernameRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const accessPanelRef = useRef<HTMLElement | null>(null)
|
||||||
|
const busy = pendingAction !== null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.add('landing-body')
|
document.body.classList.add('landing-body')
|
||||||
@@ -66,84 +82,162 @@ export function LandingPage({ onAuthenticated, serviceError = null }: LandingPag
|
|||||||
|
|
||||||
const submitLogin = async (event: FormEvent<HTMLFormElement>) => {
|
const submitLogin = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setSubmitting(true)
|
setPendingAction('operator')
|
||||||
setLoginError(null)
|
setAuthError(null)
|
||||||
try {
|
try {
|
||||||
const session = await login(username.trim(), password)
|
const session = await login(username.trim(), password)
|
||||||
onAuthenticated(session)
|
onAuthenticated(session)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLoginError(formatError(error, 'Aanmelden is niet gelukt. Probeer het opnieuw.'))
|
setAuthError(formatAuthError(error, 'Aanmelden is momenteel niet gelukt. Controleer de verbinding en probeer opnieuw.'))
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setPendingAction(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitGuestLogin = async () => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
setPendingAction('guest')
|
||||||
|
setAuthError(null)
|
||||||
|
accessPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
try {
|
||||||
|
const session = await loginAsGuest()
|
||||||
|
onAuthenticated(session)
|
||||||
|
} catch (error) {
|
||||||
|
setAuthError(formatAuthError(error, 'De gastdemo kon niet worden voorbereid. Probeer het over enkele ogenblikken opnieuw.'))
|
||||||
|
} finally {
|
||||||
|
setPendingAction(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusLogin = () => {
|
const focusLogin = () => {
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
|
accessPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
window.requestAnimationFrame(() => usernameRef.current?.focus())
|
window.requestAnimationFrame(() => usernameRef.current?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="landing-page">
|
<div className="landing-page">
|
||||||
<a className="landing-skip-link" href="#login-panel">Ga naar aanmelden</a>
|
<a className="landing-skip-link" href="#login-panel">Ga naar toegang</a>
|
||||||
|
|
||||||
<header className="landing-header">
|
<header className="landing-header">
|
||||||
<a className="landing-brand" href="#top" aria-label="GeoIntel Atlas startpagina">
|
<a className="landing-brand" href="#top" aria-label="GeoIntel Atlas startpagina">
|
||||||
<GeoIntelMark className="landing-brand-mark" />
|
<GeoIntelMark className="landing-brand-mark" />
|
||||||
<span>GeoIntel Atlas</span>
|
<span className="landing-brand-copy">
|
||||||
|
<strong>GeoIntel</strong>
|
||||||
|
<small>Atlas Workbench</small>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="landing-menu-toggle"
|
className="landing-menu-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={menuOpen ? 'Navigatie sluiten' : 'Navigatie openen'}
|
aria-label={menuOpen ? 'Navigatie sluiten' : 'Navigatie openen'}
|
||||||
aria-expanded={menuOpen}
|
aria-expanded={menuOpen}
|
||||||
|
aria-controls="landing-navigation"
|
||||||
onClick={() => setMenuOpen((current) => !current)}
|
onClick={() => setMenuOpen((current) => !current)}
|
||||||
>
|
>
|
||||||
{menuOpen ? <X aria-hidden="true" /> : <Menu aria-hidden="true" />}
|
{menuOpen ? <X aria-hidden="true" /> : <Menu aria-hidden="true" />}
|
||||||
</button>
|
</button>
|
||||||
<nav className={menuOpen ? 'landing-nav landing-nav-open' : 'landing-nav'} aria-label="Landingspagina">
|
|
||||||
<a href="#mogelijkheden" onClick={() => setMenuOpen(false)}>Verkennen</a>
|
<nav
|
||||||
<a href="#werkproces" onClick={() => setMenuOpen(false)}>Analyseren</a>
|
id="landing-navigation"
|
||||||
|
className={menuOpen ? 'landing-nav landing-nav-open' : 'landing-nav'}
|
||||||
|
aria-label="Landingspagina"
|
||||||
|
>
|
||||||
|
<a href="#mogelijkheden" onClick={() => setMenuOpen(false)}>Mogelijkheden</a>
|
||||||
|
<a href="#werkproces" onClick={() => setMenuOpen(false)}>Werkproces</a>
|
||||||
<a href="#kwaliteit" onClick={() => setMenuOpen(false)}>Kwaliteit</a>
|
<a href="#kwaliteit" onClick={() => setMenuOpen(false)}>Kwaliteit</a>
|
||||||
</nav>
|
</nav>
|
||||||
<button className="landing-header-login" type="button" onClick={focusLogin}>
|
|
||||||
|
<div className="landing-header-actions">
|
||||||
|
{guestAccessEnabled ? (
|
||||||
|
<button className="landing-header-guest" type="button" onClick={submitGuestLogin} disabled={busy}>
|
||||||
|
Gastdemo
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button className="landing-header-login" type="button" onClick={focusLogin} disabled={busy}>
|
||||||
Inloggen
|
Inloggen
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="top">
|
<main id="top">
|
||||||
<section className="landing-hero" aria-labelledby="landing-title">
|
<section className="landing-hero" aria-labelledby="landing-title">
|
||||||
<div className="landing-hero-background" aria-hidden="true" />
|
<div className="landing-hero-orbit landing-hero-orbit-one" aria-hidden="true" />
|
||||||
|
<div className="landing-hero-orbit landing-hero-orbit-two" aria-hidden="true" />
|
||||||
|
|
||||||
<div className="landing-hero-content">
|
<div className="landing-hero-content">
|
||||||
<div className="landing-hero-copy">
|
<div className="landing-hero-copy">
|
||||||
<p className="landing-kicker"><CheckCircle2 aria-hidden="true" /> Operationele GeoAI-workbench</p>
|
<p className="landing-kicker">
|
||||||
<h1 id="landing-title">Operationele GIS-analyse <span>op topniveau.</span></h1>
|
<Sparkles aria-hidden="true" /> GeoAI-workbench voor België en de Noordzee
|
||||||
<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>
|
</p>
|
||||||
|
<h1 id="landing-title">
|
||||||
|
Van kaartlaag naar <span>aantoonbaar inzicht.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="landing-lead">
|
||||||
|
GeoIntel brengt officiële bronnen, ruimtelijke analyse, AI-resultaten en kwaliteitscontrole samen in één professionele werkomgeving. Niet alleen zien wat er op de kaart staat, maar ook weten waarop het resultaat steunt.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="landing-hero-actions">
|
<div className="landing-hero-actions">
|
||||||
<a className="landing-primary-action" href="#mogelijkheden">
|
{guestAccessEnabled ? (
|
||||||
<MapPinned aria-hidden="true" /> Bekijk mogelijkheden
|
<button className="landing-primary-action" type="button" onClick={submitGuestLogin} disabled={busy}>
|
||||||
</a>
|
<UserRound aria-hidden="true" />
|
||||||
<button className="landing-secondary-action" type="button" onClick={focusLogin}>
|
{pendingAction === 'guest' ? 'Gastdemo voorbereiden…' : 'Verkennen als gast'}
|
||||||
Naar inloggen <ArrowRight aria-hidden="true" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
) : (
|
||||||
<dl className="landing-trust-strip" aria-label="Platformbereik">
|
<button className="landing-primary-action" type="button" onClick={focusLogin}>
|
||||||
<div><dt>Geografie</dt><dd>België + Noordzee</dd></div>
|
<LogIn aria-hidden="true" /> Open de workbench
|
||||||
<div><dt>Bronnen</dt><dd>Officieel per regio</dd></div>
|
</button>
|
||||||
<div><dt>Uitvoer</dt><dd>GIS-herleidbaar</dd></div>
|
)}
|
||||||
</dl>
|
<a className="landing-secondary-action" href="#mogelijkheden">
|
||||||
|
Bekijk mogelijkheden <ArrowRight aria-hidden="true" />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="landing-login-card" id="login-panel">
|
<div className="landing-proof-row" aria-label="Kernkwaliteiten">
|
||||||
<div className="landing-login-heading">
|
<div><Check aria-hidden="true" /><span><strong>Officiële bronnen</strong><small>per rechtsgebied</small></span></div>
|
||||||
|
<div><Check aria-hidden="true" /><span><strong>Meetbare kwaliteit</strong><small>vóór export</small></span></div>
|
||||||
|
<div><Check aria-hidden="true" /><span><strong>Volledige provenance</strong><small>bij ieder resultaat</small></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="landing-access-card"
|
||||||
|
id="login-panel"
|
||||||
|
ref={accessPanelRef}
|
||||||
|
aria-labelledby="access-title"
|
||||||
|
>
|
||||||
|
<div className="landing-access-preview" aria-hidden="true">
|
||||||
|
<div className="landing-preview-toolbar">
|
||||||
|
<span><i /> Live werkcontext</span>
|
||||||
|
<span>België · Noordzee</span>
|
||||||
|
</div>
|
||||||
|
<div className="landing-preview-focus">
|
||||||
|
<span className="landing-preview-pin"><MapPinned /></span>
|
||||||
|
<div>
|
||||||
|
<small>Actieve analyse</small>
|
||||||
|
<strong>Gebouwen & referentiedata</strong>
|
||||||
|
<span>Brondekking gecontroleerd</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="landing-preview-metrics">
|
||||||
|
<span><strong>4</strong><small>rechtsgebieden</small></span>
|
||||||
|
<span><strong>QA</strong><small>bewijs zichtbaar</small></span>
|
||||||
|
<span><strong>GIS</strong><small>exporteerbaar</small></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="landing-access-body">
|
||||||
|
<div className="landing-access-heading">
|
||||||
<span className="landing-login-icon" aria-hidden="true"><LockKeyhole /></span>
|
<span className="landing-login-icon" aria-hidden="true"><LockKeyhole /></span>
|
||||||
<div>
|
<div>
|
||||||
<h2>Toegang Workbench</h2>
|
<p>Veilige toegang</p>
|
||||||
<p>Log in met uw GeoIntel-account.</p>
|
<h2 id="access-title">Open de GeoIntel-workbench</h2>
|
||||||
|
<span>Meld u aan als operator of start een beperkte demosessie.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={submitLogin} aria-busy={submitting}>
|
|
||||||
|
<form onSubmit={submitLogin} aria-busy={pendingAction === 'operator'}>
|
||||||
<label htmlFor="login-username">Gebruikersnaam</label>
|
<label htmlFor="login-username">Gebruikersnaam</label>
|
||||||
<input
|
<input
|
||||||
ref={usernameRef}
|
ref={usernameRef}
|
||||||
@@ -153,80 +247,138 @@ export function LandingPage({ onAuthenticated, serviceError = null }: LandingPag
|
|||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
disabled={submitting}
|
disabled={busy}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label htmlFor="login-password">Wachtwoord</label>
|
<label htmlFor="login-password">Wachtwoord</label>
|
||||||
|
<div className="landing-password-field">
|
||||||
<input
|
<input
|
||||||
id="login-password"
|
id="login-password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
disabled={submitting}
|
disabled={busy}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{loginError || serviceError ? (
|
<button
|
||||||
<p className="landing-login-error" role="alert">{loginError ?? serviceError}</p>
|
type="button"
|
||||||
|
aria-label={showPassword ? 'Wachtwoord verbergen' : 'Wachtwoord tonen'}
|
||||||
|
aria-pressed={showPassword}
|
||||||
|
onClick={() => setShowPassword((current) => !current)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff aria-hidden="true" /> : <Eye aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authError || serviceError ? (
|
||||||
|
<p className="landing-login-error" role="alert">{authError ?? serviceError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button type="submit" disabled={submitting || !username.trim() || !password}>
|
|
||||||
<LogIn aria-hidden="true" /> {submitting ? 'Aanmelden…' : 'Inloggen'}
|
<button
|
||||||
|
className="landing-operator-submit"
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !username.trim() || !password}
|
||||||
|
>
|
||||||
|
<LogIn aria-hidden="true" />
|
||||||
|
{pendingAction === 'operator' ? 'Veilig aanmelden…' : 'Inloggen als operator'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p className="landing-session-note"><ShieldCheck aria-hidden="true" /> Beveiligde, tijdelijke operatorsessie</p>
|
|
||||||
|
{guestAccessEnabled ? (
|
||||||
|
<div className="landing-access-divider"><span>of verken zonder account</span></div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{guestAccessEnabled ? (
|
||||||
|
<div className="landing-guest-access">
|
||||||
|
<span className="landing-guest-icon" aria-hidden="true"><UserRound /></span>
|
||||||
|
<div>
|
||||||
|
<strong>Gastmodus</strong>
|
||||||
|
<p>Open een tijdelijke, alleen-lezen demowerkruimte met voorbeelddata en een vooraf uitgevoerde kwaliteitscontrole.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" onClick={submitGuestLogin} disabled={busy}>
|
||||||
|
{pendingAction === 'guest' ? 'Demo voorbereiden…' : 'Als gast verkennen'}
|
||||||
|
<ChevronRight aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="landing-session-note">
|
||||||
|
<ShieldCheck aria-hidden="true" /> HttpOnly-sessie · geen wachtwoordopslag in de browser
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="landing-capabilities" id="mogelijkheden" aria-labelledby="capabilities-title">
|
<section className="landing-capabilities" id="mogelijkheden" aria-labelledby="capabilities-title">
|
||||||
<div className="landing-section-heading">
|
<div className="landing-section-heading">
|
||||||
<p>Van bron tot besluit</p>
|
<p>Gebouwd voor controleerbare analyse</p>
|
||||||
<h2 id="capabilities-title">Eén werkbank, controle over de hele keten</h2>
|
<h2 id="capabilities-title">Geen los dashboard, maar één samenhangende GIS-keten</h2>
|
||||||
|
<span>De interface volgt de manier waarop een onderbouwde ruimtelijke analyse werkelijk tot stand komt.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="landing-capability-grid">
|
<div className="landing-capability-grid">
|
||||||
{capabilityItems.map(({ icon: Icon, title, description, tags, tone }) => (
|
{capabilityItems.map(({ icon: Icon, number, title, description }) => (
|
||||||
<article key={title} className={`landing-capability landing-capability-${tone}`}>
|
<article key={title} className="landing-capability">
|
||||||
|
<div className="landing-capability-topline">
|
||||||
<span className="landing-capability-icon" aria-hidden="true"><Icon /></span>
|
<span className="landing-capability-icon" aria-hidden="true"><Icon /></span>
|
||||||
|
<small>{number}</small>
|
||||||
|
</div>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
<div>{tags.map((tag) => <span key={tag}>{tag}</span>)}</div>
|
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="landing-workflow" id="werkproces" aria-labelledby="workflow-title">
|
<section className="landing-workflow" id="werkproces" aria-labelledby="workflow-title">
|
||||||
<div className="landing-workflow-map">
|
<div className="landing-workflow-visual">
|
||||||
<div className="landing-workflow-map-image" aria-hidden="true" />
|
<div className="landing-workflow-map-image" aria-hidden="true" />
|
||||||
<div>
|
<div className="landing-workflow-overlay">
|
||||||
<p>Kaart als werkomgeving</p>
|
<p>Kaart als werkomgeving</p>
|
||||||
<h2 id="workflow-title">Van selectie naar aantoonbaar inzicht</h2>
|
<h2 id="workflow-title">Van selectie naar een resultaat dat u kunt verdedigen</h2>
|
||||||
<span>Kies thema → teken gebied → controleer bron → analyseer → exporteer</span>
|
<span>Bron, gebied, meetmoment en kwaliteitsbewijs blijven samen zichtbaar.</span>
|
||||||
|
</div>
|
||||||
|
<div className="landing-workflow-floating" aria-hidden="true">
|
||||||
|
<MousePointer2 />
|
||||||
|
<span><small>Selectie gecontroleerd</small><strong>2,14 km² · 186 objecten</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="landing-workflow-details">
|
|
||||||
<article>
|
<div className="landing-workflow-content">
|
||||||
<Database aria-hidden="true" />
|
<p className="landing-workflow-eyebrow">Een helder werkproces</p>
|
||||||
<div><h3>Bronnen per rechtsgebied</h3><p>Vlaamse, Waalse, Brusselse en maritieme bronnen blijven herkenbaar gescheiden.</p></div>
|
<h2>Vier stappen, zonder de technische context te verliezen</h2>
|
||||||
</article>
|
<div className="landing-workflow-steps">
|
||||||
<article>
|
{workflowSteps.map(([title, description], index) => (
|
||||||
<Layers3 aria-hidden="true" />
|
<article key={title}>
|
||||||
<div><h3>Toestand en evolutie</h3><p>Vergelijk alleen meetmomenten die inhoudelijk en ruimtelijk verenigbaar zijn.</p></div>
|
<span>{String(index + 1).padStart(2, '0')}</span>
|
||||||
</article>
|
<div><h3>{title}</h3><p>{description}</p></div>
|
||||||
<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>
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="landing-quality-callout" id="kwaliteit">
|
||||||
|
<BrainCircuit aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>AI blijft een gecontroleerde analysemethode</strong>
|
||||||
|
<p>Modelversie, confidence, referentiedata en validatiegrenzen blijven zichtbaar; ontbrekende configuratie wordt niet als succes voorgesteld.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="landing-footer">
|
<footer className="landing-footer">
|
||||||
|
<div className="landing-footer-brand">
|
||||||
|
<GeoIntelMark className="landing-footer-mark" />
|
||||||
<div><strong>GeoIntel Atlas Workbench</strong><p>Operationele GIS-analyse voor België en de Belgische Noordzee.</p></div>
|
<div><strong>GeoIntel Atlas Workbench</strong><p>Operationele GIS-analyse voor België en de Belgische Noordzee.</p></div>
|
||||||
|
</div>
|
||||||
<div className="landing-footer-ownership">
|
<div className="landing-footer-ownership">
|
||||||
<ItWorxSignature />
|
<ItWorxSignature />
|
||||||
<p>© {new Date().getFullYear()} GeoIntel · Interne operatoromgeving</p>
|
<p>© {new Date().getFullYear()} GeoIntel · Professionele operator- en demonstratieomgeving</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -697,6 +697,7 @@ function floodScenarioLabel(dataset: DatasetCreateResponse): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MapWorkspaceProps {
|
interface MapWorkspaceProps {
|
||||||
|
readOnly?: boolean
|
||||||
selectedProjectId: string | null
|
selectedProjectId: string | null
|
||||||
projects: ProjectRead[]
|
projects: ProjectRead[]
|
||||||
areas: AreaRead[]
|
areas: AreaRead[]
|
||||||
@@ -791,6 +792,7 @@ interface MapWorkspaceProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MapWorkspace({
|
export function MapWorkspace({
|
||||||
|
readOnly = false,
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
projects,
|
projects,
|
||||||
areas,
|
areas,
|
||||||
@@ -884,6 +886,9 @@ export function MapWorkspace({
|
|||||||
onOpenExports,
|
onOpenExports,
|
||||||
}: MapWorkspaceProps): JSX.Element {
|
}: MapWorkspaceProps): JSX.Element {
|
||||||
const [advancedMode, setAdvancedMode] = useState(false)
|
const [advancedMode, setAdvancedMode] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (readOnly && advancedMode) setAdvancedMode(false)
|
||||||
|
}, [advancedMode, readOnly])
|
||||||
const [activeThemeId, setActiveThemeId] = useState<DataThemeId>(() => {
|
const [activeThemeId, setActiveThemeId] = useState<DataThemeId>(() => {
|
||||||
const selectedDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null
|
const selectedDataset = availableMapDatasets.find((dataset) => dataset.id === selectedMapDatasetId) ?? null
|
||||||
return themeIdForDataset(selectedDataset) ?? 'buildings'
|
return themeIdForDataset(selectedDataset) ?? 'buildings'
|
||||||
@@ -2213,6 +2218,7 @@ export function MapWorkspace({
|
|||||||
Evolutie
|
Evolutie
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{!readOnly ? (
|
||||||
<button
|
<button
|
||||||
className="secondary-action geo-explorer-advanced"
|
className="secondary-action geo-explorer-advanced"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2225,15 +2231,26 @@ export function MapWorkspace({
|
|||||||
<SlidersHorizontal aria-hidden="true" />
|
<SlidersHorizontal aria-hidden="true" />
|
||||||
<span>Geavanceerde werkbank</span>
|
<span>Geavanceerde werkbank</span>
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{readOnly ? (
|
||||||
|
<div className="geo-guest-preview-note" role="note">
|
||||||
|
<MapPinned aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>Interactieve demo met bewaarde voorbeelddata</strong>
|
||||||
|
<span>U kunt kaartlagen verkennen, een selectie meten en kwaliteitsbewijs bekijken. Nieuwe gebieden, bronimports en bewaarde analyses zijn uitgeschakeld.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<MunicipalitySearch
|
<MunicipalitySearch
|
||||||
projectId={selectedProjectId}
|
projectId={selectedProjectId}
|
||||||
activeArea={selectedMapArea ?? null}
|
activeArea={selectedMapArea ?? null}
|
||||||
disabled={workspaceLoading}
|
disabled={workspaceLoading}
|
||||||
onActivate={onActivateMunicipality}
|
onActivate={onActivateMunicipality}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{workspaceLoading ? (
|
{workspaceLoading ? (
|
||||||
<div className="geo-bootstrap-status" role="status" aria-live="polite">
|
<div className="geo-bootstrap-status" role="status" aria-live="polite">
|
||||||
@@ -2266,6 +2283,8 @@ export function MapWorkspace({
|
|||||||
<small>
|
<small>
|
||||||
{workspaceLoading
|
{workspaceLoading
|
||||||
? 'Gebieden en bronnen worden geladen'
|
? 'Gebieden en bronnen worden geladen'
|
||||||
|
: readOnly
|
||||||
|
? `${municipalityAreaCount || 1} vooraf ingestelde demogrens; vrije kaartselectie blijft beschikbaar`
|
||||||
: municipalityAreaCount > 0
|
: municipalityAreaCount > 0
|
||||||
? `${municipalityAreaCount} geactiveerde gemeentegrenzen; vrij tekenen blijft mogelijk`
|
? `${municipalityAreaCount} geactiveerde gemeentegrenzen; vrij tekenen blijft mogelijk`
|
||||||
: 'Zoek optioneel een gemeente of teken vrij op de kaart'}
|
: 'Zoek optioneel een gemeente of teken vrij op de kaart'}
|
||||||
@@ -2280,7 +2299,7 @@ export function MapWorkspace({
|
|||||||
const temporalGroup = temporalGroups[0]
|
const temporalGroup = temporalGroups[0]
|
||||||
const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
|
const evolutionAvailable = temporalGroups.some((group) => group.items.length >= 2)
|
||||||
const available = !workspaceLoading && (analysisMode === 'current'
|
const available = !workspaceLoading && (analysisMode === 'current'
|
||||||
? Boolean(dataset || onDemandProduct)
|
? Boolean(dataset || (!readOnly && onDemandProduct))
|
||||||
: Boolean(dataset) && evolutionAvailable)
|
: Boolean(dataset) && evolutionAvailable)
|
||||||
const active = activeThemeId === theme.id
|
const active = activeThemeId === theme.id
|
||||||
const temporalRange = temporalGroup ? temporalRangeLabel(temporalGroup.items) : null
|
const temporalRange = temporalGroup ? temporalRangeLabel(temporalGroup.items) : null
|
||||||
@@ -2308,7 +2327,7 @@ export function MapWorkspace({
|
|||||||
: dataset
|
: dataset
|
||||||
? `${datasetAvailabilityLabel(dataset, partitions)}${onDemandProduct ? ' · zo nodig automatisch aangevuld' : ''}`
|
? `${datasetAvailabilityLabel(dataset, partitions)}${onDemandProduct ? ' · zo nodig automatisch aangevuld' : ''}`
|
||||||
: onDemandProduct
|
: onDemandProduct
|
||||||
? onDemandProduct.availabilityLabel
|
? readOnly ? 'Niet opgenomen in deze demo' : onDemandProduct.availabilityLabel
|
||||||
: 'Bron nog niet ingeladen'}
|
: 'Bron nog niet ingeladen'}
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
@@ -2317,7 +2336,7 @@ export function MapWorkspace({
|
|||||||
? 'Laden'
|
? 'Laden'
|
||||||
: analysisMode === 'evolution'
|
: analysisMode === 'evolution'
|
||||||
? evolutionAvailable ? 'Tijdreeks' : dataset ? 'Alleen huidig' : 'Ontbreekt'
|
? evolutionAvailable ? 'Tijdreeks' : dataset ? 'Alleen huidig' : 'Ontbreekt'
|
||||||
: dataset ? 'Beschikbaar' : onDemandProduct ? 'Automatisch' : 'Ontbreekt'}
|
: dataset ? 'Beschikbaar' : onDemandProduct ? readOnly ? 'Niet in demo' : 'Automatisch' : 'Ontbreekt'}
|
||||||
</i>
|
</i>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -2648,7 +2667,7 @@ export function MapWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? (
|
{!readOnly && analysisMode === 'current' && activeTheme.id === 'buildings' && mapSelectionBbox ? (
|
||||||
<div className={`geo-image-analysis geo-image-analysis-${orthophotoAnalysisStage}`}>
|
<div className={`geo-image-analysis geo-image-analysis-${orthophotoAnalysisStage}`}>
|
||||||
<div>
|
<div>
|
||||||
<span>Beeldanalyse</span>
|
<span>Beeldanalyse</span>
|
||||||
@@ -2914,6 +2933,14 @@ export function MapWorkspace({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeSelectionResult || temporalComparison ? (
|
{activeSelectionResult || temporalComparison ? (
|
||||||
|
readOnly ? (
|
||||||
|
<div className="geo-result-next-actions geo-result-next-actions-readonly" aria-label="Gastresultaat">
|
||||||
|
<span>
|
||||||
|
<strong>Analyse klaar</strong>
|
||||||
|
<small>Dit resultaat blijft tijdelijk in de browser. Meld u aan als operator om analyses te bewaren of verder te verwerken.</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="geo-result-next-actions" aria-label="Volgende stap">
|
<div className="geo-result-next-actions" aria-label="Volgende stap">
|
||||||
<span>
|
<span>
|
||||||
<strong>Analyse klaar</strong>
|
<strong>Analyse klaar</strong>
|
||||||
@@ -2929,6 +2956,7 @@ export function MapWorkspace({
|
|||||||
{selectionExporting ? 'Resultaat bewaren…' : 'Bewaar in downloads'}
|
{selectionExporting ? 'Resultaat bewaren…' : 'Bewaar in downloads'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, render } from '@testing-library/react'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/dom'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { areasApi } from '../../services/api/areas'
|
import { areasApi } from '../../services/api/areas'
|
||||||
import { MunicipalitySearch } from './MunicipalitySearch'
|
import { MunicipalitySearch } from './MunicipalitySearch'
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
import { cleanup, render } from '@testing-library/react'
|
||||||
|
import { fireEvent, screen } from '@testing-library/dom'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ProjectAtlasIllustration } from './ProjectAtlasIllustration'
|
import { ProjectAtlasIllustration } from './ProjectAtlasIllustration'
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { DatasetCreateResponse, QualityCheckRead } from '../types'
|
|||||||
import { formatError } from '../lib/formatError'
|
import { formatError } from '../lib/formatError'
|
||||||
|
|
||||||
interface DemoWorkflowOptions {
|
interface DemoWorkflowOptions {
|
||||||
|
restrictedMode?: boolean
|
||||||
loadProjects: (preferredProjectId?: string | null) => Promise<void>
|
loadProjects: (preferredProjectId?: string | null) => Promise<void>
|
||||||
loadProjectData: (projectId: string) => Promise<{ datasets: DatasetCreateResponse[] } | null>
|
loadProjectData: (projectId: string) => Promise<{ datasets: DatasetCreateResponse[] } | null>
|
||||||
loadDatasetDetails: (projectId: string, dataset: DatasetCreateResponse) => Promise<void>
|
loadDatasetDetails: (projectId: string, dataset: DatasetCreateResponse) => Promise<void>
|
||||||
@@ -25,6 +26,7 @@ interface DemoWorkflowOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useDemoWorkflow({
|
export function useDemoWorkflow({
|
||||||
|
restrictedMode = false,
|
||||||
loadProjects,
|
loadProjects,
|
||||||
loadProjectData,
|
loadProjectData,
|
||||||
loadDatasetDetails,
|
loadDatasetDetails,
|
||||||
@@ -47,7 +49,7 @@ export function useDemoWorkflow({
|
|||||||
const [loadingDemoWorkflow, setLoadingDemoWorkflow] = useState(false)
|
const [loadingDemoWorkflow, setLoadingDemoWorkflow] = useState(false)
|
||||||
const [demoWorkflowMessage, setDemoWorkflowMessage] = useState<string | null>(null)
|
const [demoWorkflowMessage, setDemoWorkflowMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
const loadDemoWorkflow = async () => {
|
const loadDemoWorkflow = async (): Promise<boolean> => {
|
||||||
setLoadingDemoWorkflow(true)
|
setLoadingDemoWorkflow(true)
|
||||||
setDemoWorkflowMessage(null)
|
setDemoWorkflowMessage(null)
|
||||||
setErrorMessage(null)
|
setErrorMessage(null)
|
||||||
@@ -65,12 +67,17 @@ export function useDemoWorkflow({
|
|||||||
setSegmentationReferenceDatasetId(result.reference_dataset_id)
|
setSegmentationReferenceDatasetId(result.reference_dataset_id)
|
||||||
setDemoWorkflowMessage(result.message)
|
setDemoWorkflowMessage(result.message)
|
||||||
await loadProjects(result.project_id)
|
await loadProjects(result.project_id)
|
||||||
const [projectData] = await Promise.all([
|
const operatorOnlyLoads = restrictedMode
|
||||||
loadProjectData(result.project_id),
|
? Promise.resolve()
|
||||||
|
: Promise.all([
|
||||||
loadDetectionRuns(result.project_id),
|
loadDetectionRuns(result.project_id),
|
||||||
loadSegmentationRuns(result.project_id),
|
loadSegmentationRuns(result.project_id),
|
||||||
loadQualityChecks(result.project_id),
|
|
||||||
loadExports(result.project_id),
|
loadExports(result.project_id),
|
||||||
|
]).then(() => undefined)
|
||||||
|
const [projectData] = await Promise.all([
|
||||||
|
loadProjectData(result.project_id),
|
||||||
|
loadQualityChecks(result.project_id),
|
||||||
|
operatorOnlyLoads,
|
||||||
])
|
])
|
||||||
const candidateDataset = projectData?.datasets.find((dataset) => dataset.id === result.candidate_dataset_id)
|
const candidateDataset = projectData?.datasets.find((dataset) => dataset.id === result.candidate_dataset_id)
|
||||||
const rasterDataset = projectData?.datasets.find((dataset) => dataset.id === result.raster_dataset_id)
|
const rasterDataset = projectData?.datasets.find((dataset) => dataset.id === result.raster_dataset_id)
|
||||||
@@ -79,8 +86,10 @@ export function useDemoWorkflow({
|
|||||||
} else if (rasterDataset) {
|
} else if (rasterDataset) {
|
||||||
await loadDatasetDetails(result.project_id, rasterDataset)
|
await loadDatasetDetails(result.project_id, rasterDataset)
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(formatError(error, 'Failed to load demo workflow'))
|
setErrorMessage(formatError(error, 'De demowerkruimte kon niet worden geladen.'))
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingDemoWorkflow(false)
|
setLoadingDemoWorkflow(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { formatError } from '../lib/formatError'
|
import { formatAuthError } from '../lib/authError'
|
||||||
import { getAuthSession, logout, type AuthSession } from '../services/api/auth'
|
import { getAuthSession, logout, type AuthSession } from '../services/api/auth'
|
||||||
|
|
||||||
const signedOutSession: AuthSession = {
|
const signedOutSession: AuthSession = {
|
||||||
@@ -7,6 +7,9 @@ const signedOutSession: AuthSession = {
|
|||||||
authenticated: false,
|
authenticated: false,
|
||||||
username: null,
|
username: null,
|
||||||
expires_at: null,
|
expires_at: null,
|
||||||
|
role: null,
|
||||||
|
guest_access_enabled: false,
|
||||||
|
guest_project_id: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOperatorSession() {
|
export function useOperatorSession() {
|
||||||
@@ -26,7 +29,7 @@ export function useOperatorSession() {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
setSession(signedOutSession)
|
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 () => {
|
return () => {
|
||||||
@@ -36,7 +39,10 @@ export function useOperatorSession() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const expireSession = () => {
|
const expireSession = () => {
|
||||||
setSession(signedOutSession)
|
setSession((current) => ({
|
||||||
|
...signedOutSession,
|
||||||
|
guest_access_enabled: current?.guest_access_enabled ?? false,
|
||||||
|
}))
|
||||||
setSessionError('Uw sessie is verlopen. Meld u opnieuw aan.')
|
setSessionError('Uw sessie is verlopen. Meld u opnieuw aan.')
|
||||||
}
|
}
|
||||||
window.addEventListener('geointel:session-expired', expireSession)
|
window.addEventListener('geointel:session-expired', expireSession)
|
||||||
@@ -49,7 +55,7 @@ export function useOperatorSession() {
|
|||||||
setSession(await logout())
|
setSession(await logout())
|
||||||
setSessionError(null)
|
setSessionError(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSessionError(formatError(error, 'Uitloggen is niet gelukt.'))
|
setSessionError(formatAuthError(error, 'Uitloggen is niet gelukt. Vernieuw de pagina en probeer opnieuw.'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoggingOut(false)
|
setLoggingOut(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { renderHook, waitFor } from '@testing-library/react'
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import { waitFor } from '@testing-library/dom'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { useWorkbenchBootstrap } from './useWorkbenchBootstrap'
|
import { useWorkbenchBootstrap } from './useWorkbenchBootstrap'
|
||||||
|
|
||||||
@@ -63,4 +64,20 @@ describe('useWorkbenchBootstrap', () => {
|
|||||||
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1')
|
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1')
|
||||||
expect(state.loadExports).toHaveBeenCalledWith('project-1')
|
expect(state.loadExports).toHaveBeenCalledWith('project-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the guest bootstrap inside the read-only demo surface', async () => {
|
||||||
|
const state = { ...options('project-1'), restrictedMode: true }
|
||||||
|
renderHook(() => useWorkbenchBootstrap(state))
|
||||||
|
|
||||||
|
await waitFor(() => expect(state.loadProjectData).toHaveBeenCalledWith('project-1'))
|
||||||
|
expect(state.loadCapabilities).toHaveBeenCalledOnce()
|
||||||
|
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1')
|
||||||
|
expect(state.loadDetectionModels).not.toHaveBeenCalled()
|
||||||
|
expect(state.loadSegmentationModels).not.toHaveBeenCalled()
|
||||||
|
expect(state.loadDetectionRuns).not.toHaveBeenCalled()
|
||||||
|
expect(state.loadSegmentationRuns).not.toHaveBeenCalled()
|
||||||
|
expect(state.loadExports).not.toHaveBeenCalled()
|
||||||
|
expect(state.loadDetectionResults).not.toHaveBeenCalled()
|
||||||
|
expect(state.loadSegmentationResults).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ type AsyncAction = () => Promise<unknown>
|
|||||||
type ProjectAction = (projectId: string) => Promise<unknown>
|
type ProjectAction = (projectId: string) => Promise<unknown>
|
||||||
|
|
||||||
interface WorkbenchBootstrapOptions {
|
interface WorkbenchBootstrapOptions {
|
||||||
|
restrictedMode?: boolean
|
||||||
selectedProjectId: string | null
|
selectedProjectId: string | null
|
||||||
selectedDetectionRunId: string
|
selectedDetectionRunId: string
|
||||||
detectionClassFilter: string
|
detectionClassFilter: string
|
||||||
@@ -30,6 +31,7 @@ interface WorkbenchBootstrapOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkbenchBootstrap({
|
export function useWorkbenchBootstrap({
|
||||||
|
restrictedMode = false,
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
selectedDetectionRunId,
|
selectedDetectionRunId,
|
||||||
detectionClassFilter,
|
detectionClassFilter,
|
||||||
@@ -57,9 +59,11 @@ export function useWorkbenchBootstrap({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProjects().catch(() => null)
|
loadProjects().catch(() => null)
|
||||||
loadCapabilities().catch(() => null)
|
loadCapabilities().catch(() => null)
|
||||||
|
if (!restrictedMode) {
|
||||||
loadDetectionModels().catch(() => null)
|
loadDetectionModels().catch(() => null)
|
||||||
loadSegmentationModels().catch(() => null)
|
loadSegmentationModels().catch(() => null)
|
||||||
}, [])
|
}
|
||||||
|
}, [restrictedMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProjectId) {
|
if (!selectedProjectId) {
|
||||||
@@ -76,17 +80,21 @@ export function useWorkbenchBootstrap({
|
|||||||
resetSegmentationForProject()
|
resetSegmentationForProject()
|
||||||
resetExportsForProject()
|
resetExportsForProject()
|
||||||
loadProjectData(selectedProjectId).catch(() => null)
|
loadProjectData(selectedProjectId).catch(() => null)
|
||||||
|
loadQualityChecks(selectedProjectId).catch(() => null)
|
||||||
|
if (!restrictedMode) {
|
||||||
loadDetectionRuns(selectedProjectId).catch(() => null)
|
loadDetectionRuns(selectedProjectId).catch(() => null)
|
||||||
loadSegmentationRuns(selectedProjectId).catch(() => null)
|
loadSegmentationRuns(selectedProjectId).catch(() => null)
|
||||||
loadQualityChecks(selectedProjectId).catch(() => null)
|
|
||||||
loadExports(selectedProjectId).catch(() => null)
|
loadExports(selectedProjectId).catch(() => null)
|
||||||
}, [selectedProjectId])
|
}
|
||||||
|
}, [restrictedMode, selectedProjectId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (restrictedMode) return
|
||||||
loadDetectionResults().catch(() => null)
|
loadDetectionResults().catch(() => null)
|
||||||
}, [selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter])
|
}, [restrictedMode, selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (restrictedMode) return
|
||||||
loadSegmentationResults().catch(() => null)
|
loadSegmentationResults().catch(() => null)
|
||||||
}, [selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
|
}, [restrictedMode, selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const PASSTHROUGH_CODES = new Set([
|
||||||
|
'INVALID_CREDENTIALS',
|
||||||
|
'LOGIN_RATE_LIMITED',
|
||||||
|
'GUEST_ACCESS_DISABLED',
|
||||||
|
'GUEST_READ_ONLY',
|
||||||
|
'GUEST_PROJECT_SCOPE_REQUIRED',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function formatAuthError(error: unknown, fallback: string): string {
|
||||||
|
if (!(error instanceof Error)) return fallback
|
||||||
|
|
||||||
|
const code = (error as { code?: string }).code
|
||||||
|
if (code && PASSTHROUGH_CODES.has(code)) return error.message
|
||||||
|
|
||||||
|
if (
|
||||||
|
code === 'REQUEST_ERROR'
|
||||||
|
|| code === 'INTERNAL_ERROR'
|
||||||
|
|| code === 'SESSION_CREATION_FAILED'
|
||||||
|
|| /request failed|failed to fetch|networkerror/i.test(error.message)
|
||||||
|
) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return error.message || fallback
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ export interface AuthSession {
|
|||||||
authenticated: boolean
|
authenticated: boolean
|
||||||
username: string | null
|
username: string | null
|
||||||
expires_at: string | null
|
expires_at: string | null
|
||||||
|
role: 'operator' | 'guest' | null
|
||||||
|
guest_access_enabled: boolean
|
||||||
|
guest_project_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthSession(): Promise<AuthSession> {
|
export function getAuthSession(): Promise<AuthSession> {
|
||||||
@@ -15,6 +18,10 @@ export function login(username: string, password: string): Promise<AuthSession>
|
|||||||
return apiPost<AuthSession>('/api/v1/auth/login', { username, password })
|
return apiPost<AuthSession>('/api/v1/auth/login', { username, password })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loginAsGuest(): Promise<AuthSession> {
|
||||||
|
return apiPost<AuthSession>('/api/v1/auth/guest')
|
||||||
|
}
|
||||||
|
|
||||||
export function logout(): Promise<AuthSession> {
|
export function logout(): Promise<AuthSession> {
|
||||||
return apiPost<AuthSession>('/api/v1/auth/logout')
|
return apiPost<AuthSession>('/api/v1/auth/logout')
|
||||||
}
|
}
|
||||||
|
|||||||
+1582
-233
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
|||||||
|
/*
|
||||||
|
* GeoIntel professionalisation pass — 2026-07-27
|
||||||
|
*
|
||||||
|
* This intentionally remains a small, final override layer. The historical
|
||||||
|
* style sheets are consolidated in a separate refactor so this release does
|
||||||
|
* not destabilise the map workbench or its responsive contracts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.workbench-stage {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1361px) {
|
||||||
|
.workbench-layout {
|
||||||
|
grid-template-columns: 13.75rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-sidebar {
|
||||||
|
padding-inline: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-main:not(.workbench-main-map) {
|
||||||
|
padding-inline: clamp(1.5rem, 2vw, 2.4rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-bar > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-bar strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-account-guest {
|
||||||
|
border-color: #9fcfc4;
|
||||||
|
color: #0c5e55;
|
||||||
|
background: linear-gradient(180deg, #f7fffc, #e8f7f3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-account-guest > svg {
|
||||||
|
color: #0d7d6f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-shell-guest .workbench-stage {
|
||||||
|
grid-template-rows: var(--atlas-topbar-height) auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-mode-banner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 22;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 0.72rem;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
border-bottom: 1px solid #b7dbd3;
|
||||||
|
padding: 0.68rem 1.35rem;
|
||||||
|
color: #164d47;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 0%, rgba(57, 172, 151, 0.13), transparent 16rem),
|
||||||
|
linear-gradient(90deg, #edf9f6, #f7fcfa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-mode-banner > svg {
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-mode-banner > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-mode-banner strong {
|
||||||
|
font-family: "Manrope", sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-mode-banner span:not(.guest-mode-badge) {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #52736d;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-mode-badge {
|
||||||
|
border: 1px solid #9bcfc3;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.33rem 0.62rem;
|
||||||
|
color: #0d665b;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.045em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-demo-loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: calc(var(--atlas-topbar-height) + 3.75rem) 1.25rem auto auto;
|
||||||
|
z-index: 85;
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: min(28rem, calc(100vw - 2rem));
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #b7d9d1;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
padding: 0.72rem 0.9rem;
|
||||||
|
color: #164d47;
|
||||||
|
background: rgba(250, 255, 253, 0.97);
|
||||||
|
box-shadow: 0 18px 44px rgba(9, 65, 57, 0.16);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-demo-loading > span {
|
||||||
|
width: 0.85rem;
|
||||||
|
height: 0.85rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 2px solid #b5ddd4;
|
||||||
|
border-top-color: #0f766e;
|
||||||
|
border-radius: 999px;
|
||||||
|
animation: geointel-guest-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-demo-loading strong {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes geointel-guest-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-guest-preview-note,
|
||||||
|
.guest-readonly-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 0.78rem;
|
||||||
|
align-items: start;
|
||||||
|
border: 1px solid #b9ddd5;
|
||||||
|
color: #174f48;
|
||||||
|
background: linear-gradient(135deg, #eef9f6, #fbfefd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-guest-preview-note {
|
||||||
|
border-width: 0 0 1px;
|
||||||
|
padding: 0.78rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-guest-preview-note > svg,
|
||||||
|
.guest-readonly-card > svg {
|
||||||
|
width: 1.12rem;
|
||||||
|
height: 1.12rem;
|
||||||
|
margin-top: 0.08rem;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-guest-preview-note > div,
|
||||||
|
.guest-readonly-card > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-guest-preview-note strong,
|
||||||
|
.guest-readonly-card strong {
|
||||||
|
font-family: "Manrope", sans-serif;
|
||||||
|
font-size: 0.79rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-guest-preview-note span,
|
||||||
|
.guest-readonly-card span {
|
||||||
|
color: #54756f;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-readonly-card {
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
box-shadow: 0 8px 22px rgba(20, 81, 71, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-result-next-actions-readonly {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
border-color: #b9dcd4;
|
||||||
|
background: #f1faf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-result-next-actions-readonly small {
|
||||||
|
max-width: 48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-shell-guest .geo-explorer-header {
|
||||||
|
min-height: 5.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-shell-guest .geo-explorer-layout {
|
||||||
|
grid-template-columns: minmax(13.5rem, 0.7fr) minmax(28rem, 1.75fr) minmax(18rem, 0.8fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.guest-mode-banner {
|
||||||
|
padding-inline: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-mode-banner span:not(.guest-mode-badge) {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-shell-guest .geo-explorer-layout {
|
||||||
|
grid-template-columns: 13rem minmax(24rem, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.workbench-shell-guest .workbench-stage {
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-mode-banner {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-mode-badge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-demo-loading {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0.75rem 0.75rem auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.guest-mode-banner > svg {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-mode-banner {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-mode-banner strong {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guest-mode-banner span:not(.guest-mode-badge) {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-guest-preview-note {
|
||||||
|
padding: 0.72rem 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.guest-demo-loading > span {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client", "geojson"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user