M21: add optional organisation identity

This commit is contained in:
NuklearRabbit
2026-08-10 15:35:25 +02:00
parent 509cb95110
commit c3f1cfc699
38 changed files with 563 additions and 110 deletions
+11
View File
@@ -14,6 +14,17 @@ TZ=Europe/Brussels
# otherwise browsers will silently drop the cookie and no one can log in. # otherwise browsers will silently drop the cookie and no one can log in.
SESSION_COOKIE_SECURE=false SESSION_COOKIE_SECURE=false
# Optional OpenID Connect login. Public demo role buttons remain available when enabled.
OIDC_ENABLED=false
OIDC_PROVIDER_NAME=Organisatieaccount
OIDC_ISSUER_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=
OIDC_ALLOWED_EMAIL_DOMAINS=
OIDC_AUTO_PROVISION=true
OIDC_DEFAULT_ROLE=rental_employee
# Demo presentation (fictional org identity, badge/manifest, reset safety valve). # Demo presentation (fictional org identity, badge/manifest, reset safety valve).
# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent # 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. # of role -- a safety valve for any environment where the dataset must not be rebuildable.
+17
View File
@@ -2503,3 +2503,20 @@ evidence yet."
`/mnt/user/appdata/mobilityops/backups/postgres/mobilityops-20260810T111913Z.dump`. `/mnt/user/appdata/mobilityops/backups/postgres/mobilityops-20260810T111913Z.dump`.
- Exact next action: none for the locked PoC. Routine operation, monitoring and any scope - Exact next action: none for the locked PoC. Routine operation, monitoring and any scope
expansion require a separate approved milestone. expansion require a separate approved milestone.
## M21 — optional organisation identity alongside the public demo (2026-08-10)
- Added standards-based OpenID Connect login while preserving both public demo roles and
the guided demo. Provider discovery, authorization-code exchange, state/nonce checks
and ID-token validation are delegated to Authlib's OIDC client.
- External identities bind uniquely to issuer plus subject. A verified email is required;
deployments can enforce an email-domain allowlist and disable auto-provisioning. New
users receive the least-privileged rental role and every provision/link/login is
audited. Deactivated users remain blocked by the canonical user record.
- Added the nullable external-identity migration `a81d0ce9f662`, configuration contract,
trilingual login action and runbook. OIDC secrets stay deployment-only.
- Added Starlette's supported `httpx2` test transport, removing the prior suite-wide
deprecation warning rather than suppressing it.
- Evidence: focused authentication **13 passed with zero warnings**; frontend production
build passed; ruff clean. Exact next action: implement structured request logging,
correlation, metrics, dashboards and alerts.
@@ -0,0 +1,28 @@
"""add optional external OIDC identity
Revision ID: a81d0ce9f662
Revises: f43d829ab610
"""
import sqlalchemy as sa
from alembic import op
revision = "a81d0ce9f662"
down_revision = "f43d829ab610"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("identity_provider", sa.String(80), nullable=True))
op.add_column("users", sa.Column("external_subject", sa.String(255), nullable=True))
op.create_unique_constraint(
"uq_user_external_identity", "users", ["identity_provider", "external_subject"]
)
def downgrade() -> None:
op.drop_constraint("uq_user_external_identity", "users", type_="unique")
op.drop_column("users", "external_subject")
op.drop_column("users", "identity_provider")
+4 -4
View File
@@ -77,14 +77,14 @@ def list_audit_events(
matched_ids: set[uuid.UUID] = set() matched_ids: set[uuid.UUID] = set()
for model in _ENTITY_MODELS.values(): for model in _ENTITY_MODELS.values():
matched_ids.update( matched_ids.update(
db.scalars(select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))).all() db.scalars(
select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))
).all()
) )
if not matched_ids: if not matched_ids:
if page is None: if page is None:
return [] return []
return AuditEventPageOut( return AuditEventPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
items=[], page=1, page_size=page_size, total=0, total_pages=1
)
stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids)) stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids))
if correlation_id: if correlation_id:
stmt = stmt.where(AuditEvent.correlation_id == correlation_id) stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
+147 -5
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
import time import time
import uuid import uuid
from authlib.integrations.starlette_client import OAuth, OAuthError # type: ignore[import-untyped]
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -17,12 +19,21 @@ from app.core.security import (
verify_password, verify_password,
) )
from app.models.user import User from app.models.user import User
from app.schemas import CurrentUser, PasswordLoginRequest from app.schemas import CurrentUser, OidcStatusOut, PasswordLoginRequest
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
from app.services.sessions import revoke_session from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
settings = get_settings() settings = get_settings()
oauth = OAuth()
if settings.oidc_enabled and settings.oidc_issuer_url:
oauth.register(
name="oidc",
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
server_metadata_url=f"{settings.oidc_issuer_url.rstrip('/')}/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
)
def _current_user_out(user: User) -> CurrentUser: def _current_user_out(user: User) -> CurrentUser:
@@ -36,14 +47,21 @@ def _current_user_out(user: User) -> CurrentUser:
def _set_session(response: Response, user: User) -> None: def _set_session(response: Response, user: User) -> None:
token = create_session_token( token = create_session_token(
SessionPayload( SessionPayload(
user_id=str(user.id), public_ref=user.public_ref, role=user.role, user_id=str(user.id),
display_name=user.display_name, issued_at=int(time.time()), public_ref=user.public_ref,
role=user.role,
display_name=user.display_name,
issued_at=int(time.time()),
session_id=str(uuid.uuid4()), session_id=str(uuid.uuid4()),
) )
) )
response.set_cookie( response.set_cookie(
settings.session_cookie_name, token, httponly=True, samesite="lax", settings.session_cookie_name,
secure=settings.session_cookie_secure, max_age=settings.session_ttl_seconds, token,
httponly=True,
samesite="lax",
secure=settings.session_cookie_secure,
max_age=settings.session_ttl_seconds,
) )
@@ -78,6 +96,130 @@ def bootstrap_initial_admin(db: Session) -> None:
db.commit() db.commit()
def _oidc_configured() -> bool:
return bool(
settings.oidc_enabled
and settings.oidc_issuer_url
and settings.oidc_client_id
and settings.oidc_client_secret
)
def _oidc_redirect_uri() -> str:
return settings.oidc_redirect_uri or (
f"{settings.mobilityops_public_url.rstrip('/')}/api/v1/auth/oidc/callback"
)
def _allowed_oidc_email(email: str) -> bool:
domains = {
value.strip().casefold()
for value in settings.oidc_allowed_email_domains.split(",")
if value.strip()
}
return not domains or email.rsplit("@", 1)[-1].casefold() in domains
def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
subject = str(claims.get("sub") or "").strip()
email = str(claims.get("email") or "").strip().lower()
if not subject or not email or claims.get("email_verified") is False:
raise HTTPException(status_code=401, detail="Verified OIDC email and subject are required")
if not _allowed_oidc_email(email):
raise HTTPException(status_code=403, detail="Email domain is not allowed")
provider = settings.oidc_issuer_url.rstrip("/")
user = db.scalar(
select(User).where(
User.identity_provider == provider,
User.external_subject == subject,
)
)
if user is None:
user = db.scalar(select(User).where(User.email == email))
if user is not None and user.external_subject not in (None, subject):
raise HTTPException(status_code=409, detail="Email is linked to another identity")
created = user is None
if created:
if not settings.oidc_auto_provision:
raise HTTPException(status_code=403, detail="OIDC user is not provisioned")
role = settings.oidc_default_role
if role not in {"operations_manager", "rental_employee"}:
role = "rental_employee"
user = User(
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
email=email,
password_hash=None,
display_name=str(claims.get("name") or email),
role=role,
active=True,
)
db.add(user)
db.flush()
assert user is not None
if not user.active:
raise HTTPException(status_code=403, detail="User is inactive")
user.identity_provider = provider
user.external_subject = subject
record_audit_event(
db,
actor_type="system" if created else "user",
actor_id=None if created else user.id,
actor_label=settings.oidc_provider_name,
action="oidc_user_provisioned" if created else "oidc_identity_linked",
entity_type="user",
entity_id=user.id,
metadata={"provider": provider},
)
return user
@router.get("/oidc/status", response_model=OidcStatusOut)
def oidc_status() -> OidcStatusOut:
return OidcStatusOut(
enabled=_oidc_configured(),
provider_name=settings.oidc_provider_name if _oidc_configured() else None,
)
@router.get("/oidc/login")
async def oidc_login(request: Request) -> Response:
if not _oidc_configured():
raise HTTPException(status_code=404, detail="OIDC login is not configured")
client = oauth.create_client("oidc")
if client is None:
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
return await client.authorize_redirect(request, _oidc_redirect_uri())
@router.get("/oidc/callback")
async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> Response:
if not _oidc_configured():
raise HTTPException(status_code=404, detail="OIDC login is not configured")
client = oauth.create_client("oidc")
if client is None:
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
try:
token = await client.authorize_access_token(request)
except OAuthError as exc:
raise HTTPException(status_code=401, detail="OIDC authentication failed") from exc
user = _resolve_oidc_user(db, dict(token.get("userinfo") or {}))
response = RedirectResponse(f"{settings.mobilityops_public_url.rstrip('/')}/dashboard")
_set_session(response, user)
record_audit_event(
db,
actor_type="user",
actor_id=user.id,
actor_label=user.display_name,
action="oidc_login",
entity_type="user",
entity_id=user.id,
metadata={"provider": settings.oidc_issuer_url.rstrip("/")},
)
db.commit()
return response
@router.post("/login", response_model=CurrentUser) @router.post("/login", response_model=CurrentUser)
def password_login( def password_login(
body: PasswordLoginRequest, body: PasswordLoginRequest,
+7 -5
View File
@@ -100,22 +100,24 @@ def get_dashboard(
if b.starts_at.date() == today and b.status in ("reserved", "active"): if b.starts_at.date() == today and b.status in ("reserved", "active"):
today_items.append( today_items.append(
TodayItem( TodayItem(
kind="departure", booking_ref=b.public_ref, vehicle_ref=vehicle_ref, kind="departure",
booking_ref=b.public_ref,
vehicle_ref=vehicle_ref,
scheduled_at=b.starts_at, scheduled_at=b.starts_at,
) )
) )
if b.ends_at.date() == today and b.status in ("active", "returned"): if b.ends_at.date() == today and b.status in ("active", "returned"):
today_items.append( today_items.append(
TodayItem( TodayItem(
kind="return", booking_ref=b.public_ref, vehicle_ref=vehicle_ref, kind="return",
booking_ref=b.public_ref,
vehicle_ref=vehicle_ref,
scheduled_at=b.ends_at, scheduled_at=b.ends_at,
) )
) )
today_items.sort(key=lambda item: item.scheduled_at) today_items.sort(key=lambda item: item.scheduled_at)
recent = db.scalars( recent = db.scalars(select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)).all()
select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)
).all()
recent_automation = [ recent_automation = [
AutomationRunOut( AutomationRunOut(
event_id=str(r.event_id), event_id=str(r.event_id),
+1 -3
View File
@@ -74,9 +74,7 @@ def demo_login(
@router.get("/session", response_model=CurrentUser) @router.get("/session", response_model=CurrentUser)
def get_session( def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
response: Response, user: CurrentUser = Depends(get_current_user)
) -> CurrentUser:
# Never let the browser (or an intermediary) cache an authentication check — a stale # Never let the browser (or an intermediary) cache an authentication check — a stale
# cached 200 here would keep showing a logged-out browser as authenticated. # cached 200 here would keep showing a logged-out browser as authenticated.
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
+6 -1
View File
@@ -42,7 +42,12 @@ def get_correlation_id(
def _audit_service_request( def _audit_service_request(
db: Session, *, client_id: str, tool: str, status_label: str, correlation_id: str, db: Session,
*,
client_id: str,
tool: str,
status_label: str,
correlation_id: str,
metadata: dict[str, object] | None = None, metadata: dict[str, object] | None = None,
) -> None: ) -> None:
record_audit_event( record_audit_event(
+4 -1
View File
@@ -143,7 +143,10 @@ def search(
) )
for b in db.scalars( for b in db.scalars(
select(Booking).where(Booking.public_ref.ilike(like)).order_by(Booking.starts_at.desc()).limit(5) select(Booking)
.where(Booking.public_ref.ilike(like))
.order_by(Booking.starts_at.desc())
.limit(5)
).all(): ).all():
results.append( results.append(
SearchResultItem( SearchResultItem(
+10
View File
@@ -51,6 +51,16 @@ class Settings(BaseSettings):
initial_admin_email: str = "" initial_admin_email: str = ""
initial_admin_password: str = "" initial_admin_password: str = ""
initial_admin_display_name: str = "Operations Manager" initial_admin_display_name: str = "Operations Manager"
mobilityops_public_url: str = "http://localhost:1228"
oidc_enabled: bool = False
oidc_provider_name: str = "Organisatieaccount"
oidc_issuer_url: str = ""
oidc_client_id: str = ""
oidc_client_secret: str = ""
oidc_redirect_uri: str = ""
oidc_allowed_email_domains: str = ""
oidc_auto_provision: bool = True
oidc_default_role: str = "rental_employee"
@lru_cache @lru_cache
+13 -3
View File
@@ -5,6 +5,7 @@ from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy import text from sqlalchemy import text
from starlette.middleware.sessions import SessionMiddleware
from app.api.routers import ( from app.api.routers import (
audit, audit,
@@ -43,6 +44,15 @@ async def lifespan(_app: FastAPI):
app = FastAPI(title=f"{PRODUCT_NAME} API", version="0.1.0", lifespan=lifespan) app = FastAPI(title=f"{PRODUCT_NAME} API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
SessionMiddleware,
secret_key=settings.app_secret,
session_cookie="mobilityops_oidc_state",
max_age=600,
same_site="lax",
https_only=settings.session_cookie_secure,
)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[o.strip() for o in settings.cors_allow_origins.split(",")], allow_origins=[o.strip() for o in settings.cors_allow_origins.split(",")],
@@ -95,9 +105,7 @@ def readiness() -> JSONResponse:
status_code=503, status_code=503,
content={"status": "not_ready", "service": "mobilityops-api", "database": "down"}, content={"status": "not_ready", "service": "mobilityops-api", "database": "down"},
) )
return JSONResponse( return JSONResponse(content={"status": "ready", "service": "mobilityops-api", "database": "up"})
content={"status": "ready", "service": "mobilityops-api", "database": "up"}
)
@app.get("/api/v1/system/status") @app.get("/api/v1/system/status")
@@ -107,6 +115,8 @@ def system_status() -> dict[str, object]:
"environment": settings.mobilityops_env, "environment": settings.mobilityops_env,
"demo_mode": settings.mobilityops_demo_mode, "demo_mode": settings.mobilityops_demo_mode,
"knowledge_provider": settings.knowledge_provider, "knowledge_provider": settings.knowledge_provider,
"oidc_enabled": auth.oidc_status().enabled,
"oidc_provider_name": auth.oidc_status().provider_name,
} }
+1 -3
View File
@@ -7,9 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column
class UUIDPrimaryKeyMixin: class UUIDPrimaryKeyMixin:
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
class TimestampMixin: class TimestampMixin:
+6 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Boolean, String from sqlalchemy import Boolean, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base from app.core.db import Base
@@ -9,6 +9,9 @@ ROLES = ("operations_manager", "rental_employee")
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "users" __tablename__ = "users"
__table_args__ = (
UniqueConstraint("identity_provider", "external_subject", name="uq_user_external_identity"),
)
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
email: Mapped[str | None] = mapped_column(String(320), unique=True, nullable=True) email: Mapped[str | None] = mapped_column(String(320), unique=True, nullable=True)
@@ -16,3 +19,5 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
display_name: Mapped[str] = mapped_column(String(120), nullable=False) display_name: Mapped[str] = mapped_column(String(120), nullable=False)
role: Mapped[str] = mapped_column(String(30), nullable=False) role: Mapped[str] = mapped_column(String(30), nullable=False)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
identity_provider: Mapped[str | None] = mapped_column(String(80), nullable=True)
external_subject: Mapped[str | None] = mapped_column(String(255), nullable=True)
+5
View File
@@ -379,6 +379,11 @@ class SearchResponse(BaseModel):
results: list[SearchResultItem] results: list[SearchResultItem]
class OidcStatusOut(BaseModel):
enabled: bool
provider_name: str | None = None
class N8nWorkflowEvidence(BaseModel): class N8nWorkflowEvidence(BaseModel):
name: str name: str
built: bool built: bool
+15 -13
View File
@@ -39,18 +39,14 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
overlap_issue = db.scalar( overlap_issue = db.scalar(
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP") select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP")
) )
failed_run = db.scalar( failed_run = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID))
select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID)
)
knowledge_health = get_knowledge_provider().health() knowledge_health = get_knowledge_provider().health()
# Human copy (title, problem statement, "demonstrates" summary) lives entirely in the # Human copy (title, problem statement, "demonstrates" summary) lives entirely in the
# frontend's demo.json (scenarios.items.<id>.*) so it's available in all three UI # frontend's demo.json (scenarios.items.<id>.*) so it's available in all three UI
# languages. This service only emits stable identifiers and message codes -- never # languages. This service only emits stable identifiers and message codes -- never
# display prose -- per the message_code + params architecture used across the app. # display prose -- per the message_code + params architecture used across the app.
return_ready = bool( return_ready = bool(booking and booking.status == "active" and booking.end_odometer_km is None)
booking and booking.status == "active" and booking.end_odometer_km is None
)
duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open") duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open")
overlap_ready = bool(overlap_issue and overlap_issue.status == "open") overlap_ready = bool(overlap_issue and overlap_issue.status == "open")
automation_ready = bool(failed_run and failed_run.delivery_status == "failed") automation_ready = bool(failed_run and failed_run.delivery_status == "failed")
@@ -65,7 +61,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
blocked_reason_code=( blocked_reason_code=(
None None
if return_ready if return_ready
else "bookingNotFound" if booking is None else "bookingAlreadyProcessed" else "bookingNotFound"
if booking is None
else "bookingAlreadyProcessed"
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
@@ -81,7 +79,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
blocked_reason_code=( blocked_reason_code=(
None None
if duplicate_ready if duplicate_ready
else "duplicateIssueNotFound" if duplicate_issue is None else "issueAlreadyResolved" else "duplicateIssueNotFound"
if duplicate_issue is None
else "issueAlreadyResolved"
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
@@ -95,7 +95,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
blocked_reason_code=( blocked_reason_code=(
None None
if overlap_ready if overlap_ready
else "overlapIssueNotFound" if overlap_issue is None else "issueAlreadyResolved" else "overlapIssueNotFound"
if overlap_issue is None
else "issueAlreadyResolved"
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
@@ -107,7 +109,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
blocked_reason_code=( blocked_reason_code=(
None None
if automation_ready if automation_ready
else "failedEventNotFound" if failed_run is None else "eventAlreadyRecovered" else "failedEventNotFound"
if failed_run is None
else "eventAlreadyRecovered"
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
@@ -172,9 +176,7 @@ def scenario_integrity_report(db: Session) -> dict:
overview already use, so this can never drift from what a visitor actually sees.""" overview already use, so this can never drift from what a visitor actually sees."""
scenarios = _scenarios(db) scenarios = _scenarios(db)
not_ready = [ not_ready = [
{"id": s.id, "reason_code": s.blocked_reason_code} {"id": s.id, "reason_code": s.blocked_reason_code} for s in scenarios if not s.ready
for s in scenarios
if not s.ready
] ]
return {"all_ready": len(not_ready) == 0, "not_ready": not_ready} return {"all_ready": len(not_ready) == 0, "not_ready": not_ready}
+1 -2
View File
@@ -141,8 +141,7 @@ def _deliver_one(event_id: uuid.UUID) -> None:
# succeeded. # succeeded.
success = False success = False
error = ( error = (
"Unexpected non-JSON-object response from n8n " f"Unexpected non-JSON-object response from n8n (status {response.status_code})"
f"(status {response.status_code})"
) )
error_code = "malformedResponse" error_code = "malformedResponse"
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
+2 -6
View File
@@ -107,9 +107,7 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
# procedures_sync_result), the same "the workflow's own callback is the evidence" # procedures_sync_result), the same "the workflow's own callback is the evidence"
# pattern the scheduled scan and error handler already use below. # pattern the scheduled scan and error handler already use below.
latest_procedure_sync_at = db.scalar( latest_procedure_sync_at = db.scalar(
select(func.max(AuditEvent.occurred_at)).where( select(func.max(AuditEvent.occurred_at)).where(AuditEvent.action == "n8n_procedures_synced")
AuditEvent.action == "n8n_procedures_synced"
)
) )
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error # Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
@@ -240,9 +238,7 @@ def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
`MCP_HUB_REGISTRATION_ENABLED` flag flipped on. Every `mcp_tool_request` call `MCP_HUB_REGISTRATION_ENABLED` flag flipped on. Every `mcp_tool_request` call
already writes an `AuditEvent` (see `app/api/routers/mcp_integrations.py`).""" already writes an `AuditEvent` (see `app/api/routers/mcp_integrations.py`)."""
total_calls = ( total_calls = (
db.scalar( db.scalar(select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request"))
select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request")
)
or 0 or 0
) )
latest_call_row = db.execute( latest_call_row = db.execute(
+137 -16
View File
@@ -14,25 +14,146 @@ DEFAULT_LANGUAGE = "en-GB"
STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = { STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = {
"en-GB": { "en-GB": {
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "a",
"to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then", "an",
"do", "does", "did", "must", "may", "can", "could", "should", "would", "the",
"i", "you", "it", "we", "they", "my", "your", "what", "when", "how", "is",
"with", "without", "this", "that", "these", "those", "not", "no", "are",
"was",
"were",
"be",
"been",
"being",
"to",
"of",
"in",
"on",
"at",
"for",
"and",
"or",
"but",
"if",
"then",
"do",
"does",
"did",
"must",
"may",
"can",
"could",
"should",
"would",
"i",
"you",
"it",
"we",
"they",
"my",
"your",
"what",
"when",
"how",
"with",
"without",
"this",
"that",
"these",
"those",
"not",
"no",
}, },
"nl-BE": { "nl-BE": {
"een", "de", "het", "is", "zijn", "was", "waren", "worden", "wordt", "een",
"van", "in", "op", "voor", "en", "of", "maar", "als", "dan", "de",
"moet", "mag", "kan", "kunnen", "zou", "zouden", "het",
"ik", "jij", "u", "we", "wij", "zij", "mijn", "jouw", "wat", "wanneer", "hoe", "is",
"met", "zonder", "dit", "dat", "deze", "die", "niet", "geen", "zijn",
"was",
"waren",
"worden",
"wordt",
"van",
"in",
"op",
"voor",
"en",
"of",
"maar",
"als",
"dan",
"moet",
"mag",
"kan",
"kunnen",
"zou",
"zouden",
"ik",
"jij",
"u",
"we",
"wij",
"zij",
"mijn",
"jouw",
"wat",
"wanneer",
"hoe",
"met",
"zonder",
"dit",
"dat",
"deze",
"die",
"niet",
"geen",
}, },
"fr-BE": { "fr-BE": {
"un", "une", "le", "la", "les", "des", "est", "sont", "était", "être", "un",
"de", "du", "en", "sur", "pour", "et", "ou", "mais", "si", "alors", "une",
"doit", "peut", "peuvent", "pourrait", "devrait", "le",
"je", "tu", "vous", "il", "elle", "nous", "ils", "mon", "votre", "quoi", "quand", "comment", "la",
"avec", "sans", "ce", "cette", "ces", "cela", "pas", "non", "les",
"des",
"est",
"sont",
"était",
"être",
"de",
"du",
"en",
"sur",
"pour",
"et",
"ou",
"mais",
"si",
"alors",
"doit",
"peut",
"peuvent",
"pourrait",
"devrait",
"je",
"tu",
"vous",
"il",
"elle",
"nous",
"ils",
"mon",
"votre",
"quoi",
"quand",
"comment",
"avec",
"sans",
"ce",
"cette",
"ces",
"cela",
"pas",
"non",
}, },
} }
@@ -141,7 +262,7 @@ _LOW_CONFIDENCE_TEXT = {
_LEAD_ANSWER_TEMPLATE = { _LEAD_ANSWER_TEMPLATE = {
"en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}', "en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}',
"nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}', "nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}',
"fr-BE": 'Selon « {title} » (v{version}), section « {heading} » : {excerpt}', "fr-BE": "Selon « {title} » (v{version}), section « {heading} » : {excerpt}",
} }
+3 -1
View File
@@ -10,7 +10,9 @@ SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
# Stable across runs (and across which language ships first) so a document's RAGcore # Stable across runs (and across which language ships first) so a document's RAGcore
# source_id never changes just because the sync ran on a different day or in a # source_id never changes just because the sync ran on a different day or in a
# different order -- required for RAGcore's upload idempotency to work per document. # different order -- required for RAGcore's upload idempotency to work per document.
_SOURCE_ID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures") _SOURCE_ID_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures"
)
def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]: def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
+1 -3
View File
@@ -14,9 +14,7 @@ settings = get_settings()
def is_session_revoked(db: Session, token: str) -> bool: def is_session_revoked(db: Session, token: str) -> bool:
token_hash = session_token_hash(token) token_hash = session_token_hash(token)
revoked_id = db.scalar( revoked_id = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
select(RevokedSession.id).where(RevokedSession.token_hash == token_hash)
)
return revoked_id is not None return revoked_id is not None
+4 -12
View File
@@ -175,9 +175,7 @@ def evaluate_vehicle_status(
if facts.has_active_rental and not blocking_reasons: if facts.has_active_rental and not blocking_reasons:
if current == "rented": if current == "rented":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False) return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result( return result("rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False)
"rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False
)
if facts.has_active_rental and blocking_reasons: if facts.has_active_rental and blocking_reasons:
# Explicitly forbidden shortcut this evaluator must never take: an active # Explicitly forbidden shortcut this evaluator must never take: an active
@@ -190,24 +188,18 @@ def evaluate_vehicle_status(
if facts.service_threshold_reached: if facts.service_threshold_reached:
if current == "maintenance": if current == "maintenance":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False) return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result( return result("maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False)
"maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False
)
if facts.has_booking_conflict: if facts.has_booking_conflict:
if current == "blocked": if current == "blocked":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False) return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result( return result("blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False)
"blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False
)
# No active rental, no maintenance need, no booking conflict. # No active rental, no maintenance need, no booking conflict.
if current in ("available", "cleaning", "blocked"): if current in ("available", "cleaning", "blocked"):
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False) return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
if current == "rented": if current == "rented":
return result( return result("available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False)
"available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False
)
if current == "maintenance": if current == "maintenance":
# No positive fact confirms maintenance is actually finished (no completed # No positive fact confirms maintenance is actually finished (no completed
# service record is tracked here) -- clearing "maintenance" without such a # service record is tracked here) -- clearing "maintenance" without such a
+4 -1
View File
@@ -14,7 +14,10 @@ dependencies = [
"sqlalchemy>=2.0,<3", "sqlalchemy>=2.0,<3",
"psycopg[binary]>=3.2,<4", "psycopg[binary]>=3.2,<4",
"alembic>=1.13,<2", "alembic>=1.13,<2",
"httpx>=0.27,<1" "httpx>=0.27,<1",
"httpx2>=2.10,<3",
"authlib>=1.6,<2",
"itsdangerous>=2.2,<3"
] ]
[project.optional-dependencies] [project.optional-dependencies]
+26
View File
@@ -13,14 +13,23 @@ annotated-types==0.8.0
anyio==4.14.2 anyio==4.14.2
# via # via
# httpx # httpx
# httpx2
# starlette # starlette
# watchfiles # watchfiles
authlib==1.7.2
# via mobilityops-api (pyproject.toml)
certifi==2026.7.22 certifi==2026.7.22
# via # via
# httpcore # httpcore
# httpx # httpx
cffi==2.1.1
# via cryptography
click==8.4.2 click==8.4.2
# via uvicorn # via uvicorn
cryptography==50.0.0
# via
# authlib
# joserfc
fastapi==0.141.1 fastapi==0.141.1
# via mobilityops-api (pyproject.toml) # via mobilityops-api (pyproject.toml)
greenlet==3.5.4 greenlet==3.5.4
@@ -28,19 +37,29 @@ greenlet==3.5.4
h11==0.16.0 h11==0.16.0
# via # via
# httpcore # httpcore
# httpcore2
# uvicorn # uvicorn
httpcore==1.0.9 httpcore==1.0.9
# via httpx # via httpx
httpcore2==2.10.0
# via httpx2
httptools==0.8.0 httptools==0.8.0
# via uvicorn # via uvicorn
httpx==0.28.1 httpx==0.28.1
# via mobilityops-api (pyproject.toml) # via mobilityops-api (pyproject.toml)
httpx2==2.10.0
# via mobilityops-api (pyproject.toml)
idna==3.18 idna==3.18
# via # via
# anyio # anyio
# httpx # httpx
# httpx2
iniconfig==2.3.0 iniconfig==2.3.0
# via pytest # via pytest
itsdangerous==2.2.0
# via mobilityops-api (pyproject.toml)
joserfc==1.7.4
# via authlib
librt==0.13.0 librt==0.13.0
# via mypy # via mypy
mako==1.3.12 mako==1.3.12
@@ -61,6 +80,8 @@ psycopg[binary]==3.3.4
# via mobilityops-api (pyproject.toml) # via mobilityops-api (pyproject.toml)
psycopg-binary==3.3.4 psycopg-binary==3.3.4
# via psycopg # via psycopg
pycparser==3.0
# via cffi
pydantic==2.13.4 pydantic==2.13.4
# via # via
# fastapi # fastapi
@@ -91,11 +112,16 @@ sqlalchemy==2.0.51
# mobilityops-api (pyproject.toml) # mobilityops-api (pyproject.toml)
starlette==1.3.1 starlette==1.3.1
# via fastapi # via fastapi
truststore==0.10.4
# via
# httpcore2
# httpx2
typing-extensions==4.16.0 typing-extensions==4.16.0
# via # via
# alembic # alembic
# anyio # anyio
# fastapi # fastapi
# httpx2
# mypy # mypy
# psycopg # psycopg
# pydantic # pydantic
+1 -3
View File
@@ -66,9 +66,7 @@ def test_return_registered_audit_event_exposes_before_after_and_link(ops_client)
) )
key = "test-audit-before-after-001" key = "test-audit-before-after-001"
events = ops_client.get( events = ops_client.get("/api/v1/audit", params={"action": "return_registered"}).json()
"/api/v1/audit", params={"action": "return_registered"}
).json()
event = next(e for e in events if e["metadata"]["idempotency_key"] == key) event = next(e for e in events if e["metadata"]["idempotency_key"] == key)
assert event["before"] == {"status": "active"} assert event["before"] == {"status": "active"}
assert event["after"]["status"] == "returned" assert event["after"]["status"] == "returned"
+43 -3
View File
@@ -4,6 +4,48 @@ def test_unauthenticated_dashboard_is_rejected(client):
assert response.json()["error"]["code"] == "401" assert response.json()["error"]["code"] == "401"
def test_oidc_status_is_disabled_without_configuration(client):
assert client.get("/api/v1/auth/oidc/status").json() == {
"enabled": False,
"provider_name": None,
}
assert client.get("/api/v1/auth/oidc/login").status_code == 404
def test_oidc_callback_auto_provisions_and_logs_in(client, monkeypatch):
import app.api.routers.auth as auth_router
class FakeClient:
async def authorize_access_token(self, _request):
return {
"userinfo": {
"sub": "external-subject-1",
"email": "oidc.user@example.test",
"email_verified": True,
"name": "OIDC User",
}
}
class FakeOAuth:
def create_client(self, _name):
return FakeClient()
monkeypatch.setattr(auth_router.settings, "oidc_enabled", True)
monkeypatch.setattr(auth_router.settings, "oidc_issuer_url", "https://id.example.test")
monkeypatch.setattr(auth_router.settings, "oidc_client_id", "client")
monkeypatch.setattr(auth_router.settings, "oidc_client_secret", "secret")
monkeypatch.setattr(auth_router.settings, "oidc_allowed_email_domains", "example.test")
monkeypatch.setattr(auth_router, "oauth", FakeOAuth())
response = client.get("/api/v1/auth/oidc/callback", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"].endswith("/dashboard")
session = client.get("/api/v1/auth/session")
assert session.status_code == 200
assert session.json()["display_name"] == "OIDC User"
assert session.json()["role"] == "rental_employee"
def test_demo_login_grants_access(ops_client): def test_demo_login_grants_access(ops_client):
response = ops_client.get("/api/v1/dashboard") response = ops_client.get("/api/v1/dashboard")
assert response.status_code == 200 assert response.status_code == 200
@@ -83,9 +125,7 @@ def test_logout_rejects_a_cookie_even_if_the_browser_retains_it(ops_client):
ops_client.cookies.clear() ops_client.cookies.clear()
# A fresh login in the same second receives a distinct signed token and remains valid. # A fresh login in the same second receives a distinct signed token and remains valid.
fresh_login = ops_client.post( fresh_login = ops_client.post("/api/v1/demo/login", json={"role": "operations_manager"})
"/api/v1/demo/login", json={"role": "operations_manager"}
)
assert fresh_login.status_code == 200 assert fresh_login.status_code == 200
assert ops_client.get("/api/v1/auth/session").status_code == 200 assert ops_client.get("/api/v1/auth/session").status_code == 200
+2 -6
View File
@@ -65,9 +65,7 @@ def test_attention_vehicles_respects_limit(client):
def test_vehicle_details_known_ref(client): def test_vehicle_details_known_ref(client):
response = client.get( response = client.get("/api/v1/integrations/mcp/vehicles/MO-016", headers=_headers())
"/api/v1/integrations/mcp/vehicles/MO-016", headers=_headers()
)
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
assert body["public_ref"] == "MO-016" assert body["public_ref"] == "MO-016"
@@ -75,9 +73,7 @@ def test_vehicle_details_known_ref(client):
def test_vehicle_details_unknown_ref_is_404(client): def test_vehicle_details_unknown_ref_is_404(client):
response = client.get( response = client.get("/api/v1/integrations/mcp/vehicles/MO-999", headers=_headers())
"/api/v1/integrations/mcp/vehicles/MO-999", headers=_headers()
)
assert response.status_code == 404 assert response.status_code == 404
+1 -3
View File
@@ -56,9 +56,7 @@ def test_seed_demo_scenarios_present():
assert duplicate_issue is not None assert duplicate_issue is not None
assert duplicate_issue.rule_type == "possible_duplicate_customer" assert duplicate_issue.rule_type == "possible_duplicate_customer"
failed_run = db.scalar( failed_run = db.scalar(select(OutboxEvent).where(OutboxEvent.delivery_status == "failed"))
select(OutboxEvent).where(OutboxEvent.delivery_status == "failed")
)
assert failed_run is not None assert failed_run is not None
finally: finally:
db.close() db.close()
+2 -6
View File
@@ -36,9 +36,7 @@ def _facts(**overrides) -> VehicleStatusFacts:
def test_available_with_active_rental_recommends_rented(): def test_available_with_active_rental_recommends_rented():
result = evaluate_vehicle_status( result = evaluate_vehicle_status(_vehicle("available"), _facts(active_booking_refs=["BK-0001"]))
_vehicle("available"), _facts(active_booking_refs=["BK-0001"])
)
assert result.recommended_status == "rented" assert result.recommended_status == "rented"
assert result.recommendation_code == RECOMMENDATION_CODE_ACTIVE_RENTAL assert result.recommendation_code == RECOMMENDATION_CODE_ACTIVE_RENTAL
assert result.safe_to_apply is True assert result.safe_to_apply is True
@@ -83,9 +81,7 @@ def test_rented_with_no_active_booking_recommends_available():
def test_service_threshold_reached_recommends_maintenance(): def test_service_threshold_reached_recommends_maintenance():
result = evaluate_vehicle_status( result = evaluate_vehicle_status(_vehicle("available"), _facts(service_threshold_reached=True))
_vehicle("available"), _facts(service_threshold_reached=True)
)
assert result.recommended_status == "maintenance" assert result.recommended_status == "maintenance"
assert result.recommendation_code == RECOMMENDATION_CODE_SERVICE_THRESHOLD assert result.recommendation_code == RECOMMENDATION_CODE_SERVICE_THRESHOLD
+1 -3
View File
@@ -52,9 +52,7 @@ def test_retry_failed_run_moves_to_pending_and_audits(ops_client):
def test_retry_requires_operations_manager(employee_client): def test_retry_requires_operations_manager(employee_client):
response = employee_client.post( response = employee_client.post("/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry")
"/api/v1/workflows/00000000-0000-4000-8000-000000000020/retry"
)
assert response.status_code == 403 assert response.status_code == 403
+11
View File
@@ -41,6 +41,17 @@ services:
DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility} DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels} DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels}
DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true} DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true}
MOBILITYOPS_PUBLIC_URL: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
OIDC_ENABLED: ${OIDC_ENABLED:-false}
OIDC_PROVIDER_NAME: ${OIDC_PROVIDER_NAME:-Organisatieaccount}
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-}
OIDC_ALLOWED_EMAIL_DOMAINS: ${OIDC_ALLOWED_EMAIL_DOMAINS:-}
OIDC_AUTO_PROVISION: ${OIDC_AUTO_PROVISION:-true}
OIDC_DEFAULT_ROLE: ${OIDC_DEFAULT_ROLE:-rental_employee}
ports: ports:
- "8128:8000" - "8128:8000"
depends_on: depends_on:
+16
View File
@@ -50,6 +50,22 @@ uses HTTPS, and keep `INITIAL_ADMIN_PASSWORD` out of Git and logs. Existing sess
revalidated against the current user record on every request, so deactivating an account revalidated against the current user record on every request, so deactivating an account
invalidates its next request. invalidates its next request.
### Optional organisation login (OIDC)
OpenID Connect can coexist with the public demo. Set `OIDC_ENABLED=true`, issuer URL,
client ID and client secret; register
`<MOBILITYOPS_PUBLIC_URL>/api/v1/auth/oidc/callback` at the identity provider. The login
screen then adds an organisation-login action without removing either public demo role.
State and nonce validation use a short-lived signed HttpOnly cookie. Identity binding is
unique on issuer plus `sub`; only a verified email is accepted. Optionally restrict
domains with `OIDC_ALLOWED_EMAIL_DOMAINS`. New identities receive the least-privileged
`rental_employee` role by default and can subsequently be promoted by an Operations
Manager. Set `OIDC_AUTO_PROVISION=false` when every account must be pre-created.
OIDC must use HTTPS outside a trusted local network. Set `SESSION_COOKIE_SECURE=true` and
keep `OIDC_CLIENT_SECRET` in the deployment secret store. Disabling OIDC immediately
removes the organisation-login action but does not affect public demo access.
## n8n automation (one-time per environment) ## n8n automation (one-time per environment)
The optional bundled fallback image (`n8nio/n8n:2.33.7`) requires an owner account before any The optional bundled fallback image (`n8nio/n8n:2.33.7`) requires an owner account before any
+2
View File
@@ -11,6 +11,8 @@ export interface SystemStatus {
environment: string; environment: string;
demo_mode: boolean; demo_mode: boolean;
knowledge_provider: string; knowledge_provider: string;
oidc_enabled: boolean;
oidc_provider_name: string | null;
} }
export interface Vehicle { export interface Vehicle {
+10 -2
View File
@@ -6,6 +6,8 @@ interface AuthState {
user: CurrentUser | null; user: CurrentUser | null;
loading: boolean; loading: boolean;
demoMode: boolean; demoMode: boolean;
oidcEnabled: boolean;
oidcProviderName: string | null;
loginAs: (role: Role) => Promise<void>; loginAs: (role: Role) => Promise<void>;
loginWithPassword: (email: string, password: string) => Promise<void>; loginWithPassword: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
@@ -35,11 +37,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<CurrentUser | null>(() => readCachedUser()); const [user, setUser] = useState<CurrentUser | null>(() => readCachedUser());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [demoMode, setDemoMode] = useState(true); const [demoMode, setDemoMode] = useState(true);
const [oidcEnabled, setOidcEnabled] = useState(false);
const [oidcProviderName, setOidcProviderName] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
api.get<SystemStatus>("/api/v1/system/status") api.get<SystemStatus>("/api/v1/system/status")
.then((system) => setDemoMode(system.demo_mode)) .then((system) => {
setDemoMode(system.demo_mode);
setOidcEnabled(system.oidc_enabled);
setOidcProviderName(system.oidc_provider_name);
})
.catch(() => setDemoMode(true)) .catch(() => setDemoMode(true))
.finally(() => api .finally(() => api
.get<CurrentUser>("/api/v1/auth/session") .get<CurrentUser>("/api/v1/auth/session")
@@ -119,7 +127,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []); }, []);
return ( return (
<AuthContext.Provider value={{ user, loading, demoMode, loginAs, loginWithPassword, logout }}>{children}</AuthContext.Provider> <AuthContext.Provider value={{ user, loading, demoMode, oidcEnabled, oidcProviderName, loginAs, loginWithPassword, logout }}>{children}</AuthContext.Provider>
); );
} }
+3 -1
View File
@@ -23,5 +23,7 @@
"emailLabel": "Email address", "emailLabel": "Email address",
"passwordLabel": "Password", "passwordLabel": "Password",
"signIn": "Sign in", "signIn": "Sign in",
"passwordLoginFailed": "Sign-in failed. Check your credentials." "passwordLoginFailed": "Sign-in failed. Check your credentials.",
"or": "or",
"signInWithOrganization": "Sign in with {{provider}}"
} }
+3 -1
View File
@@ -23,5 +23,7 @@
"emailLabel": "Adresse e-mail", "emailLabel": "Adresse e-mail",
"passwordLabel": "Mot de passe", "passwordLabel": "Mot de passe",
"signIn": "Se connecter", "signIn": "Se connecter",
"passwordLoginFailed": "Connexion impossible. Vérifiez vos identifiants." "passwordLoginFailed": "Connexion impossible. Vérifiez vos identifiants.",
"or": "ou",
"signInWithOrganization": "Se connecter avec {{provider}}"
} }
+3 -1
View File
@@ -23,5 +23,7 @@
"emailLabel": "E-mailadres", "emailLabel": "E-mailadres",
"passwordLabel": "Wachtwoord", "passwordLabel": "Wachtwoord",
"signIn": "Aanmelden", "signIn": "Aanmelden",
"passwordLoginFailed": "Aanmelden mislukt. Controleer je gegevens." "passwordLoginFailed": "Aanmelden mislukt. Controleer je gegevens.",
"or": "of",
"signInWithOrganization": "Aanmelden met {{provider}}"
} }
+8 -1
View File
@@ -10,7 +10,7 @@ import { PRODUCT_NAME } from "../product";
export function Login() { export function Login() {
const { t } = useTranslation(["auth", "common"]); const { t } = useTranslation(["auth", "common"]);
const { loginAs, loginWithPassword, loading, demoMode } = useAuth(); const { loginAs, loginWithPassword, loading, demoMode, oidcEnabled, oidcProviderName } = useAuth();
const { manifest } = useDemoManifest(); const { manifest } = useDemoManifest();
const navigate = useNavigate(); const navigate = useNavigate();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -100,6 +100,13 @@ export function Login() {
<label>{t("passwordLabel")}<input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} minLength={8} required /></label> <label>{t("passwordLabel")}<input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} minLength={8} required /></label>
<button type="submit" className="button button-primary" disabled={loading}>{t("signIn")}</button> <button type="submit" className="button button-primary" disabled={loading}>{t("signIn")}</button>
</form>} </form>}
{oidcEnabled && <>
<div className="login-divider"><span>{t("or")}</span></div>
<a className="button login-oidc" href="/api/v1/auth/oidc/login">
<Icon name="shield" />
{t("signInWithOrganization", { provider: oidcProviderName })}
</a>
</>}
<div className="access-note"><Icon name="shield" /><p><strong>{t("safeByDesignTitle")}</strong><span>{t("safeByDesignDetail")}</span></p></div> <div className="access-note"><Icon name="shield" /><p><strong>{t("safeByDesignTitle")}</strong><span>{t("safeByDesignDetail")}</span></p></div>
</div> </div>
</section> </section>
+4
View File
@@ -113,6 +113,10 @@ a:hover { color: var(--teal); }
.sidebar-language { display: none; padding: 0 20px 14px; } .sidebar-language { display: none; padding: 0 20px 14px; }
.login-panel-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .login-panel-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.login-panel-top .page-eyebrow { margin: 0; } .login-panel-top .page-eyebrow { margin: 0; }
.login-divider { display: flex; align-items: center; gap: 12px; margin: 18px 0; color: var(--muted); font-size: .68rem; text-transform: uppercase; letter-spacing: .08em; }
.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; }
.demo-badge { position: relative; } .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 { 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; } .demo-badge-trigger:hover { background: #dfe8ef; }