Scaffolder admin-ui/ (Vite + React + TypeScript, almindelig CSS) med en minimal login-side (POST /auth/login + GET /auth/me) der beviser hele kæden virker. Vites build skriver direkte til app/admin_dist/. Admin-UI'et serveres af samme FastAPI-app som API'et og den offentlige side, men på sit eget dedikerede domæne (ADMIN_DOMAIN, default admin.localhost) via en ny AdminDomainDispatch-middleware (app/admin_site.py) der genbruger Host-header-teknikken fra 8b: den offentlige side ejer allerede roden "/" domæneopløst, så admin-UI'et kan ikke dele det domæne uden at kollidere. Samme trick undgår CORS helt, da admin-UI'ets fetch-kald går til samme origin. API-kald (/auth, /participants, ...) passerer uændret gennem til det almindelige API uanset domæne; alt andet på admin-domænet serveres fra den byggede SPA med index.html-fallback for client-side routing. Et sikkerhedsnet deaktiverer dispatch'en stille hvis admin_dist/ mangler (frisk clone uden frontend-build), i stedet for at crashe hele appen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
65 lines
2.2 KiB
Python
65 lines
2.2 KiB
Python
from pathlib import Path
|
|
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
from starlette.responses import Response
|
|
from starlette.staticfiles import StaticFiles
|
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
|
|
from app.core.config import get_settings
|
|
|
|
ADMIN_DIST_DIR = Path(__file__).parent / "admin_dist"
|
|
|
|
# Præfikser der altid rammer det almindelige API, uanset domæne — skal
|
|
# holdes i sync med routerne registreret i app/main.py
|
|
_API_PREFIXES = (
|
|
"/auth",
|
|
"/participants",
|
|
"/purchase-rounds",
|
|
"/wine-offerings",
|
|
"/wine-categories",
|
|
"/mail-templates",
|
|
"/mail-logs",
|
|
"/orders",
|
|
"/webhooks",
|
|
"/public",
|
|
"/health",
|
|
"/docs",
|
|
"/redoc",
|
|
"/openapi.json",
|
|
)
|
|
|
|
|
|
class SPAStaticFiles(StaticFiles):
|
|
"""Standard SPA-fallback: ukendte stier (client-side routing)
|
|
falder tilbage til index.html i stedet for 404."""
|
|
|
|
async def get_response(self, path: str, scope: Scope) -> Response:
|
|
try:
|
|
return await super().get_response(path, scope)
|
|
except StarletteHTTPException as exc:
|
|
if exc.status_code == 404:
|
|
return await super().get_response("index.html", scope)
|
|
raise
|
|
|
|
|
|
class AdminDomainDispatch:
|
|
"""Sender requests mod admin-domænet til den byggede React-SPA i
|
|
stedet for den domæne-opløste offentlige side (public_site.py) —
|
|
men lader API-kald (/auth, /participants, ...) passere uændret,
|
|
så admin-UI'et kan kalde API'et uden CORS (samme origin)."""
|
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
self.app = app
|
|
self.admin_domain = get_settings().admin_domain
|
|
self.spa_app = SPAStaticFiles(directory=str(ADMIN_DIST_DIR), html=True) if ADMIN_DIST_DIR.is_dir() else None
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
if scope["type"] != "http" or self.spa_app is None:
|
|
await self.app(scope, receive, send)
|
|
return
|
|
headers = dict(scope["headers"])
|
|
host = headers.get(b"host", b"").decode().split(":")[0]
|
|
if host == self.admin_domain and not scope["path"].startswith(_API_PREFIXES):
|
|
await self.spa_app(scope, receive, send)
|
|
return
|
|
await self.app(scope, receive, send)
|