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>
|
# 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
4
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
63
CLAUDE.md
63
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
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.
|
`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_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",
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue