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>
This commit is contained in:
Carsten Gram 2026-09-29 01:24:40 +02:00
parent fb4a3fb732
commit 62d464fda2
15 changed files with 744 additions and 95 deletions

View file

@ -201,9 +201,14 @@ offentlige side, egen `Jinja2Templates`-instans i
## Admin-UI-arkitektur (opgave 11) ## Admin-UI-arkitektur (opgave 11)
Opgave 11 (React admin-UI) er delt op ligesom opgave 8: **11a** Opgave 11 (React admin-UI) er delt op ligesom opgave 8: **11a**
(scaffold + hosting-mekanisme, ✅), **11b** (deltagere, (scaffold + hosting-mekanisme, ✅). **11b** viste sig under afklaring
runder/vinliste, annoncering) og **11c** (ordreoversigt/markér betalt) at være for stort til én omgang og er selv delt i tre dele, alle
er selve admin-skærmene, ikke bygget endnu. stadig under "11b" (ingen ny bogstav-opdeling): **del 1** (delt
frontend-infrastruktur + Deltagere-CRUD, ✅, se nedenfor), **del 2**
(runder + vinliste, ikke bygget) og **del 3** (mail-skabelon-editor +
annoncér-knap, ikke bygget — "annoncér" er reelt ubrugelig uden en
måde at oprette en `round_announced`-skabelon på). **11c**
(ordreoversigt/markér betalt) er heller ikke bygget endnu.
**Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript, **Hosting-beslutning:** Admin-UI'et (`admin-ui/`, React + TypeScript,
almindelig CSS — ingen framework) serveres af **samme FastAPI-app** almindelig CSS — ingen framework) serveres af **samme FastAPI-app**
@ -244,12 +249,46 @@ bruger Vites egen dev-server med `server.proxy` videresendt til
`http://127.0.0.1:8001`, uafhængigt af dispatch-mekanismen. `http://127.0.0.1:8001`, uafhængigt af dispatch-mekanismen.
**11a's indhold er bevidst minimalt** (kun nok til at bevise hele **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 kæden virker): en login-side, email+password → `POST /auth/login`
(`admin-ui/src/App.tsx`, email+password → `POST /auth/login`
form-encoded per `OAuth2PasswordRequestForm`, JWT gemmes i form-encoded per `OAuth2PasswordRequestForm`, JWT gemmes i
`localStorage`), som ved succes kalder `GET /auth/me` og viser den `localStorage`. Login og log ud er browser-testet af brugeren
indloggede brugers navn/email. Login og log ud er browser-testet af (ModHeader, `Host: admin.localhost`) og virker.
brugeren (ModHeader, `Host: admin.localhost`) og virker.
**11b del 1** (frontend-infrastruktur + Deltagere-CRUD) bygger videre
på dette:
- **`admin-ui/src/api.ts`** — delt `apiFetch`-wrapper (sætter
`Authorization: Bearer`, kaster en fejl med API'ets `detail`-besked
ved ikke-2xx), samt `elevate()`/`decodeJwtExpiry()`.
- **`admin-ui/src/AuthContext.tsx`** — React Context: session
overlever nu en sideopdatering (kalder `GET /auth/me` med det
gemte token ved appstart, i stedet for at nulstille til login-siden
— en mangel fra 11a). Holder desuden en **eleveret sessions-
tilstand** (`isElevated`, `elevatedToken`, `requestElevation()`):
eleveringen er bevidst en **synlig, separat handling** ("Forhøj
rettigheder"-knap i navbaren, ✅ browser-testet) — destruktive
knapper som "Slet permanent" er kun synlige/aktive mens
`isElevated` er sand (udløber automatisk efter ~5 minutter, samme
som backend'ens `elevation_expire_minutes`). Dette er bevidst
strengere end blot at kunne, teknisk set, kalde `/auth/elevate`
usynligt i baggrunden ved hvert destruktivt klik — elevering skal
*føles* som en bevidst handling, ikke ske transparent bag et enkelt
klik (fundet under brugertest: et enkelt klik + kun en
browser-`confirm()` var utilstrækkeligt).
- **`react-router-dom`** — `Layout.tsx` (navbar: Deltagere-link,
elevations-knap for superadmins, brugernavn, log ud) + en
**`ParticipantsPage`**: liste, opret, redigér (inkl. aktiv/inaktiv),
hård-sletning (kun synlig for superadmins der er eleverede).
- **Ny backend-route:** `GET /routes` (`app/routers/routes.py`,
`RoutePublic`-model) — minimal, kun læsning, org-scoped. UI'et har
brug for rutens id for at oprette deltagere; dette er **ikke** den
udskudte rute-indstillings-redigeringsskærm (se Fase 1 nedenfor).
- **Fundet og rettet under brugertest:** `GET /participants` uden et
eksplicit `limit` rammer API'ets default (100) — med 308 rigtige
deltagere (sorteret efter stigende id) betød det at hverken alle
deltagere eller nyoprettede deltagere (som altid får det højeste id)
kunne ses. `ParticipantsPage` sender nu eksplicit `limit=500`
(API'ets maksimum). Rigtig pagination er udskudt til Fase 2, hvis
listen en dag vokser forbi 500.
## 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**
@ -283,7 +322,7 @@ logges/advares om i stedet.
## Fase 1 — nuværende scope ## Fase 1 — nuværende scope
**Færdige opgaver (1-10, 11a):** **Færdige opgaver (1-10, 11a, 11b del 1):**
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,
@ -334,7 +373,11 @@ logges/advares om i stedet.
11. Simpelt React admin-UI til pkt. 5, 6, 7, 9 — delt i tre: 11. Simpelt React admin-UI til pkt. 5, 6, 7, 9 — delt i tre:
- **11a**: ✅ Scaffold + domæne-baseret hosting-mekanisme — se - **11a**: ✅ Scaffold + domæne-baseret hosting-mekanisme — se
"Admin-UI-arkitektur" ovenfor. "Admin-UI-arkitektur" ovenfor.
- **11b**: Deltagere, runder/vinliste, annoncering — ikke bygget. - **11b**: Deltagere, runder/vinliste, annoncering — delt i tre
(se "Admin-UI-arkitektur" ovenfor): **del 1** ✅
(frontend-infrastruktur + Deltagere-CRUD), **del 2** (runder +
vinliste, ikke bygget), **del 3** (mail-skabelon-editor +
annoncér-knap, ikke bygget).
- **11c**: Ordreoversigt/markér betalt — ikke bygget. - **11c**: Ordreoversigt/markér betalt — ikke bygget.
**Resterende opgaver:** **Resterende opgaver:**
@ -344,6 +387,12 @@ logges/advares om i stedet.
- enablebanking.com: automatisk match af indbetalinger mod ubetalte - enablebanking.com: automatisk match af indbetalinger mod ubetalte
ordrer → kalder samme "markér betalt"-logik ordrer → kalder samme "markér betalt"-logik
- Passkey-login til admin - Passkey-login til admin
- Rigtig pagination i admin-UI'ets deltagerliste, hvis antallet af
deltagere vokser forbi API'ets maksimale sidestørrelse (500) — se
"Admin-UI-arkitektur" ovenfor
- Rute-indstillings-skærm i admin-UI'et (navn, mødested,
afsender-navn/email, `public_domain`) — kun én rute findes i dag,
direkte database-redigering er fint indtil videre
- Rute-vælger på bestillingssiden (når flere ruter er aktive samtidig) - Rute-vælger på bestillingssiden (når flere ruter er aktive samtidig)
- sold_only_by_case-flag + validering, hvis det bliver relevant - sold_only_by_case-flag + validering, hvis det bliver relevant
- i18n/fransk oversættelse af den offentlige side (8c blev bevidst - i18n/fransk oversættelse af den offentlige side (8c blev bevidst

View file

@ -9,7 +9,8 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8",
"react-router-dom": "^7.18.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
@ -676,6 +677,19 @@
} }
} }
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -1125,6 +1139,44 @@
"react": "^19.3.0" "react": "^19.3.0"
} }
}, },
"node_modules/react-router": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.4"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.11", "version": "1.2.11",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz",
@ -1165,6 +1217,12 @@
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View file

