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>
This commit is contained in:
Carsten Gram 2026-09-29 00:41:03 +02:00
parent b6b14aefac
commit 60fb7db119
21 changed files with 1780 additions and 3 deletions

View file

@ -8,3 +8,7 @@ POSTAL_API_KEY=change-me
# Find med: dig +short TXT <dkim-selector>._domainkey.<dit-domæne> # Find med: dig +short TXT <dkim-selector>._domainkey.<dit-domæne>
# og brug værdien af "p=" (base64 DER, ingen PEM-indpakning nødvendig) # og brug værdien af "p=" (base64 DER, ingen PEM-indpakning nødvendig)
POSTAL_WEBHOOK_PUBLIC_KEY_B64=change-me POSTAL_WEBHOOK_PUBLIC_KEY_B64=change-me
# Domænet React admin-UI'et (admin-ui/) serveres på — reverse proxy'en
# skal sende dette domæne til samme backend, ligesom organisationernes
# public_domain gør
ADMIN_DOMAIN=admin.vinindkoeb.dk

4
.gitignore vendored
View file

@ -26,3 +26,7 @@ dist/
# Raw personal-data exports used for one-off migrations — not for version control # Raw personal-data exports used for one-off migrations — not for version control
old_participants.json old_participants.json
old-emails/ old-emails/
# React admin-UI (admin-ui/) build artefact + dependencies — genskabes af npm run build
app/admin_dist/
admin-ui/node_modules/

View file

