vinindkoeb/app/admin_site.py
carsten 60fb7db119 Opgave 11a: React admin-scaffold + domænebaseret hosting
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>
2026-09-29 00:41:03 +02:00

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)