M21: add optional organisation identity
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user