M24: implement privacy governance
This commit is contained in:
@@ -40,6 +40,11 @@ BACKUP_MINIMUM_COPIES=7
|
||||
BACKUP_SECONDARY_DESTINATION=
|
||||
MOBILITYOPS_BACKUP_SECONDARY_DIR=./backups/offsite
|
||||
|
||||
# Privacy governance defaults.
|
||||
PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS=30
|
||||
PRIVACY_AUDIT_RETENTION_DAYS=2555
|
||||
PRIVACY_AUDIT_EXPORT_MAX_ROWS=10000
|
||||
|
||||
# Demo presentation (fictional org identity, badge/manifest, reset safety valve).
|
||||
# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent
|
||||
# of role -- a safety valve for any environment where the dataset must not be rebuildable.
|
||||
|
||||
@@ -2554,3 +2554,23 @@ evidence yet."
|
||||
disposable PostgreSQL instance produced a real dump, checksum verification passed and
|
||||
`pg_restore --list` accepted the artifact. Exact next action: implement privacy export,
|
||||
anonymisation safeguards, retention reporting and governance documentation.
|
||||
|
||||
## M24 — executable privacy governance (2026-08-10)
|
||||
|
||||
- Added a manager-only Privacy workspace with persisted policy metrics, customer dossier
|
||||
export, bounded audit CSV export and irreversible customer anonymisation. Privacy is
|
||||
localized in all three supported languages, searchable and hidden from rental staff.
|
||||
- Anonymisation is row-locked and requires the exact stable customer reference plus a
|
||||
reason. Reserved/active bookings and bookings inside the configurable minimum retention
|
||||
window block the action. PII is cleared while stable references and operational history
|
||||
remain valid; repeated requests are idempotent.
|
||||
- Every export and anonymisation is audited. The anonymisation audit records state and
|
||||
justification but deliberately never copies erased PII. Audit CSV ranges are capped at
|
||||
90 days and a configurable maximum row count.
|
||||
- Added explicit customer anonymisation state/migration `b913a72e8c14`, a governance
|
||||
runbook covering inventory, retention, data-subject requests, access review and incident
|
||||
handling, plus regenerated OpenAPI.
|
||||
- Evidence: privacy API **6 passed without warnings**; ruff/mypy clean; React review led
|
||||
to stable callback/effect dependencies and a lazy route chunk; TypeScript, lint and
|
||||
production build pass. Exact next action: extend RAGcore corpus statistics and health
|
||||
evidence, then run complete acceptance and deploy all production-readiness milestones.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""add explicit customer anonymisation state
|
||||
|
||||
Revision ID: b913a72e8c14
|
||||
Revises: a81d0ce9f662
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "b913a72e8c14"
|
||||
down_revision = "a81d0ce9f662"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("customers", sa.Column("anonymized_at", sa.DateTime(timezone=True)))
|
||||
op.create_index("ix_customers_anonymized_at", "customers", ["anonymized_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_customers_anonymized_at", table_name="customers")
|
||||
op.drop_column("customers", "anonymized_at")
|
||||
@@ -1,23 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.core.config import get_settings
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
||||
settings = get_settings()
|
||||
|
||||
# Only entity types with a stable public reference and (optionally) a real frontend route
|
||||
# are resolved here. Types like "system", "knowledge" or "mcp_tool" carry no linkable
|
||||
@@ -37,6 +44,74 @@ _ROUTE_TEMPLATES: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
@router.get("/export.csv")
|
||||
def export_audit_csv(
|
||||
occurred_from: datetime | None = Query(default=None),
|
||||
occurred_to: datetime | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
actor: CurrentUser = Depends(require_operations_manager),
|
||||
) -> Response:
|
||||
end = occurred_to or datetime.now(UTC)
|
||||
start = occurred_from or end - timedelta(days=30)
|
||||
if end <= start or end - start > timedelta(days=90):
|
||||
raise HTTPException(status_code=422, detail="Audit export range must be 1 to 90 days")
|
||||
events = db.scalars(
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.occurred_at >= start, AuditEvent.occurred_at <= end)
|
||||
.order_by(AuditEvent.occurred_at)
|
||||
.limit(settings.privacy_audit_export_max_rows + 1)
|
||||
).all()
|
||||
if len(events) > settings.privacy_audit_export_max_rows:
|
||||
raise HTTPException(status_code=413, detail="Audit export exceeds configured row limit")
|
||||
output = io.StringIO(newline="")
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(
|
||||
(
|
||||
"id",
|
||||
"occurred_at",
|
||||
"actor_type",
|
||||
"actor_label",
|
||||
"action",
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
"correlation_id",
|
||||
"before",
|
||||
"after",
|
||||
"metadata",
|
||||
)
|
||||
)
|
||||
for event in events:
|
||||
writer.writerow(
|
||||
(
|
||||
event.id,
|
||||
event.occurred_at.isoformat(),
|
||||
event.actor_type,
|
||||
event.actor_label,
|
||||
event.action,
|
||||
event.entity_type,
|
||||
event.entity_id or "",
|
||||
event.correlation_id,
|
||||
json.dumps(event.before_json, separators=(",", ":"), default=str),
|
||||
json.dumps(event.after_json, separators=(",", ":"), default=str),
|
||||
json.dumps(event.metadata_json, separators=(",", ":"), default=str),
|
||||
)
|
||||
)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="audit_exported",
|
||||
entity_type="audit",
|
||||
metadata={"from": start.isoformat(), "to": end.isoformat(), "rows": len(events)},
|
||||
)
|
||||
db.commit()
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="mobilityops-audit.csv"'},
|
||||
)
|
||||
|
||||
|
||||
def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid.UUID, str]:
|
||||
ids_by_type: dict[str, set[uuid.UUID]] = {}
|
||||
for event in events:
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.core.config import get_settings
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.schemas import (
|
||||
CurrentUser,
|
||||
CustomerAnonymizeRequest,
|
||||
CustomerAnonymizeResult,
|
||||
PrivacyRetentionOut,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
router = APIRouter(prefix="/api/v1/privacy", tags=["privacy"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _retention_cutoff() -> datetime:
|
||||
return datetime.now(UTC) - timedelta(days=settings.privacy_minimum_booking_retention_days)
|
||||
|
||||
|
||||
def _customer_is_eligible(db: Session, customer_id) -> bool:
|
||||
blocking = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Booking)
|
||||
.where(
|
||||
Booking.customer_id == customer_id,
|
||||
or_(
|
||||
Booking.status.in_(("reserved", "active")),
|
||||
Booking.ends_at > _retention_cutoff(),
|
||||
),
|
||||
)
|
||||
)
|
||||
return not blocking
|
||||
|
||||
|
||||
@router.get("/retention", response_model=PrivacyRetentionOut)
|
||||
def retention_status(
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> PrivacyRetentionOut:
|
||||
customers = db.scalars(select(Customer)).all()
|
||||
return PrivacyRetentionOut(
|
||||
minimum_booking_retention_days=settings.privacy_minimum_booking_retention_days,
|
||||
audit_retention_days=settings.privacy_audit_retention_days,
|
||||
customers_total=len(customers),
|
||||
customers_anonymized=sum(customer.anonymized_at is not None for customer in customers),
|
||||
customers_eligible=sum(
|
||||
customer.anonymized_at is None and _customer_is_eligible(db, customer.id)
|
||||
for customer in customers
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/customers/{public_ref}/export")
|
||||
def export_customer_data(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
actor: CurrentUser = Depends(require_operations_manager),
|
||||
) -> JSONResponse:
|
||||
customer = db.scalar(select(Customer).where(Customer.public_ref == public_ref))
|
||||
if customer is None:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
bookings = db.scalars(
|
||||
select(Booking).where(Booking.customer_id == customer.id).order_by(Booking.starts_at)
|
||||
).all()
|
||||
payload = {
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
"customer": {
|
||||
"public_ref": customer.public_ref,
|
||||
"first_name": customer.first_name,
|
||||
"last_name": customer.last_name,
|
||||
"email": customer.email,
|
||||
"phone": customer.phone,
|
||||
"postal_code": customer.postal_code,
|
||||
"city": customer.city,
|
||||
"date_of_birth": customer.date_of_birth.isoformat() if customer.date_of_birth else None,
|
||||
"anonymized_at": customer.anonymized_at.isoformat() if customer.anonymized_at else None,
|
||||
},
|
||||
"bookings": [
|
||||
{
|
||||
"public_ref": booking.public_ref,
|
||||
"starts_at": booking.starts_at.isoformat(),
|
||||
"ends_at": booking.ends_at.isoformat(),
|
||||
"status": booking.status,
|
||||
}
|
||||
for booking in bookings
|
||||
],
|
||||
}
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="privacy_customer_exported",
|
||||
entity_type="customer",
|
||||
entity_id=customer.id,
|
||||
metadata={"customer_ref": customer.public_ref, "booking_count": len(bookings)},
|
||||
)
|
||||
db.commit()
|
||||
return JSONResponse(
|
||||
payload,
|
||||
headers={"Content-Disposition": f'attachment; filename="{public_ref}-privacy.json"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/customers/{public_ref}/anonymize", response_model=CustomerAnonymizeResult)
|
||||
def anonymize_customer(
|
||||
public_ref: str,
|
||||
body: CustomerAnonymizeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
actor: CurrentUser = Depends(require_operations_manager),
|
||||
) -> CustomerAnonymizeResult:
|
||||
customer = db.scalar(
|
||||
select(Customer).where(Customer.public_ref == public_ref).with_for_update()
|
||||
)
|
||||
if customer is None:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
if body.confirmation != public_ref:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Customer reference confirmation does not match"
|
||||
)
|
||||
if customer.anonymized_at is not None:
|
||||
return CustomerAnonymizeResult(
|
||||
public_ref=public_ref,
|
||||
anonymized_at=customer.anonymized_at,
|
||||
status="already_anonymized",
|
||||
)
|
||||
if not _customer_is_eligible(db, customer.id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Customer has an active/recent booking within the minimum retention period",
|
||||
)
|
||||
anonymized_at = datetime.now(UTC)
|
||||
customer.first_name = "Anoniem"
|
||||
customer.last_name = public_ref
|
||||
customer.email = None
|
||||
customer.phone = None
|
||||
customer.postal_code = None
|
||||
customer.city = None
|
||||
customer.date_of_birth = None
|
||||
customer.anonymized_at = anonymized_at
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="privacy_customer_anonymized",
|
||||
entity_type="customer",
|
||||
entity_id=customer.id,
|
||||
before={"anonymized": False},
|
||||
after={"anonymized": True},
|
||||
metadata={"reason": body.reason, "customer_ref": public_ref},
|
||||
)
|
||||
db.commit()
|
||||
return CustomerAnonymizeResult(
|
||||
public_ref=public_ref,
|
||||
anonymized_at=anonymized_at,
|
||||
status="anonymized",
|
||||
)
|
||||
@@ -89,6 +89,12 @@ _SECTIONS: list[dict] = [
|
||||
"terms": ["audit", "history", "geschiedenis", "historique"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
{
|
||||
"id": "privacy",
|
||||
"link": "/privacy",
|
||||
"terms": ["privacy", "retention", "anonymise", "anonimiseren", "confidentialité"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ class Settings(BaseSettings):
|
||||
oidc_default_role: str = "rental_employee"
|
||||
log_level: str = "INFO"
|
||||
metrics_bearer_token: str = ""
|
||||
privacy_minimum_booking_retention_days: int = 30
|
||||
privacy_audit_retention_days: int = 2555
|
||||
privacy_audit_export_max_rows: int = 10000
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.api.routers import (
|
||||
knowledge,
|
||||
mcp_integrations,
|
||||
observability,
|
||||
privacy,
|
||||
search,
|
||||
users,
|
||||
vehicles,
|
||||
@@ -183,3 +184,4 @@ app.include_router(search.router)
|
||||
app.include_router(integration_status.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(observability.router)
|
||||
app.include_router(privacy.router)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, String
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -23,3 +23,4 @@ class Customer(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
merged_into_customer_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("customers.id")
|
||||
)
|
||||
anonymized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
@@ -384,6 +384,25 @@ class OidcStatusOut(BaseModel):
|
||||
provider_name: str | None = None
|
||||
|
||||
|
||||
class PrivacyRetentionOut(BaseModel):
|
||||
minimum_booking_retention_days: int
|
||||
audit_retention_days: int
|
||||
customers_total: int
|
||||
customers_anonymized: int
|
||||
customers_eligible: int
|
||||
|
||||
|
||||
class CustomerAnonymizeRequest(BaseModel):
|
||||
confirmation: str = Field(min_length=1, max_length=20)
|
||||
reason: str = Field(min_length=8, max_length=500)
|
||||
|
||||
|
||||
class CustomerAnonymizeResult(BaseModel):
|
||||
public_ref: str
|
||||
anonymized_at: datetime
|
||||
status: Literal["anonymized", "already_anonymized"]
|
||||
|
||||
|
||||
class N8nWorkflowEvidence(BaseModel):
|
||||
name: str
|
||||
built: bool
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.customer import Customer
|
||||
|
||||
|
||||
def test_privacy_retention_uses_persisted_counts(ops_client):
|
||||
response = ops_client.get("/api/v1/privacy/retention")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["customers_total"] >= 180
|
||||
assert body["minimum_booking_retention_days"] == 30
|
||||
assert body["audit_retention_days"] == 2555
|
||||
|
||||
|
||||
def test_privacy_retention_requires_manager(employee_client):
|
||||
assert employee_client.get("/api/v1/privacy/retention").status_code == 403
|
||||
|
||||
|
||||
def test_customer_export_is_downloadable_and_audited_without_mutation(ops_client):
|
||||
response = ops_client.get("/api/v1/privacy/customers/CUS-0001/export")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-disposition"] == 'attachment; filename="CUS-0001-privacy.json"'
|
||||
assert response.json()["customer"]["public_ref"] == "CUS-0001"
|
||||
assert isinstance(response.json()["bookings"], list)
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "privacy_customer_exported"}).json()
|
||||
assert any(event["metadata"]["customer_ref"] == "CUS-0001" for event in events)
|
||||
|
||||
|
||||
def test_active_customer_cannot_be_anonymized(ops_client):
|
||||
response = ops_client.post(
|
||||
"/api/v1/privacy/customers/CUS-0042/anonymize",
|
||||
json={"confirmation": "CUS-0042", "reason": "Verified erasure request"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_eligible_customer_is_irreversibly_anonymized_without_pii_in_audit(ops_client):
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-PRIVACY",
|
||||
first_name="Private",
|
||||
last_name="Person",
|
||||
email="private.person@example.test",
|
||||
phone="+32000000000",
|
||||
postal_code="1000",
|
||||
city="Brussels",
|
||||
date_of_birth=None,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
customer_id = customer.id
|
||||
try:
|
||||
mismatch = ops_client.post(
|
||||
"/api/v1/privacy/customers/CUS-PRIVACY/anonymize",
|
||||
json={"confirmation": "CUS-WRONG", "reason": "Verified erasure request"},
|
||||
)
|
||||
assert mismatch.status_code == 422
|
||||
response = ops_client.post(
|
||||
"/api/v1/privacy/customers/CUS-PRIVACY/anonymize",
|
||||
json={"confirmation": "CUS-PRIVACY", "reason": "Verified erasure request"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "anonymized"
|
||||
with SessionLocal() as db:
|
||||
customer = db.scalar(select(Customer).where(Customer.id == customer_id))
|
||||
assert customer is not None
|
||||
assert customer.email is None
|
||||
assert customer.phone is None
|
||||
assert customer.first_name == "Anoniem"
|
||||
event = db.scalar(
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.action == "privacy_customer_anonymized")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
)
|
||||
assert event is not None
|
||||
serialized = f"{event.before_json}{event.after_json}{event.metadata_json}"
|
||||
assert "private.person@example.test" not in serialized
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id))
|
||||
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_audit_csv_export_is_bounded_and_audited(ops_client):
|
||||
response = ops_client.get("/api/v1/audit/export.csv")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/csv")
|
||||
assert response.text.startswith("id,occurred_at,actor_type")
|
||||
invalid = ops_client.get(
|
||||
"/api/v1/audit/export.csv",
|
||||
params={"occurred_from": "2025-01-01T00:00:00Z", "occurred_to": "2026-01-01T00:00:00Z"},
|
||||
)
|
||||
assert invalid.status_code == 422
|
||||
@@ -54,6 +54,9 @@ services:
|
||||
OIDC_DEFAULT_ROLE: ${OIDC_DEFAULT_ROLE:-rental_employee}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
METRICS_BEARER_TOKEN: ${METRICS_BEARER_TOKEN:-}
|
||||
PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS: ${PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS:-30}
|
||||
PRIVACY_AUDIT_RETENTION_DAYS: ${PRIVACY_AUDIT_RETENTION_DAYS:-2555}
|
||||
PRIVACY_AUDIT_EXPORT_MAX_ROWS: ${PRIVACY_AUDIT_EXPORT_MAX_ROWS:-10000}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
|
||||
@@ -139,6 +139,43 @@ paths:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
title: Response Demo Reset Api V1 Demo Reset Post
|
||||
/api/v1/auth/oidc/status:
|
||||
get:
|
||||
tags:
|
||||
- auth
|
||||
summary: Oidc Status
|
||||
operationId: oidc_status_api_v1_auth_oidc_status_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OidcStatusOut'
|
||||
/api/v1/auth/oidc/login:
|
||||
get:
|
||||
tags:
|
||||
- auth
|
||||
summary: Oidc Login
|
||||
operationId: oidc_login_api_v1_auth_oidc_login_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
/api/v1/auth/oidc/callback:
|
||||
get:
|
||||
tags:
|
||||
- auth
|
||||
summary: Oidc Callback
|
||||
operationId: oidc_callback_api_v1_auth_oidc_callback_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
/api/v1/auth/login:
|
||||
post:
|
||||
tags:
|
||||
@@ -762,6 +799,43 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/v1/audit/export.csv:
|
||||
get:
|
||||
tags:
|
||||
- audit
|
||||
summary: Export Audit Csv
|
||||
operationId: export_audit_csv_api_v1_audit_export_csv_get
|
||||
parameters:
|
||||
- name: occurred_from
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
title: Occurred From
|
||||
- name: occurred_to
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
title: Occurred To
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/v1/audit:
|
||||
get:
|
||||
tags:
|
||||
@@ -1969,6 +2043,76 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/v1/privacy/retention:
|
||||
get:
|
||||
tags:
|
||||
- privacy
|
||||
summary: Retention Status
|
||||
operationId: retention_status_api_v1_privacy_retention_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrivacyRetentionOut'
|
||||
/api/v1/privacy/customers/{public_ref}/export:
|
||||
get:
|
||||
tags:
|
||||
- privacy
|
||||
summary: Export Customer Data
|
||||
operationId: export_customer_data_api_v1_privacy_customers__public_ref__export_get
|
||||
parameters:
|
||||
- name: public_ref
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Public Ref
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/v1/privacy/customers/{public_ref}/anonymize:
|
||||
post:
|
||||
tags:
|
||||
- privacy
|
||||
summary: Anonymize Customer
|
||||
operationId: anonymize_customer_api_v1_privacy_customers__public_ref__anonymize_post
|
||||
parameters:
|
||||
- name: public_ref
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
title: Public Ref
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerAnonymizeRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CustomerAnonymizeResult'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
components:
|
||||
schemas:
|
||||
ApplyRecommendedStatusRequest:
|
||||
@@ -2613,6 +2757,44 @@ components:
|
||||
- display_name
|
||||
- role
|
||||
title: CurrentUser
|
||||
CustomerAnonymizeRequest:
|
||||
properties:
|
||||
confirmation:
|
||||
type: string
|
||||
maxLength: 20
|
||||
minLength: 1
|
||||
title: Confirmation
|
||||
reason:
|
||||
type: string
|
||||
maxLength: 500
|
||||
minLength: 8
|
||||
title: Reason
|
||||
type: object
|
||||
required:
|
||||
- confirmation
|
||||
- reason
|
||||
title: CustomerAnonymizeRequest
|
||||
CustomerAnonymizeResult:
|
||||
properties:
|
||||
public_ref:
|
||||
type: string
|
||||
title: Public Ref
|
||||
anonymized_at:
|
||||
type: string
|
||||
format: date-time
|
||||
title: Anonymized At
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- anonymized
|
||||
- already_anonymized
|
||||
title: Status
|
||||
type: object
|
||||
required:
|
||||
- public_ref
|
||||
- anonymized_at
|
||||
- status
|
||||
title: CustomerAnonymizeResult
|
||||
CustomerOptionOut:
|
||||
properties:
|
||||
public_ref:
|
||||
@@ -3554,6 +3736,20 @@ components:
|
||||
- starts_at
|
||||
- at_risk
|
||||
title: NextBookingRisk
|
||||
OidcStatusOut:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
title: Enabled
|
||||
provider_name:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Provider Name
|
||||
type: object
|
||||
required:
|
||||
- enabled
|
||||
title: OidcStatusOut
|
||||
OperationsSummaryOut:
|
||||
properties:
|
||||
tenant:
|
||||
@@ -3583,6 +3779,31 @@ components:
|
||||
- email
|
||||
- password
|
||||
title: PasswordLoginRequest
|
||||
PrivacyRetentionOut:
|
||||
properties:
|
||||
minimum_booking_retention_days:
|
||||
type: integer
|
||||
title: Minimum Booking Retention Days
|
||||
audit_retention_days:
|
||||
type: integer
|
||||
title: Audit Retention Days
|
||||
customers_total:
|
||||
type: integer
|
||||
title: Customers Total
|
||||
customers_anonymized:
|
||||
type: integer
|
||||
title: Customers Anonymized
|
||||
customers_eligible:
|
||||
type: integer
|
||||
title: Customers Eligible
|
||||
type: object
|
||||
required:
|
||||
- minimum_booking_retention_days
|
||||
- audit_retention_days
|
||||
- customers_total
|
||||
- customers_anonymized
|
||||
- customers_eligible
|
||||
title: PrivacyRetentionOut
|
||||
ProcedureDocumentOut:
|
||||
properties:
|
||||
id:
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
For the demo-specific 5-minute/10-minute walkthroughs, reset behaviour, Unraid
|
||||
redeploy/rollback steps and troubleshooting, see `docs/demo-release/demo-runbook.md`.
|
||||
This document covers general environment bootstrap and n8n setup.
|
||||
Privacy retention, data-subject exports, anonymisation and incident governance are
|
||||
defined in `docs/18-privacy-governance.md`.
|
||||
|
||||
## Bootstrap (clean checkout)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Privacy and data governance
|
||||
|
||||
## Scope and ownership
|
||||
|
||||
MobilityOps stores operational users, customers, bookings, vehicle inspections,
|
||||
maintenance, data-quality issues, workflow state and audit events. The deployed demo
|
||||
contains synthetic data only. Before introducing real data, the deploying organisation
|
||||
must document its controller/processor roles, lawful bases, contact point and approved
|
||||
retention values. Operations Managers are the only role permitted to export or anonymise.
|
||||
|
||||
## Retention policy
|
||||
|
||||
- Customer PII may be anonymised only when no reserved or active booking exists and the
|
||||
newest booking is older than `PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS` (default 30).
|
||||
- Audit events are configured for 2,555 days by default. They are immutable operational
|
||||
evidence; changing this period requires legal approval and a separate, audited purge
|
||||
implementation. MobilityOps reports the policy but never silently deletes audit data.
|
||||
- Database backups default to 30 days with at least seven newest recovery points. A
|
||||
configured secondary mount must follow the same policy.
|
||||
- Logs are bounded by Docker rotation. They must not contain request bodies, passwords,
|
||||
OIDC tokens or customer fields.
|
||||
|
||||
## Data-subject request procedure
|
||||
|
||||
1. Verify the requester's identity outside MobilityOps and record the case reference.
|
||||
2. In **Privacy management**, download the customer JSON. The export action is audited.
|
||||
3. Review active/recent booking blockers and applicable legal retention obligations.
|
||||
4. Enter a reason and the exact customer reference to anonymise. The operation is
|
||||
irreversible and row-locked. It clears name, email, phone, address and birth date but
|
||||
preserves the stable reference and operational bookings so financial/operational
|
||||
evidence is not corrupted.
|
||||
5. Verify the resulting audit event. Its before/after payload records only anonymisation
|
||||
state; erased PII is deliberately not copied into the audit log.
|
||||
6. Handle verified backups according to their bounded retention; never mutate individual
|
||||
dump files.
|
||||
|
||||
## Audit export and access review
|
||||
|
||||
Audit CSV exports are manager-only, limited to a maximum 90-day range and a configurable
|
||||
row cap. Every export is itself audited. Review manager accounts, external OIDC bindings,
|
||||
failed sign-ins, privacy exports and anonymisations periodically. Deactivate access at the
|
||||
canonical MobilityOps user record; this invalidates subsequent operational requests.
|
||||
|
||||
## Incident handling
|
||||
|
||||
Preserve relevant JSON logs, correlation IDs and audit exports; do not overwrite the
|
||||
database or backups. Rotate affected secrets, disable OIDC or external integrations when
|
||||
needed, assess notification obligations and restore only through the guarded runbook.
|
||||
Record all incident decisions outside the application in the organisation's incident
|
||||
system.
|
||||
@@ -25,7 +25,7 @@ test.beforeEach(async ({ page, request }) => {
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
test("all seven nav items navigate correctly", async ({ page }) => {
|
||||
test("all manager navigation items navigate correctly", async ({ page }) => {
|
||||
const items: [string, RegExp][] = [
|
||||
["Overview", /\/dashboard$/],
|
||||
["Fleet", /\/vehicles$/],
|
||||
@@ -34,6 +34,8 @@ test("all seven nav items navigate correctly", async ({ page }) => {
|
||||
["Knowledge", /\/knowledge$/],
|
||||
["Integrations", /\/automation$/],
|
||||
["Audit trail", /\/audit$/],
|
||||
["Users", /\/users$/],
|
||||
["Privacy", /\/privacy$/],
|
||||
];
|
||||
const primaryNavigation = page.getByRole("navigation", { name: "Primary navigation" });
|
||||
for (const [label, urlPattern] of items) {
|
||||
@@ -378,6 +380,7 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
|
||||
await expect(page.getByRole("link", { name: "Data quality" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Integrations" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Audit trail" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Privacy" })).toHaveCount(0);
|
||||
|
||||
// Direct URL navigation is still blocked server-side and shows the same restricted
|
||||
// message as a defense-in-depth measure, not just a hidden button.
|
||||
@@ -394,6 +397,9 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
|
||||
await page.goto("/audit");
|
||||
await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible();
|
||||
|
||||
await page.goto("/privacy");
|
||||
await expect(page.getByText("These governance functions are available to Operations Managers only.")).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
async function resetAndLogin(request: APIRequestContext, page: Page) {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
}
|
||||
|
||||
test("privacy centre reports policy and produces an audited CSV export", async ({ page, request }) => {
|
||||
await resetAndLogin(request, page);
|
||||
await page.goto("/privacy");
|
||||
await expect(page.getByRole("heading", { name: "Privacybeheer" })).toBeVisible();
|
||||
await expect(page.getByText("30 dagen")).toBeVisible();
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("link", { name: "Audit CSV downloaden" }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe("mobilityops-audit.csv");
|
||||
});
|
||||
|
||||
test("privacy centre refuses anonymisation of a customer with an active booking", async ({ page, request }) => {
|
||||
await resetAndLogin(request, page);
|
||||
await page.goto("/privacy");
|
||||
await page.getByLabel("Klantreferentie").first().fill("CUS-0042");
|
||||
await page.getByLabel("Gemotiveerde reden").fill("Gevalideerd verzoek van de betrokkene");
|
||||
await page.getByLabel(/Typ CUS-0042 ter bevestiging/).fill("CUS-0042");
|
||||
await page.getByRole("button", { name: "Definitief anonimiseren" }).click();
|
||||
await expect(page.getByText(/kon niet worden uitgevoerd/)).toBeVisible();
|
||||
});
|
||||
@@ -20,6 +20,7 @@ const Audit = lazy(() => import("./pages/Audit").then((module) => ({ default: mo
|
||||
const AboutDemo = lazy(() => import("./pages/AboutDemo").then((module) => ({ default: module.AboutDemo })));
|
||||
const Scenarios = lazy(() => import("./pages/Scenarios").then((module) => ({ default: module.Scenarios })));
|
||||
const Users = lazy(() => import("./pages/Users").then((module) => ({ default: module.Users })));
|
||||
const Privacy = lazy(() => import("./pages/Privacy").then((module) => ({ default: module.Privacy })));
|
||||
|
||||
function deferredPage(element: ReactNode) {
|
||||
return <Suspense fallback={<div className="route-loading" role="status"><span className="spinner" /></div>}>{element}</Suspense>;
|
||||
@@ -51,6 +52,7 @@ export function App() {
|
||||
<Route path="/knowledge" element={deferredPage(<Knowledge />)} />
|
||||
<Route path="/audit" element={deferredPage(<Audit />)} />
|
||||
<Route path="/users" element={deferredPage(<Users />)} />
|
||||
<Route path="/privacy" element={deferredPage(<Privacy />)} />
|
||||
<Route path="/about" element={deferredPage(<AboutDemo />)} />
|
||||
<Route path="/scenarios" element={deferredPage(<Scenarios />)} />
|
||||
</Route>
|
||||
|
||||
@@ -90,6 +90,20 @@ export interface UserRecord {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface PrivacyRetention {
|
||||
minimum_booking_retention_days: number;
|
||||
audit_retention_days: number;
|
||||
customers_total: number;
|
||||
customers_anonymized: number;
|
||||
customers_eligible: number;
|
||||
}
|
||||
|
||||
export interface CustomerAnonymizeResult {
|
||||
public_ref: string;
|
||||
anonymized_at: string;
|
||||
status: "anonymized" | "already_anonymized";
|
||||
}
|
||||
|
||||
export interface Inspection {
|
||||
public_ref: string;
|
||||
booking_ref: string;
|
||||
|
||||
@@ -70,6 +70,7 @@ const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
|
||||
{ to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
|
||||
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
|
||||
{ to: "/users", labelKey: "items.users", icon: "activity", roles: ["operations_manager"] },
|
||||
{ to: "/privacy", labelKey: "items.privacy", icon: "shield", roles: ["operations_manager"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -16,6 +16,7 @@ import demoNl from "./locales/nl-BE/demo.json";
|
||||
import errorsNl from "./locales/nl-BE/errors.json";
|
||||
import accessibilityNl from "./locales/nl-BE/accessibility.json";
|
||||
import operationsNl from "./locales/nl-BE/operations.json";
|
||||
import privacyNl from "./locales/nl-BE/privacy.json";
|
||||
|
||||
import commonEn from "./locales/en-GB/common.json";
|
||||
import authEn from "./locales/en-GB/auth.json";
|
||||
@@ -32,6 +33,7 @@ import demoEn from "./locales/en-GB/demo.json";
|
||||
import errorsEn from "./locales/en-GB/errors.json";
|
||||
import accessibilityEn from "./locales/en-GB/accessibility.json";
|
||||
import operationsEn from "./locales/en-GB/operations.json";
|
||||
import privacyEn from "./locales/en-GB/privacy.json";
|
||||
|
||||
import commonFr from "./locales/fr-BE/common.json";
|
||||
import authFr from "./locales/fr-BE/auth.json";
|
||||
@@ -48,6 +50,7 @@ import demoFr from "./locales/fr-BE/demo.json";
|
||||
import errorsFr from "./locales/fr-BE/errors.json";
|
||||
import accessibilityFr from "./locales/fr-BE/accessibility.json";
|
||||
import operationsFr from "./locales/fr-BE/operations.json";
|
||||
import privacyFr from "./locales/fr-BE/privacy.json";
|
||||
|
||||
export const SUPPORTED_LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
|
||||
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
@@ -70,6 +73,7 @@ export const NAMESPACES = [
|
||||
"errors",
|
||||
"accessibility",
|
||||
"operations",
|
||||
"privacy",
|
||||
] as const;
|
||||
|
||||
function readStoredLanguage(): SupportedLanguage {
|
||||
@@ -122,6 +126,7 @@ void i18n
|
||||
errors: errorsNl,
|
||||
accessibility: accessibilityNl,
|
||||
operations: operationsNl,
|
||||
privacy: privacyNl,
|
||||
},
|
||||
"en-GB": {
|
||||
common: commonEn,
|
||||
@@ -139,6 +144,7 @@ void i18n
|
||||
errors: errorsEn,
|
||||
accessibility: accessibilityEn,
|
||||
operations: operationsEn,
|
||||
privacy: privacyEn,
|
||||
},
|
||||
"fr-BE": {
|
||||
common: commonFr,
|
||||
@@ -156,6 +162,7 @@ void i18n
|
||||
errors: errorsFr,
|
||||
accessibility: accessibilityFr,
|
||||
operations: operationsFr,
|
||||
privacy: privacyFr,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"knowledge": "Knowledge",
|
||||
"integrations": "Integrations",
|
||||
"audit": "Audit trail",
|
||||
"users": "Users"
|
||||
"users": "Users",
|
||||
"privacy": "Privacy"
|
||||
},
|
||||
"primaryNavLabel": "Primary navigation",
|
||||
"mobileNavLabel": "Mobile navigation",
|
||||
@@ -42,7 +43,8 @@
|
||||
"quality": "Quality workbench",
|
||||
"knowledge": "Procedure assistant",
|
||||
"integrations": "Automation and integration status",
|
||||
"audit": "Audit history"
|
||||
"audit": "Audit history",
|
||||
"privacy": "Privacy management"
|
||||
},
|
||||
"switchRole": "Switch role",
|
||||
"switchRoleTitle": "Switch demo role",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"eyebrow": "Assure / Governance",
|
||||
"title": "Privacy management",
|
||||
"description": "Export personal data, monitor retention and anonymise safely with a complete audit trail.",
|
||||
"managerOnly": "These governance functions are available to Operations Managers only.",
|
||||
"loading": "Loading privacy policy…",
|
||||
"error": "The privacy action could not be completed. Check the reference and retention period.",
|
||||
"policyTitle": "Retention policy",
|
||||
"totalCustomers": "Customers",
|
||||
"eligibleCustomers": "Eligible",
|
||||
"anonymizedCustomers": "Anonymised",
|
||||
"bookingRetention": "Minimum booking retention",
|
||||
"auditRetention": "Audit retention",
|
||||
"days_one": "{{count}} day",
|
||||
"days_other": "{{count}} days",
|
||||
"customerRef": "Customer reference",
|
||||
"exports": {"title":"Data exports","detail":"Download a customer file as JSON or a bounded audit export as CSV. Every export is logged.","customer":"Download customer file","audit":"Download audit CSV"},
|
||||
"anonymize": {"title":"Anonymise customer","detail":"Irreversible. Active bookings and bookings newer than {{days}} days block this action.","reason":"Reasoned justification","confirmation":"Type {{ref}} to confirm","submit":"Anonymise permanently","working":"Anonymising…","anonymized":"{{ref}} was anonymised.","already_anonymized":"{{ref}} was already anonymised."}
|
||||
}
|
||||
@@ -12,7 +12,8 @@
|
||||
"knowledge": "Connaissances",
|
||||
"integrations": "Intégrations",
|
||||
"audit": "Piste d'audit",
|
||||
"users": "Utilisateurs"
|
||||
"users": "Utilisateurs",
|
||||
"privacy": "Confidentialité"
|
||||
},
|
||||
"primaryNavLabel": "Navigation principale",
|
||||
"mobileNavLabel": "Navigation mobile",
|
||||
@@ -42,7 +43,8 @@
|
||||
"quality": "Atelier qualité",
|
||||
"knowledge": "Assistant de procédures",
|
||||
"integrations": "Statut d'automatisation et d'intégration",
|
||||
"audit": "Historique d’audit"
|
||||
"audit": "Historique d’audit",
|
||||
"privacy": "Gestion de la confidentialité"
|
||||
},
|
||||
"switchRole": "Changer de rôle",
|
||||
"switchRoleTitle": "Changer de rôle de démo",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"eyebrow": "Surveiller / Gouvernance",
|
||||
"title": "Gestion de la confidentialité",
|
||||
"description": "Exportez les données personnelles, contrôlez la conservation et anonymisez avec une piste d’audit complète.",
|
||||
"managerOnly": "Ces fonctions de gouvernance sont réservées aux responsables des opérations.",
|
||||
"loading": "Chargement de la politique…",
|
||||
"error": "L’action n’a pas pu être exécutée. Vérifiez la référence et la durée de conservation.",
|
||||
"policyTitle": "Politique de conservation",
|
||||
"totalCustomers": "Clients",
|
||||
"eligibleCustomers": "Éligibles",
|
||||
"anonymizedCustomers": "Anonymisés",
|
||||
"bookingRetention": "Conservation minimale des réservations",
|
||||
"auditRetention": "Conservation de l’audit",
|
||||
"days_one": "{{count}} jour",
|
||||
"days_other": "{{count}} jours",
|
||||
"customerRef": "Référence client",
|
||||
"exports": {"title":"Exports de données","detail":"Téléchargez un dossier client JSON ou un export d’audit CSV limité. Chaque export est journalisé.","customer":"Télécharger le dossier","audit":"Télécharger l’audit CSV"},
|
||||
"anonymize": {"title":"Anonymiser un client","detail":"Irréversible. Les réservations actives ou de moins de {{days}} jours bloquent l’action.","reason":"Motif justifié","confirmation":"Saisissez {{ref}} pour confirmer","submit":"Anonymiser définitivement","working":"Anonymisation…","anonymized":"{{ref}} a été anonymisé.","already_anonymized":"{{ref}} était déjà anonymisé."}
|
||||
}
|
||||
@@ -12,7 +12,8 @@
|
||||
"knowledge": "Kennis",
|
||||
"integrations": "Integraties",
|
||||
"audit": "Auditgeschiedenis",
|
||||
"users": "Gebruikers"
|
||||
"users": "Gebruikers",
|
||||
"privacy": "Privacy"
|
||||
},
|
||||
"primaryNavLabel": "Hoofdnavigatie",
|
||||
"mobileNavLabel": "Mobiele navigatie",
|
||||
@@ -42,7 +43,8 @@
|
||||
"quality": "Kwaliteitswerkbank",
|
||||
"knowledge": "Procedureassistent",
|
||||
"integrations": "Automatiserings- en integratiestatus",
|
||||
"audit": "Auditgeschiedenis"
|
||||
"audit": "Auditgeschiedenis",
|
||||
"privacy": "Privacybeheer"
|
||||
},
|
||||
"switchRole": "Wissel van rol",
|
||||
"switchRoleTitle": "Wissel van demo-rol",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"eyebrow": "Bewaken / Governance",
|
||||
"title": "Privacybeheer",
|
||||
"description": "Exporteer persoonsgegevens, bewaak bewaartermijnen en anonimiseer veilig met een volledig auditspoor.",
|
||||
"managerOnly": "Deze governancefuncties zijn uitsluitend beschikbaar voor Operationsmanagers.",
|
||||
"loading": "Privacybeleid laden…",
|
||||
"error": "De privacyactie kon niet worden uitgevoerd. Controleer de referentie en bewaartermijn.",
|
||||
"policyTitle": "Bewaarbeleid",
|
||||
"totalCustomers": "Klanten",
|
||||
"eligibleCustomers": "Anonimiseerbaar",
|
||||
"anonymizedCustomers": "Geanonimiseerd",
|
||||
"bookingRetention": "Minimum boekingsretentie",
|
||||
"auditRetention": "Auditretentie",
|
||||
"days_one": "{{count}} dag",
|
||||
"days_other": "{{count}} dagen",
|
||||
"customerRef": "Klantreferentie",
|
||||
"exports": {"title":"Gegevensexport","detail":"Download een klantdossier als JSON of een begrensde auditexport als CSV. Elke export wordt gelogd.","customer":"Klantdossier downloaden","audit":"Audit CSV downloaden"},
|
||||
"anonymize": {"title":"Klant anonimiseren","detail":"Onomkeerbaar. Actieve boekingen en boekingen jonger dan {{days}} dagen blokkeren deze actie.","reason":"Gemotiveerde reden","confirmation":"Typ {{ref}} ter bevestiging","submit":"Definitief anonimiseren","working":"Anonimiseren…","anonymized":"{{ref}} is geanonimiseerd.","already_anonymized":"{{ref}} was al geanonimiseerd."}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import type { CustomerAnonymizeResult, PrivacyRetention } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
|
||||
export function Privacy() {
|
||||
const { t } = useTranslation("privacy");
|
||||
const { user } = useAuth();
|
||||
const [retention, setRetention] = useState<PrivacyRetention | null>(null);
|
||||
const [customerRef, setCustomerRef] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
const loadRetention = useCallback(() => {
|
||||
setError(false);
|
||||
api.get<PrivacyRetention>("/api/v1/privacy/retention").then(setRetention).catch(() => setError(true));
|
||||
}, []);
|
||||
|
||||
useEffect(loadRetention, [loadRetention]);
|
||||
|
||||
async function anonymize(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setWorking(true);
|
||||
setMessage(null);
|
||||
setError(false);
|
||||
try {
|
||||
const result = await api.post<CustomerAnonymizeResult>(
|
||||
`/api/v1/privacy/customers/${encodeURIComponent(customerRef)}/anonymize`,
|
||||
{ confirmation, reason },
|
||||
);
|
||||
setMessage(t(`anonymize.${result.status}`, { ref: result.public_ref }));
|
||||
setConfirmation("");
|
||||
setReason("");
|
||||
loadRetention();
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (user?.role !== "operations_manager") return <div className="page">
|
||||
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} />
|
||||
</div>;
|
||||
return <div className="page privacy-page">
|
||||
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
|
||||
{error && <ErrorState message={t("error")} />}
|
||||
{!retention && !error && <LoadingState label={t("loading")} />}
|
||||
{retention && <>
|
||||
<section className="privacy-metrics" aria-label={t("policyTitle")}>
|
||||
<article><span>{t("totalCustomers")}</span><strong>{retention.customers_total}</strong></article>
|
||||
<article><span>{t("eligibleCustomers")}</span><strong>{retention.customers_eligible}</strong></article>
|
||||
<article><span>{t("anonymizedCustomers")}</span><strong>{retention.customers_anonymized}</strong></article>
|
||||
<article><span>{t("bookingRetention")}</span><strong>{t("days", { count: retention.minimum_booking_retention_days })}</strong></article>
|
||||
<article><span>{t("auditRetention")}</span><strong>{t("days", { count: retention.audit_retention_days })}</strong></article>
|
||||
</section>
|
||||
<div className="privacy-workspaces">
|
||||
<section className="panel privacy-panel">
|
||||
<h2>{t("exports.title")}</h2><p>{t("exports.detail")}</p>
|
||||
<label>{t("customerRef")}<input value={customerRef} onChange={(event) => setCustomerRef(event.target.value.toUpperCase())} placeholder="CUS-0001" /></label>
|
||||
<div className="form-actions-inline">
|
||||
<a className={`button ${customerRef ? "" : "is-disabled"}`} aria-disabled={!customerRef} href={customerRef ? `/api/v1/privacy/customers/${encodeURIComponent(customerRef)}/export` : undefined}>{t("exports.customer")}</a>
|
||||
<a className="button" href="/api/v1/audit/export.csv">{t("exports.audit")}</a>
|
||||
</div>
|
||||
</section>
|
||||
<form className="panel privacy-panel" onSubmit={anonymize}>
|
||||
<h2>{t("anonymize.title")}</h2><p>{t("anonymize.detail", { days: retention.minimum_booking_retention_days })}</p>
|
||||
<label>{t("customerRef")}<input value={customerRef} onChange={(event) => setCustomerRef(event.target.value.toUpperCase())} placeholder="CUS-0001" required /></label>
|
||||
<label>{t("anonymize.reason")}<textarea value={reason} onChange={(event) => setReason(event.target.value)} minLength={8} required /></label>
|
||||
<label>{t("anonymize.confirmation", { ref: customerRef || "CUS-…" })}<input value={confirmation} onChange={(event) => setConfirmation(event.target.value.toUpperCase())} required /></label>
|
||||
<button className="button button-danger" disabled={working || confirmation !== customerRef || !customerRef}>{working ? t("anonymize.working") : t("anonymize.submit")}</button>
|
||||
</form>
|
||||
</div>
|
||||
{message && <p className="success" role="status">{message}</p>}
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
@@ -117,6 +117,17 @@ a:hover { color: var(--teal); }
|
||||
.login-divider::before, .login-divider::after { content: ""; height: 1px; flex: 1; background: var(--line); }
|
||||
.login-oidc { width: 100%; min-height: 44px; justify-content: center; gap: 8px; background: white; border: 1px solid var(--line-strong); }
|
||||
.login-oidc svg { width: 17px; }
|
||||
.privacy-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
|
||||
.privacy-metrics article { min-height: 92px; padding: 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.privacy-metrics span { display: block; color: var(--muted); font-size: .68rem; }
|
||||
.privacy-metrics strong { display: block; margin-top: 10px; font-size: 1.25rem; }
|
||||
.privacy-workspaces { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.privacy-panel { display: grid; align-content: start; gap: 14px; padding: 20px; }
|
||||
.privacy-panel h2, .privacy-panel p { margin: 0; }
|
||||
.privacy-panel label { display: grid; gap: 6px; }
|
||||
.privacy-panel textarea { min-height: 92px; resize: vertical; }
|
||||
.form-actions-inline { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.button.is-disabled { pointer-events: none; opacity: .5; }
|
||||
.demo-badge { position: relative; }
|
||||
.demo-badge-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: #48566a; background: #eaf0f5; border: 1px solid #d7e0e8; border-radius: 999px; font-size: .68rem; font-weight: 700; letter-spacing: .02em; cursor: pointer; }
|
||||
.demo-badge-trigger:hover { background: #dfe8ef; }
|
||||
@@ -513,6 +524,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.privacy-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }.privacy-workspaces { grid-template-columns: 1fr; }
|
||||
.app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.topbar-meta > .language-switcher-compact { display: none; }.sidebar-language { display: block; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 68px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 44px; min-height: 44px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .69rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 68px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user