@ -11,7 +11,8 @@
}, },
"dependencies": { "dependencies": {
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8",
"react-router-dom": "^7.18.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.13.3", "@types/node": "^24.13.3",

View file

@ -4,7 +4,6 @@
padding: 24px; padding: 24px;
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 8px; border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
} }
.login-card h1 { .login-card h1 {
@ -38,3 +37,98 @@
color: #b3261e; color: #b3261e;
margin-top: 12px; margin-top: 12px;
} }
.navbar {
background: #1a1a1a;
color: #fff;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
}
.navbar-brand {
font-weight: 600;
}
.navbar-links {
display: flex;
align-items: center;
gap: 16px;
}
.navbar-links a {
color: #fff;
text-decoration: none;
}
.navbar-links a.active {
text-decoration: underline;
}
.navbar-user {
color: #bbb;
font-size: 0.9rem;
}
.elevated-badge {
color: #7fd88f;
font-size: 0.9rem;
}
.hint {
color: #666;
font-size: 0.9rem;
font-style: italic;
}
.link-button {
background: none;
border: none;
color: #fff;
text-decoration: underline;
cursor: pointer;
font-size: 1rem;
padding: 0;
}
.content {
max-width: 900px;
margin: 0 auto;
padding: 24px;
}
.content .error {
color: #b3261e;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
th,
td {
text-align: left;
padding: 6px 8px;
border-bottom: 1px solid #eee;
}
.inline-form {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin: 12px 0;
}
.inline-form input {
padding: 6px;
}
.inline-form button {
padding: 6px 12px;
cursor: pointer;
}

View file

@ -1,93 +1,33 @@
import { useState, type FormEvent } from 'react' import { Navigate, Route, BrowserRouter, Routes } from 'react-router-dom'
import { AuthProvider, useAuth } from './AuthContext'
import { Layout } from './Layout'
import { LoginPage } from './pages/LoginPage'
import { ParticipantsPage } from './pages/ParticipantsPage'
import './App.css' import './App.css'
interface CurrentUser { function AppRoutes() {
id: number const { currentUser, loading } = useAuth()
email: string
name: string
is_active: boolean
is_superadmin: boolean
organization_id: number
}
const TOKEN_STORAGE_KEY = 'vinindkoeb_admin_token' if (loading) return <p>Indlæser...</p>
if (!currentUser) return <LoginPage />
async function login(email: string, password: string): Promise<string> { return (
const response = await fetch('/auth/login', { <Routes>
method: 'POST', <Route element={<Layout />}>
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, <Route path="/deltagere" element={<ParticipantsPage />} />
body: new URLSearchParams({ username: email, password }), <Route path="*" element={<Navigate to="/deltagere" replace />} />
}) </Route>
if (!response.ok) { </Routes>
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() { 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 ( return (
<div className="login-card"> <BrowserRouter>
<h1>Fælles Vinindkøb — Admin</h1> <AuthProvider>
<p> <AppRoutes />
Logget ind som <strong>{currentUser.name}</strong> ({currentUser.email}) </AuthProvider>
</p> </BrowserRouter>
<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>
) )
} }

View file

@ -0,0 +1,97 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import {
apiFetch,
clearToken,
decodeJwtExpiry,
elevate as apiElevate,
getToken,
login as apiLogin,
setToken,
} from './api'
export interface CurrentUser {
id: number
email: string
name: string
is_active: boolean
is_superadmin: boolean
organization_id: number
}
interface AuthState {
currentUser: CurrentUser | null
loading: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
/** Kortlivet eleveret token — kun sat lige efter et bevidst "Forhøj rettigheder"-klik. */
elevatedToken: string | null
isElevated: boolean
requestElevation: () => Promise<void>
}
const AuthContext = createContext<AuthState | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null)
const [loading, setLoading] = useState(true)
const [elevatedToken, setElevatedToken] = useState<string | null>(null)
const [elevatedExpiresAt, setElevatedExpiresAt] = useState<number | null>(null)
const [, forceTick] = useState(0)
useEffect(() => {
const token = getToken()
if (!token) {
setLoading(false)
return
}
apiFetch<CurrentUser>('/auth/me', {}, token)
.then(setCurrentUser)
.catch(() => clearToken())
.finally(() => setLoading(false))
}, [])
// Genberegn isElevated løbende, så "Slet permanent"-knapper forsvinder
// igen af sig selv når den eleverede session udløber (uden en sideopdatering).
useEffect(() => {
if (elevatedExpiresAt === null) return
const interval = setInterval(() => forceTick((n) => n + 1), 5000)
return () => clearInterval(interval)
}, [elevatedExpiresAt])
const isElevated = elevatedExpiresAt !== null && Date.now() < elevatedExpiresAt
async function login(email: string, password: string) {
const token = await apiLogin(email, password)
setToken(token)
const user = await apiFetch<CurrentUser>('/auth/me', {}, token)
setCurrentUser(user)
}
function logout() {
clearToken()
setCurrentUser(null)
setElevatedToken(null)
setElevatedExpiresAt(null)
}
async function requestElevation() {
const token = await apiElevate()
const exp = decodeJwtExpiry(token)
setElevatedToken(token)
setElevatedExpiresAt(exp ? exp * 1000 : Date.now() + 5 * 60 * 1000)
}
return (
<AuthContext.Provider
value={{ currentUser, loading, login, logout, elevatedToken, isElevated, requestElevation }}
>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthState {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth skal bruges inden i AuthProvider')
return context
}

51
admin-ui/src/Layout.tsx Normal file
View file

@ -0,0 +1,51 @@
import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { ApiError } from './api'
import { useAuth } from './AuthContext'
export function Layout() {
const { currentUser, logout, isElevated, requestElevation } = useAuth()
const [elevationError, setElevationError] = useState<string | null>(null)
async function handleElevate() {
setElevationError(null)
try {
await requestElevation()
} catch (err) {
setElevationError(err instanceof ApiError ? err.message : 'Kunne ikke forhøje rettigheder')
}
}
return (
<div>
<nav className="navbar">
<span className="navbar-brand">Fælles Vinindkøb — Admin</span>
<div className="navbar-links">
<NavLink to="/deltagere">Deltagere</NavLink>
{currentUser?.is_superadmin &&
(isElevated ? (
<span className="elevated-badge" title="Udløber automatisk efter 5 minutter">
Rettigheder forhøjet ✓
</span>
) : (
<button className="link-button" onClick={handleElevate}>
Forhøj rettigheder
</button>
))}
{currentUser && (
<>
<span className="navbar-user">{currentUser.name}</span>
<button className="link-button" onClick={logout}>
Log ud
</button>
</>
)}
</div>
</nav>
{elevationError && <p className="content error">{elevationError}</p>}
<main className="content">
<Outlet />
</main>
</div>
)
}

74
admin-ui/src/api.ts Normal file
View file

@ -0,0 +1,74 @@
export const TOKEN_STORAGE_KEY = 'vinindkoeb_admin_token'
export class ApiError extends Error {}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_STORAGE_KEY)
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_STORAGE_KEY, token)
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_STORAGE_KEY)
}
async function extractErrorMessage(response: Response): Promise<string> {
try {
const body = await response.json()
if (typeof body.detail === 'string') return body.detail
if (Array.isArray(body.detail)) {
return body.detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join(', ')
}
return JSON.stringify(body.detail)
} catch {
return `Fejl (${response.status})`
}
}
/** Autentificeret fetch mod API'et — bruger det gemte token med mindre et andet gives via `token`. */
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
token: string | null = getToken(),
): Promise<T> {
const headers = new Headers(options.headers)
if (token) headers.set('Authorization', `Bearer ${token}`)
const response = await fetch(path, { ...options, headers })
if (!response.ok) {
throw new ApiError(await extractErrorMessage(response))
}
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
export 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) {
throw new ApiError(await extractErrorMessage(response))
}
const data = await response.json()
return data.access_token as string
}
/** Henter et kortlivet eleveret token (kræver ikke ny password-indtastning). */
export async function elevate(): Promise<string> {
const data = await apiFetch<{ access_token: string }>('/auth/elevate', { method: 'POST' })
return data.access_token
}
/** Læser `exp`-claimet (unix-sekunder) fra et JWT uden at verificere signaturen —
* kun til at vise/skjule UI, den rigtige kontrol sker altid server-side. */
export function decodeJwtExpiry(token: string): number | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return typeof payload.exp === 'number' ? payload.exp : null
} catch {
return null
}
}

