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:
parent
b6b14aefac
commit
60fb7db119
21 changed files with 1780 additions and 3 deletions
|
|
@ -8,3 +8,7 @@ POSTAL_API_KEY=change-me
|
|||
# Find med: dig +short TXT <dkim-selector>._domainkey.<dit-domæne>
|
||||
# og brug værdien af "p=" (base64 DER, ingen PEM-indpakning nødvendig)
|
||||
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
4
.gitignore
vendored
|
|
@ -26,3 +26,7 @@ dist/
|
|||
# Raw personal-data exports used for one-off migrations — not for version control
|
||||
old_participants.json
|
||||
old-emails/
|
||||
|
||||
# React admin-UI (admin-ui/) build artefact + dependencies — genskabes af npm run build
|
||||
app/admin_dist/
|
||||
admin-ui/node_modules/
|
||||
|
|
|
|||
63
CLAUDE.md
63
CLAUDE.md
|
|
@ -18,7 +18,8 @@ vinbonden Horcher-familiens egne ruter i Frankrig/Belgien), selvom kun
|
|||
|
||||
## Tech stack
|
||||
- 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
|
||||
FastAPI selv (Jinja2-templates i `app/templates/` + vanilla JS/CSS i
|
||||
`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
|
||||
`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
|
||||
1. Runde åbnes → mail til alle aktive deltagere på ruten. **Implementeret**
|
||||
(opgave 7a-c): skabelon pr. rute (`MailTemplate`,
|
||||
|
|
@ -229,7 +282,7 @@ logges/advares om i stedet.
|
|||
|
||||
## Fase 1 — nuværende scope
|
||||
|
||||
**Færdige opgaver (1-10):**
|
||||
**Færdige opgaver (1-10, 11a):**
|
||||
1. ✅ FastAPI + SQLModel + PostgreSQL + Alembic scaffolding
|
||||
2. ✅ Datamodellerne (Organization/Route-hierarki)
|
||||
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.
|
||||
10. ✅ Fortløbende ordrenummer pr. runde + afhentningsliste — se Order
|
||||
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:**
|
||||
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9
|
||||
12. Testkør en rigtig runde gennem hele flowet
|
||||
|
||||
## Fase 2 — senere
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -13,3 +13,19 @@ uv run fastapi dev app/main.py
|
|||
```
|
||||
|
||||
`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
24
admin-ui/.gitignore
vendored
Normal 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
8
admin-ui/.oxlintrc.json
Normal 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
32
admin-ui/README.md
Normal 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
12
admin-ui/index.html
Normal 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
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
25
admin-ui/package.json
Normal 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
40
admin-ui/src/App.css
Normal 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
94
admin-ui/src/App.tsx
Normal 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
5
admin-ui/src/index.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
body {
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
}
|
||||
10
admin-ui/src/main.tsx
Normal file
10
admin-ui/src/main.tsx
Normal 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>,
|
||||
)
|
||||
26
admin-ui/tsconfig.app.json
Normal file
26
admin-ui/tsconfig.app.json
Normal 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
7
admin-ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
admin-ui/tsconfig.node.json
Normal file
23
admin-ui/tsconfig.node.json
Normal 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
27
admin-ui/vite.config.ts
Normal 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
65
app/admin_site.py
Normal 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)
|
||||
|
|
@ -12,6 +12,7 @@ class Settings(BaseSettings):
|
|||
postal_base_url: str
|
||||
postal_api_key: str
|
||||
postal_webhook_public_key_b64: str
|
||||
admin_domain: str = "admin.localhost"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from fastapi import FastAPI
|
|||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.admin_site import AdminDomainDispatch
|
||||
from app.db import SessionDep
|
||||
from app.routers import (
|
||||
auth,
|
||||
|
|
@ -30,6 +31,7 @@ app.include_router(orders.router)
|
|||
app.include_router(webhooks.router)
|
||||
app.include_router(public_orders.router)
|
||||
app.include_router(public_site.router)
|
||||
app.add_middleware(AdminDomainDispatch)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue