vinindkoeb/app/admin_site.py
carsten 62d464fda2 Opgave 11b (del 1): frontend-infrastruktur + Deltagere-CRUD
Deler 11b op i tre dele under afklaring (runder/vinliste og
mail-skabeloner/annoncering er egne skærme). Denne del bygger den
delte infrastruktur alle senere admin-skærme skal bruge:

- api.ts: autentificeret fetch-wrapper + elevate()/decodeJwtExpiry().
- AuthContext.tsx: session overlever nu en sideopdatering (GET
  /auth/me ved appstart). Holder en eksplicit "eleveret
  session"-tilstand — react-router-dom + Layout (navbar) +
  ParticipantsPage (liste/opret/redigér/hård-sletning).
- Ny GET /routes (minimal, kun læsning, org-scoped) — UI'et skal
  kende rutens id for at oprette deltagere; ikke en rute-
  indstillings-skærm (den er bevidst udskudt).

To fund fra brugertest, begge rettet:
1. "Slet permanent" krævede kun et klik + en browser-confirm(), og
   elevered sig selv usynligt i baggrunden ved hvert klik — det
   underminerede hele pointen med en bevidst elevations-handling.
   Elevation er nu en synlig, separat handling ("Forhøj
   rettigheder"-knap i navbaren); destruktive knapper vises kun mens
   en eleveret session er aktiv (udløber automatisk efter ~5 min).
2. GET /participants uden et eksplicit limit ramte API'ets default på
   100 — med 308 rigtige deltagere (sorteret efter stigende id)
   kunne hverken alle deltagere eller nyoprettede deltagere ses.
   ParticipantsPage sender nu limit=500 (API'ets maksimum).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 01:24:40 +02:00

66 lines
2.3 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",
"/routes",
"/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)