View file

@ -2,4 +2,5 @@ body {
margin: 0; margin: 0;
background: #fff; background: #fff;
color: #222; color: #222;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
} }

View file

@ -0,0 +1,38 @@
import { useState, type FormEvent } from 'react'
import { useAuth } from '../AuthContext'
import { ApiError } from '../api'
export function LoginPage() {
const { login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setError(null)
try {
await login(email, password)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Der skete en fejl')
}
}
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>
)
}

View file

@ -0,0 +1,226 @@
import { useEffect, useState, type FormEvent } from 'react'
import { apiFetch, ApiError } from '../api'
import { useAuth } from '../AuthContext'
interface Route {
id: number
name: string
}
interface Participant {
id: number
name: string
email: string
phone: string | null
is_active: boolean
route_id: number
}
interface NewParticipantForm {
name: string
email: string
phone: string
}
const emptyForm: NewParticipantForm = { name: '', email: '', phone: '' }
export function ParticipantsPage() {
const { currentUser, isElevated, elevatedToken } = useAuth()
const [routeId, setRouteId] = useState<number | null>(null)
const [participants, setParticipants] = useState<Participant[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [newParticipant, setNewParticipant] = useState<NewParticipantForm>(emptyForm)
const [editingId, setEditingId] = useState<number | null>(null)
const [editForm, setEditForm] = useState<NewParticipantForm & { is_active: boolean }>({
...emptyForm,
is_active: true,
})
async function loadParticipants(forRouteId: number) {
setLoading(true)
setError(null)
try {
// limit=500 er API'ets maksimum (le=500) — komfortabelt over de 308 rigtige
// deltagere i dag. Skal siden bygges rigtig pagination hvis antallet vokser videre.
const data = await apiFetch<Participant[]>(`/participants?route_id=${forRouteId}&limit=500`)
setParticipants(data)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente deltagere')
} finally {
setLoading(false)
}
}
useEffect(() => {
apiFetch<Route[]>('/routes')
.then((routes) => {
if (routes.length === 0) {
setError('Ingen ruter fundet for din organisation')
setLoading(false)
return
}
setRouteId(routes[0].id)
return loadParticipants(routes[0].id)
})
.catch((err) => {
setError(err instanceof ApiError ? err.message : 'Kunne ikke hente ruter')
setLoading(false)
})
}, [])
async function handleCreate(event: FormEvent) {
event.preventDefault()
if (routeId === null) return
setError(null)
try {
await apiFetch('/participants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...newParticipant, route_id: routeId }),
})
setNewParticipant(emptyForm)
await loadParticipants(routeId)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke oprette deltager')
}
}
function startEdit(participant: Participant) {
setEditingId(participant.id)
setEditForm({
name: participant.name,
email: participant.email,
phone: participant.phone ?? '',
is_active: participant.is_active,
})
}
async function handleSaveEdit(event: FormEvent) {
event.preventDefault()
if (editingId === null || routeId === null) return
setError(null)
try {
await apiFetch(`/participants/${editingId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm),
})
setEditingId(null)
await loadParticipants(routeId)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke gemme ændringer')
}
}
async function handleHardDelete(participant: Participant) {
if (routeId === null || !elevatedToken) return
if (!window.confirm(`Slet ${participant.name} permanent? Dette kan ikke fortrydes.`)) return
setError(null)
try {
await apiFetch(`/participants/${participant.id}`, { method: 'DELETE' }, elevatedToken)
await loadParticipants(routeId)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Kunne ikke slette deltager')
}
}
if (loading) return <p>Indlæser...</p>
return (
<div>
<h1>Deltagere</h1>
{error && <p className="error">{error}</p>}
{currentUser?.is_superadmin && !isElevated && (
<p className="hint">Forhøj dine rettigheder (knap øverst) for at kunne slette en deltager permanent.</p>
)}
<form onSubmit={handleCreate} className="inline-form">
<input
placeholder="Navn"
value={newParticipant.name}
onChange={(e) => setNewParticipant({ ...newParticipant, name: e.target.value })}
required
/>
<input
type="email"
placeholder="Email"
value={newParticipant.email}
onChange={(e) => setNewParticipant({ ...newParticipant, email: e.target.value })}
required
/>
<input
placeholder="Telefon"
value={newParticipant.phone}
onChange={(e) => setNewParticipant({ ...newParticipant, phone: e.target.value })}
required
/>
<button type="submit">Ny deltager</button>
</form>
<table>
<thead>
<tr>
<th>Navn</th>
<th>Email</th>
<th>Telefon</th>
<th>Aktiv</th>
<th>Handlinger</th>
</tr>
</thead>
<tbody>
{participants.map((participant) =>
editingId === participant.id ? (
<tr key={participant.id}>
<td colSpan={5}>
<form onSubmit={handleSaveEdit} className="inline-form">
<input
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
required
/>
<input
type="email"
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
required
/>
<input
value={editForm.phone}
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
/>
<label>
<input
type="checkbox"
checked={editForm.is_active}
onChange={(e) => setEditForm({ ...editForm, is_active: e.target.checked })}
/>
Aktiv
</label>
<button type="submit">Gem</button>
<button type="button" onClick={() => setEditingId(null)}>
Fortryd
</button>
</form>
</td>
</tr>
) : (
<tr key={participant.id}>
<td>{participant.name}</td>
<td>{participant.email}</td>
<td>{participant.phone}</td>
<td>{participant.is_active ? 'Ja' : 'Nej'}</td>
<td>
<button onClick={() => startEdit(participant)}>Redigér</button>
{currentUser?.is_superadmin && isElevated && (
<button onClick={() => handleHardDelete(participant)}>Slet permanent</button>
)}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)
}

View file

@ -20,6 +20,7 @@ _API_PREFIXES = (
"/mail-templates", "/mail-templates",
"/mail-logs", "/mail-logs",
"/orders", "/orders",
"/routes",
"/webhooks", "/webhooks",
"/public", "/public",
"/health", "/health",

View file

@ -13,6 +13,7 @@ from app.routers import (
public_orders, public_orders,
public_site, public_site,
purchase_rounds, purchase_rounds,
routes,
webhooks, webhooks,
wine_categories, wine_categories,
wine_offerings, wine_offerings,
@ -28,6 +29,7 @@ app.include_router(wine_categories.router)
app.include_router(mail_templates.router) app.include_router(mail_templates.router)
app.include_router(mail_logs.router) app.include_router(mail_logs.router)
app.include_router(orders.router) app.include_router(orders.router)
app.include_router(routes.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)

View file

@ -35,3 +35,7 @@ class Route(RouteBase, table=True):
mail_templates: List["MailTemplate"] = Relationship( mail_templates: List["MailTemplate"] = Relationship(
back_populates="route", sa_relationship_kwargs={"cascade": "all, delete-orphan"} back_populates="route", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
) )
class RoutePublic(RouteBase):
id: int

13
app/routers/routes.py Normal file
View file

@ -0,0 +1,13 @@
from fastapi import APIRouter
from sqlmodel import select
from app.db import SessionDep
from app.dependencies import CurrentUser
from app.models.route import Route, RoutePublic
router = APIRouter(prefix="/routes", tags=["routes"])
@router.get("", response_model=list[RoutePublic])
def list_routes(session: SessionDep, current_user: CurrentUser) -> list[Route]:
return list(session.exec(select(Route).where(Route.organization_id == current_user.organization_id)).all())