@ -18,7 +18,8 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
## Tech stack ## Tech stack
- Backend: FastAPI + SQLModel + PostgreSQL + Alembic (migrations) - Backend: FastAPI + SQLModel + PostgreSQL + Alembic (migrations)
- Frontend: React (admin-UI, ikke bygget endnu) + den offentlige - Frontend: React + TypeScript admin-UI (`admin-ui/`, opgave 11a —
scaffold, se "Admin-UI-arkitektur" nedenfor) + den offentlige
bestillings-/tilmeldingsside (opgave 8c) — server-renderet af bestillings-/tilmeldingsside (opgave 8c) — server-renderet af
FastAPI selv (Jinja2-templates i `app/templates/` + vanilla JS/CSS i FastAPI selv (Jinja2-templates i `app/templates/` + vanilla JS/CSS i
`app/static/`, intet build-step). Fortsat intet login for deltagere. `app/static/`, intet build-step). Fortsat intet login for deltagere.
@ -197,6 +198,58 @@ admin printer siden fra browseren. Renderes via
offentlige side, egen `Jinja2Templates`-instans i offentlige side, egen `Jinja2Templates`-instans i
`purchase_rounds.py`). `purchase_rounds.py`).
## Admin-UI-arkitektur (opgave 11)
Opgave 11 (React admin-UI) er delt op ligesom opgave 8: **11a**
(scaffold + hosting-mekanisme, ✅), **11b** (deltagere,
runder/vinliste, annoncering) og **11c** (ordreoversigt/markér betalt)
er selve admin-skærmene, ikke bygget endnu.
**Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript,
almindelig CSS — ingen framework) serveres af **samme FastAPI-app**
som API'et og den offentlige side, men på sit **eget dedikerede
domæne** (`ADMIN_DOMAIN`-settingen, default `admin.localhost`,
sættes til fx `admin.vinindkoeb.dk` i produktion — reverse proxy'en
skal have en tilsvarende indgang, ligesom organisationernes
`public_domain`'er). Begrundelse: den offentlige side
(`public_site.py`) ejer allerede roden `/` domæneopløst via
`Host`-headeren (8b) — admin-UI'et kan derfor ikke også ligge på `/`
på et organisations-domæne uden at kollidere. Ved at give admin-UI'et
sit eget domæne, håndteret af samme `Host`-header-teknik, opnås desuden
**ingen CORS-behov**: admin-UI'ets `fetch`-kald til `/auth`,
`/participants` osv. går til samme origin siden selv blev hentet fra.
**Mekanismen** (`app/admin_site.py`): `AdminDomainDispatch`, en rå
ASGI-middleware registreret via `app.add_middleware(...)` i
`app/main.py`. Pr. request: hvis `Host` matcher `ADMIN_DOMAIN` **og**
stien ikke er et af de faste API-præfikser (`/auth`, `/participants`,
osv. — se `_API_PREFIXES`, skal holdes i sync med routerne i
`app/main.py`), serveres i stedet fra `app/admin_dist/` (Vites
build-output) via `SPAStaticFiles` (falder tilbage til `index.html`
for ukendte stier, så React Router's client-side routing virker).
Ellers går requesten uændret videre til den almindelige app (API'et
er allerede host-agnostisk — virker på ethvert domæne — og den
offentlige side fortsætter uændret på organisations-domænerne).
Et sikkerhedsnet: hvis `app/admin_dist/` ikke findes (frisk clone,
frontend'en er ikke bygget endnu), deaktiveres dispatch'en stille i
stedet for at crashe hele appen ved opstart.
**Build:** `admin-ui/vite.config.ts` skriver direkte til
`../app/admin_dist` (`npm run build` i `admin-ui/`, intet manuelt
kopi-trin). Både `admin-ui/node_modules/` og `app/admin_dist/` er
gitignored — en frisk clone skal køre `npm install && npm run build`
i `admin-ui/` før admin-domænet virker (API'et og den offentlige side
fungerer også uden). Lokal udvikling: `npm run dev` i `admin-ui/`
bruger Vites egen dev-server med `server.proxy` videresendt til
`http://127.0.0.1:8001`, uafhængigt af dispatch-mekanismen.
**11a's indhold er bevidst minimalt** (kun nok til at bevise hele
kæden virker — selve admin-skærmene er 11b/11c): en login-side
(`admin-ui/src/App.tsx`, email+password → `POST /auth/login`
form-encoded per `OAuth2PasswordRequestForm`, JWT gemmes i
`localStorage`), som ved succes kalder `GET /auth/me` og viser den
indloggede brugers navn/email.
## Mail-events ## Mail-events
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret** 1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
(opgave 7a-c): skabelon pr. rute (`MailTemplate`, (opgave 7a-c): skabelon pr. rute (`MailTemplate`,
@ -229,7 +282,7 @@ logges/advares om i stedet.
## Fase 1 — nuværende scope ## Fase 1 — nuværende scope
**Færdige opgaver (1-10):** **Færdige opgaver (1-10, 11a):**
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding 1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
2. ✅ Datamodellerne (Organization/Route-hierarki) 2. ✅ Datamodellerne (Organization/Route-hierarki)
3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret, 3. ✅ Deltager-migrering fra MongoDB (308 deltagere importeret,
@ -277,9 +330,13 @@ logges/advares om i stedet.
irreversibel) der afviser med `409` hvis ordren allerede er betalt. irreversibel) der afviser med `409` hvis ordren allerede er betalt.
10. ✅ Fortløbende ordrenummer pr. runde + afhentningsliste — se Order 10. ✅ Fortløbende ordrenummer pr. runde + afhentningsliste — se Order
og "Afhentningsliste" ovenfor. og "Afhentningsliste" ovenfor.
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9 — delt i tre:
- **11a**: ✅ Scaffold + domæne-baseret hosting-mekanisme — se
"Admin-UI-arkitektur" ovenfor.
- **11b**: Deltagere, runder/vinliste, annoncering — ikke bygget.
- **11c**: Ordreoversigt/markér betalt — ikke bygget.
**Resterende opgaver:** **Resterende opgaver:**
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9
12. Testkør en rigtig runde gennem hele flowet 12. Testkør en rigtig runde gennem hele flowet
## Fase 2 — senere ## Fase 2 — senere

View file

@ -13,3 +13,19 @@ uv run fastapi dev app/main.py
``` ```
`GET /health` bekræfter at appen kan nå databasen. `GET /health` bekræfter at appen kan nå databasen.
## Admin-UI (React)
Serveres af samme app på et dedikeret domæne (`ADMIN_DOMAIN`, default
`admin.localhost`) — se CLAUDE.md's "Admin-UI-arkitektur". Skal bygges
før admin-domænet virker (API'et og den offentlige side kører fint
uden):
```
cd admin-ui
npm install
npm run build # skriver til ../app/admin_dist
```
Til udvikling af selve admin-UI'et: `npm run dev` i `admin-ui/`
(Vites egen dev-server, proxier API-kald til `127.0.0.1:8001`).

24
admin-ui/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
admin-ui/.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

32
admin-ui/README.md Normal file
View file

@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

12
admin-ui/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fælles Vinindkøb — Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1295
admin-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
admin-ui/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "admin-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}

40
admin-ui/src/App.css Normal file
View file

@ -0,0 +1,40 @@
.login-card {
max-width: 360px;
margin: 10vh auto;
padding: 24px;
border: 1px solid #ddd;
border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.login-card h1 {
font-size: 1.2rem;
margin: 0 0 16px;
}
.login-card form label {
display: block;
font-weight: 600;
margin: 12px 0 4px;
}
.login-card form input {
display: block;
width: 100%;
padding: 8px;
font-size: 1rem;
box-sizing: border-box;
}
.login-card button {
width: 100%;
margin-top: 16px;
padding: 10px;
font-size: 1rem;
cursor: pointer;
}
.login-card .error {
color: #b3261e;
margin-top: 12px;
}

94
admin-ui/src/App.tsx Normal file
View file

@ -0,0 +1,94 @@
import { useState, type FormEvent } from 'react'
import './App.css'
interface CurrentUser {
id: number
email: string
name: string
is_active: boolean
is_superadmin: boolean
organization_id: number
}
const TOKEN_STORAGE_KEY = 'vinindkoeb_admin_token'
async function login(email: string, password: string): Promise<string> {
const response = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ username: email, password }),
})
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(body?.detail || 'Login mislykkedes')
}
const data = await response.json()
return data.access_token as string
}
async function fetchCurrentUser(token: string): Promise<CurrentUser> {
const response = await fetch('/auth/me', {
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) {
throw new Error('Kunne ikke hente brugerinfo')
}
return response.json()
}
function App() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null)
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setError(null)
try {
const token = await login(email, password)
localStorage.setItem(TOKEN_STORAGE_KEY, token)
const user = await fetchCurrentUser(token)
setCurrentUser(user)
} catch (err) {
setError(err instanceof Error ? err.message : 'Der skete en fejl')
}
}
function handleLogout() {
localStorage.removeItem(TOKEN_STORAGE_KEY)
setCurrentUser(null)
}
if (currentUser) {
return (
<div className="login-card">
<h1>Fælles Vinindkøb — Admin</h1>
<p>
Logget ind som <strong>{currentUser.name}</strong> ({currentUser.email})
</p>
<button onClick={handleLogout}>Log ud</button>
</div>
)
}
return (
<div className="login-card">
<h1>Fælles Vinindkøb — Admin</h1>
<form onSubmit={handleSubmit}>
<label>
Email
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>
Adgangskode
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
<button type="submit">Log ind</button>
{error && <p className="error">{error}</p>}
</form>
</div>
)
}
export default App

