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:
parent
fb4a3fb732
commit
62d464fda2
15 changed files with 744 additions and 95 deletions
69
CLAUDE.md
69
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
60
admin-ui/package-lock.json
generated
60
admin-ui/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
|
||||||
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 (
|
return (
|
||||||
<div className="login-card">
|
<Routes>
|
||||||
<h1>Fælles Vinindkøb — Admin</h1>
|
<Route element={<Layout />}>
|
||||||
<p>
|
<Route path="/deltagere" element={<ParticipantsPage />} />
|
||||||
Logget ind som <strong>{currentUser.name}</strong> ({currentUser.email})
|
<Route path="*" element={<Navigate to="/deltagere" replace />} />
|
||||||
</p>
|
</Route>
|
||||||
<button onClick={handleLogout}>Log ud</button>
|
</Routes>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="login-card">
|
<BrowserRouter>
|
||||||
<h1>Fælles Vinindkøb — Admin</h1>
|
<AuthProvider>
|
||||||
<form onSubmit={handleSubmit}>
|
<AppRoutes />
|
||||||
<label>
|
</AuthProvider>
|
||||||
Email
|
</BrowserRouter>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
97
admin-ui/src/AuthContext.tsx
Normal file
97
admin-ui/src/AuthContext.tsx
Normal 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
51
admin-ui/src/Layout.tsx
Normal 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
74
admin-ui/src/api.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
admin-ui/src/pages/LoginPage.tsx
Normal file
38
admin-ui/src/pages/LoginPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
226
admin-ui/src/pages/ParticipantsPage.tsx
Normal file
226
admin-ui/src/pages/ParticipantsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ _API_PREFIXES = (
|
||||||
"/mail-templates",
|
"/mail-templates",
|
||||||
"/mail-logs",
|
"/mail-logs",
|
||||||
"/orders",
|
"/orders",
|
||||||
|
"/routes",
|
||||||
"/webhooks",
|
"/webhooks",
|
||||||
"/public",
|
"/public",
|
||||||
"/health",
|
"/health",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
13
app/routers/routes.py
Normal 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())
|
||||||
Loading…
Add table
Reference in a new issue