5
admin-ui/src/index.css Normal file
View file

@ -0,0 +1,5 @@
body {
margin: 0;
background: #fff;
color: #222;
}

10
admin-ui/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
admin-ui/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

27
admin-ui/vite.config.ts Normal file
View file

@ -0,0 +1,27 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
const API_PREFIXES = [
'/auth',
'/participants',
'/purchase-rounds',
'/wine-offerings',
'/wine-categories',
'/mail-templates',
'/mail-logs',
'/orders',
]
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: '../app/admin_dist',
emptyOutDir: true,
},
server: {
proxy: Object.fromEntries(
API_PREFIXES.map((prefix) => [prefix, { target: 'http://127.0.0.1:8001', changeOrigin: true }]),
),
},
})

65
app/admin_site.py Normal file
View file

@ -0,0 +1,65 @@
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)

View file

@ -12,6 +12,7 @@ class Settings(BaseSettings):
postal_base_url: str postal_base_url: str
postal_api_key: str postal_api_key: str
postal_webhook_public_key_b64: str postal_webhook_public_key_b64: str
admin_domain: str = "admin.localhost"
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",

View file

@ -2,6 +2,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import text from sqlalchemy import text
from app.admin_site import AdminDomainDispatch
from app.db import SessionDep from app.db import SessionDep
from app.routers import ( from app.routers import (
auth, auth,
@ -30,6 +31,7 @@ app.include_router(orders.router)
app.include_router(webhooks.router) app.include_router(webhooks.router)
app.include_router(public_orders.router) app.include_router(public_orders.router)
app.include_router(public_site.router) app.include_router(public_site.router)
app.add_middleware(AdminDomainDispatch)
@app.get("/health") @app.get("/health")