Compare commits

...
16 Commits
Author SHA1 Message Date
florian.wilde b7c24364b8 Merge feature/v1.5: Supabase entfernt, vollständig selbst gehostet
Postgres statt Supabase-Cloud, eigene Session-/Passkey-Auth, lokale
Branding-Dateiablage, Autorisierung in den API-Routen statt RLS,
automatischer Admin-Bootstrap bei leerer Datenbank, Gerichtsuche im
Wochenplan. Vor dem Merge per Sicherheitsprüfung geprüft, ein
gefundenes SVG-Upload-XSS-Problem behoben.
2026-07-18 11:59:13 +02:00
florian.wilde be80edec61 Changelog: Hinweis auf noch offenen Merge entfernt, v1.5.0 finalisiert 2026-07-18 11:58:50 +02:00
florian.wilde 0958abaac6 Sicherheit: SVG-Upload für Logo/Favicon nicht mehr erlaubt
Der Branding-Upload akzeptierte bisher jeden Dateityp, der clientseitig
mit "image/" beginnt, inkl. SVG. Anders als beim vorherigen Supabase-
Storage-Bucket (andere Origin) wird die Datei jetzt auf der eigenen
Origin unter /branding/[filename] ausgeliefert – eine SVG mit
eingebettetem <script> hätte bei direktem Aufruf der Datei-URL
(z. B. "Bild in neuem Tab öffnen") mit dem Session-Cookie eines
anderen Admins ausgeführt werden können und wäre dabei auch am
CSRF-Schutz vorbeigekommen (Origin stimmt ja).

Upload-Route erlaubt jetzt nur noch eine Whitelist an Rasterformaten
(png/jpg/webp/gif/ico), die Dateiendung wird aus dem geprüften
MIME-Typ abgeleitet statt aus dem vom Client gesendeten Dateinamen.
Die Ausliefer-Route kennt "svg" als Content-Type nicht mehr (Defense
in Depth). Gefunden bei der Sicherheitsprüfung vor dem Merge nach
master.
2026-07-18 11:58:07 +02:00
florian.wilde 3d13ba88f4 Wochenplan: Gerichtsuche statt langer Dropdown-Liste, Beispieldaten zurück
WeekEditor: "Mahlzeit hinzufügen" zeigt jetzt ein Suchfeld mit
gefilterter Trefferliste (wie die Live-Suche bei Gerichten) statt einer
langen Dropdown-Liste zum Durchscrollen. Ausgewähltes Gericht lässt sich
per ×-Button wieder ändern.

Neues Skript db/seed-demo.sql (+ db/seed-demo.mjs, "npm run
db:seed-demo"): spielt den ursprünglichen Beispieldatensatz ein
(4 Essenszeiten, 14 Allergene, 11 Zusatzstoffe, 18 Testgerichte, zwei
Beispielwochen) – nur für lokale Entwicklung/Demos, kein Teil der
automatischen Migrationen, da frische Deployments weiterhin leer
starten sollen.
2026-07-18 11:35:13 +02:00
florian.wilde c60f481aec Version 1.5.0
Vollständig verifiziert: natives npm run dev auf frisch geleerter
lokaler Postgres-DB (Migration + Admin-Bootstrap + ZUGANGSDATEN.md,
Login, Essenszeiten/Gerichte/Wochenplan/Kennzeichnung/Vorschläge-CRUD,
Konto-Verwaltung inkl. serverseitiger Passkey-Absicherung,
Branding-Upload/-Löschung, Admin-Benutzerverwaltung mit
rollenbasierter Zugriffskontrolle, Neustart-Idempotenz) sowie über
docker compose --build auf eigenem Volume (Migration/Bootstrap im
Container-Entrypoint, Bind-Mount für ZUGANGSDATEN.md, Login).
2026-07-18 11:21:19 +02:00
florian.wilde b512093a7e v1.5: Docker/Compose/.env.example auf selbst gehostetes Postgres umgestellt
Dockerfile: keine NEXT_PUBLIC_*-Build-Args mehr (Branding-URLs sind jetzt
relativ, kein Anon-Key-Konzept mehr – das Image wird dadurch
umgebungsunabhängig), db/migrate.mjs + scripts/ werden zusätzlich in die
Runner-Stage kopiert (nicht Teil von Next's Standalone-Trace), CMD wendet
Migrationen an und legt bei Bedarf den Admin an, bevor der Server startet.

docker-compose.yml: db-Dienst ist jetzt die primäre Datenbank (kein
Supabase-Dump-Mount mehr), neues Volume branding_data für Logo/Favicon,
Bind-Mount für ZUGANGSDATEN.md.

.env.example: Supabase-Variablen durch DATABASE_URL + optionales
ADMIN_EMAIL ersetzt. package.json: @supabase/ssr und @supabase/supabase-js
entfernt. README.md komplett auf die neue Architektur aktualisiert.
2026-07-18 11:02:16 +02:00
florian.wilde a9a9cf7ba4 v1.5: Alle Manager-Komponenten auf eigene API-Routen umgestellt
Neue interne Route Handler unter app/api/ (dishes inkl. Bulk-Delete,
sections, allergens, additives, menu-entries, dish-suggestions,
settings, admin/branding) ersetzen den direkten Browser→Postgrest-
Zugriff der bisherigen Supabase-Anbindung. Jede Route prüft Autorisierung
selbst (lib/auth/session.ts requireRole) statt sich auf Postgres-RLS zu
verlassen – ersetzt 1:1 die bisherigen Policies (öffentlich lesbar /
staff+admin schreiben / nur admin für Einstellungen & Benutzer).

Rezeptvorschlag-Dublettencheck (SuggestionForm) läuft jetzt zwingend
serverseitig im POST-Handler, da es keinen Browser→Postgres-Pfad mehr
gibt. "Passkey annehmen" (SuggestionReview) läuft transaktional
(Gericht anlegen + Vorschlag löschen in einem Schritt).

Logo/Favicon: neuer lokaler Dateispeicher (lib/branding.ts, Docker-
Volume) statt Supabase-Storage-Bucket, ausgeliefert über
app/branding/[filename]. logoUrl()/faviconUrl() liefern jetzt einfache
relative Pfade statt einer fest verdrahteten Supabase-Storage-URL.

DeepL-Route (api/translate) und Benutzerverwaltung (api/admin/users)
laufen jetzt gegen die users-Tabelle statt Supabase Auth.

Alle "use client"-Manager-Komponenten (DishManager, WeekEditor,
SectionManager, CodeListManager, SuggestionForm/Review, UserManager,
DesignEditor) rufen ihre jeweiligen /api/-Routen per fetch() statt den
Supabase-Browser-Client zu nutzen.
2026-07-18 11:01:52 +02:00
florian.wilde 28c9ec15c8 v1.5: Selbst gehostete Auth (Passwort + Passkeys), Postgres statt Supabase
Fundament: db/migrations/0001_init.sql (users/sessions/passkey_credentials/
webauthn_challenges + Inhaltstabellen ohne RLS), db/migrate.mjs
(idempotenter Migrationsrunner), lib/db (pg-Pool + query-Wrapper),
lib/auth/password.ts (Node-crypto.scrypt, keine neue Abhängigkeit),
lib/auth/session.ts (opake DB-Session-Tokens, Sliding-Refresh in proxy.ts).

Passkeys: @simplewebauthn/server + /browser ersetzen Supabase's
experimentelles WebAuthn-API (dafür gab es bisher kein eigenes
Credential-Schema); RP-Konfiguration kommt jetzt direkt aus APP_URL statt
über scripts/configure-passkeys.mjs und die Supabase-Management-API.

scripts/bootstrap-admin.mjs legt bei leerer users-Tabelle automatisch
einen Admin mit zufälligem Passwort an und hängt die Zugangsdaten an
ZUGANGSDATEN.md an (idempotent, via predev/prestart-Hooks aufgerufen).

Bugfix im Zuge des Umbaus: "mind. 1 Passkey nötig, um Passwort-Login zu
deaktivieren" wurde bisher nur clientseitig geprüft – jetzt zusätzlich
serverseitig in PATCH /api/account.

E-Mail-Änderung im eigenen Konto läuft jetzt direkt nach
Passwort-Bestätigung statt über eine Supabase-Bestätigungsmail (kein
SMTP-Server vorhanden).

Rest der Anwendung (Gerichte/Essenszeiten/Kennzeichnung/Vorschläge/
Benutzerverwaltung/Branding) nutzt noch den alten Supabase-Client –
folgt in weiteren Commits.
2026-07-18 10:42:21 +02:00
florian.wilde 4c5376a8c7 Merge feature/v1.4: Essenszeiten variabel, DeepL-Button-Sichtbarkeit, Bugfix mobiler Sprachumschalter 2026-07-18 08:33:25 +02:00
florian.wilde 0a1f533880 Changelog: Hinweis auf noch offenen Merge für 1.4.0 entfernt 2026-07-18 08:33:17 +02:00
florian.wilde 4d5e7fca04 Bug: Sprachumschalter in mobiler Ansicht doppelt
Der Kopfbereich der öffentlichen Speisekarte zeigte seinen eigenen
LanguageSwitcher auch auf schmalen Bildschirmen an, obwohl
PublicMobileMenu dort bereits einen eigenen rendert. Der Header-Switcher
ist jetzt wie der Rest des Desktop-Layouts erst ab dem sm-Breakpoint
sichtbar.
2026-07-18 08:30:20 +02:00
florian.wilde 0f20ad6527 "Bereiche" zu "Essenszeiten" umbenannt
Betrifft UI-Texte (DE/EN/PL), Route (/intern/bereiche ->
/intern/essenszeiten), Code-Kommentare, Changelog und README.
Fachlich unverändert (weiterhin die Tabelle/Komponente "sections"/
SectionManager im Code), da "Essenszeiten" den Zeitbezug des Features
klarer ausdrückt als der bisherige Sammelbegriff.
2026-07-18 08:26:15 +02:00
florian.wilde d85d2e3fe3 Version 1.4.0 2026-07-18 08:15:31 +02:00
florian.wilde a80cd5bd12 Lokale PostgreSQL-Kopie: Dump nach Migration 0006 aktualisiert 2026-07-18 08:15:20 +02:00
florian.wilde 51fdb93e97 Automatisch-übersetzen-Button nur bei konfiguriertem DEEPL_API_KEY
Statt den Button immer anzuzeigen und erst nach Klick auf die
fehlende Konfiguration hinzuweisen, wird er serverseitig anhand von
process.env.DEEPL_API_KEY ein-/ausgeblendet (Gerichte, Allergene,
Zusatzstoffe). Die dadurch überflüssige "translateUnavailable"-
Meldung wurde aus allen drei Sprachen entfernt.
2026-07-18 08:14:49 +02:00
florian.wilde 868a5d2fcf Bereiche vollständig verwaltbar (anlegen/bearbeiten/löschen/Uhrzeit)
Migration 0006: sections.key bekommt einen automatisch generierten
Schlüssel (wie dishes.id), menu_entries.section_key kaskadiert beim
Löschen eines Bereichs, neue RLS-Policy erlaubt Kantinenmitarbeitern/
Admins Schreibzugriff auf sections.

Neue Seite „Bereiche" im Kantinen-Bereich (SectionManager): Bereiche
anlegen, umbenennen (inkl. EN/PL), Uhrzeit setzen, löschen und per
Pfeil-Buttons neu anordnen.
2026-07-18 08:14:32 +02:00
95 changed files with 3621 additions and 2280 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ node_modules
.env*
!.env.example
ZUGANGSDATEN.md
supabase/local
data/branding
Dockerfile
docker-compose.yml
README.md
+9 -11
View File
@@ -1,25 +1,23 @@
# Vorlage für .env.local (Entwicklung) bzw. die Umgebungsvariablen
# beim Hoster (Live-Betrieb). Echte Werte niemals committen!
# Supabase-Projekt-URL (Dashboard -> Project Settings -> API)
NEXT_PUBLIC_SUPABASE_URL=https://<projekt-ref>.supabase.co
# Öffentlicher anon-Key (darf im Browser landen, RLS schützt die Daten)
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
# Service-Role-Key (GEHEIM! Nur serverseitig, für die Benutzerverwaltung)
SUPABASE_SERVICE_ROLE_KEY=<service-role-key>
# Verbindung zur (selbst gehosteten) PostgreSQL-Datenbank.
DATABASE_URL=postgres://postgres:<passwort>@localhost:5432/canteen_menu
# Öffentliche URL, unter der die App erreichbar ist (Schema + Domain).
# Wird für den CSRF-Origin-Check hinter Reverse-Proxies genutzt und ist
# zugleich die Passkey-Domain: nach einer Änderung einmalig
# SUPABASE_ACCESS_TOKEN=<token> node scripts/configure-passkeys.mjs
# ausführen, damit Passkeys auf der neuen Domain funktionieren.
# zugleich die Passkey-Domain (RP-ID/Origin werden direkt daraus
# abgeleitet, kein separater Konfigurationsschritt nötig).
APP_URL=http://localhost:3000
# Titel im Browser-Tab und Überschrift, solange kein Logo hochgeladen ist.
SITE_TITLE=Canteen Menu
# Optional: E-Mail-Adresse für den automatisch angelegten Admin-Erstnutzer
# (nur relevant, solange die users-Tabelle noch leer ist). Ohne Angabe
# wird admin@<Hostname aus APP_URL> verwendet.
ADMIN_EMAIL=
# Optional: DeepL-API-Key für automatische Übersetzung von Gerichten nach
# Englisch/Polnisch (kostenloser Free-Tier unter deepl.com/de/pro-api,
# Free-Keys enden auf ":fx"). Ohne Key bleibt "Automatisch übersetzen"
+76
View File
@@ -4,6 +4,82 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
Versionierung nach [Semantic Versioning](https://semver.org/lang/de/).
## [1.5.0] – 2026-07-18
### Hinzugefügt
- Wochenplan: „Mahlzeit hinzufügen" bietet jetzt ein Suchfeld mit
gefilterter Trefferliste statt einer langen Dropdown-Liste zum
Durchscrollen.
- `db/seed-demo.sql` (`npm run db:seed-demo`): spielt für lokale
Entwicklung/Demos den ursprünglichen Beispieldatensatz ein
(Essenszeiten, Allergene, Zusatzstoffe, Testgerichte, zwei
Beispielwochen) – kein Teil der automatischen Migrationen, frische
Deployments starten weiterhin leer.
### Geändert
- **Supabase vollständig entfernt** – die App läuft jetzt komplett
selbst gehostet, einzige externe Abhängigkeit bleibt optional DeepL.
- Datenbank: selbst gehostetes PostgreSQL statt Supabase-Cloud-Postgres.
Schema in `db/migrations/*.sql`, wird beim Start automatisch und
idempotent angewendet (`db/migrate.mjs`).
- Anmeldung: eigene Session-Verwaltung (opake, in der DB gespeicherte
Tokens, `httpOnly`/`sameSite=lax`-Cookie) statt Supabase Auth.
Passwörter werden mit Node-eigenem `crypto.scrypt` gehasht (keine
neue Abhängigkeit).
- Passkeys: `@simplewebauthn` ersetzt Supabase's bisher experimentelles
WebAuthn-API. Die RP-Domain wird direkt aus `APP_URL` abgeleitet –
kein externer Konfigurationsschritt mehr nötig.
- Logo/Favicon: lokale Dateiablage (Docker-Volume) statt
Supabase-Storage-Bucket, ausgeliefert über `/branding/[filename]`.
- Autorisierung (öffentlich lesbar / Kantinen-Team schreibt / nur
Admin für Einstellungen & Benutzer) läuft jetzt in den API-Routen
statt über Postgres-Row-Level-Security.
- E-Mail-Änderung im eigenen Konto läuft direkt nach
Passwort-Bestätigung statt über eine Supabase-Bestätigungsmail
(kein SMTP-Server vorhanden).
- Datenbank startet beim allerersten Deployment leer; beim ersten Start
wird automatisch genau ein Admin-Benutzer mit zufällig generiertem
Passwort angelegt, die Zugangsdaten landen in `ZUGANGSDATEN.md`.
### Behoben
- „Mindestens 1 Passkey nötig, um die Passwort-Anmeldung zu
deaktivieren" wurde bisher nur clientseitig geprüft; ein manuell
gebauter Request hätte die Passwort-Anmeldung auch ohne Passkey
deaktivieren können. Jetzt zusätzlich serverseitig erzwungen.
- Logo-/Favicon-Upload akzeptierte anhand des vom Browser gemeldeten
Dateityps auch SVG-Dateien. Da diese (anders als beim vorherigen
Supabase-Storage-Bucket auf fremder Domain) jetzt auf der eigenen
Origin ausgeliefert werden, hätte eine SVG mit eingebettetem
`<script>` bei direktem Aufruf der Datei-URL im Browser eines
anderen angemeldeten Admins ausgeführt werden können. Upload
akzeptiert jetzt nur noch eine Whitelist an Rasterformaten
(png/jpg/webp/gif/ico); vor dem Merge im Rahmen der
Sicherheitsprüfung gefunden und behoben.
### Hinweis für bestehende Docker-Installationen
Das Docker-Volume `pgdata` diente bisher nur als Lese-Kopie/Backup und
wird mit v1.5 zur primären Datenbank. Es enthält noch das alte
Supabase-Mirror-Schema und muss vor dem Upgrade entfernt werden
(`docker compose down -v`, danach `docker compose up -d --build`), damit
die neuen Migrationen auf einer leeren Datenbank laufen.
## [1.4.0] – 2026-07-18
### Hinzugefügt
- Essenszeiten (Brotzeit/Mittagessen/…) vollständig verwaltbar: anlegen,
umbenennen (inkl. EN/PL), Uhrzeit ändern, löschen und per Pfeil-Buttons
neu anordnen – neue Seite „Essenszeiten" im Kantinen-Bereich. Löschen
entfernt zugewiesene Gerichte automatisch aus allen Wochenplänen.
### Geändert
- „Automatisch übersetzen"-Button bei Gerichten/Allergenen/Zusatzstoffen
ist nur noch sichtbar, wenn `DEEPL_API_KEY` gesetzt ist, statt erst
nach Klick auf die fehlende Konfiguration hinzuweisen.
### Behoben
- Sprachumschalter (DE/EN/PL) erschien auf der öffentlichen Speisekarte
in der mobilen Ansicht doppelt.
## [1.3.0] – 2026-07-18
### Hinzugefügt
+7 -7
View File
@@ -15,12 +15,6 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_*-Variablen werden zur Build-Zeit ins Client-Bundle eingebacken
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
RUN npm run build
# ---------- Runtime ----------
@@ -35,7 +29,13 @@ RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Von Next's Standalone-Output nicht automatisch mitgetraced (werden nicht
# von App-Routen importiert, sondern eigenständig vor dem Start ausgeführt).
COPY --from=builder --chown=nextjs:nodejs /app/db ./db
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
RUN mkdir -p /data/branding && chown nextjs:nodejs /data/branding
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
CMD ["sh", "-c", "node db/migrate.mjs && node scripts/bootstrap-admin.mjs && exec node server.js"]
+68 -81
View File
@@ -4,8 +4,11 @@ Digitale Speisekarte für eine Firmenkantine (ca. 250 Mitarbeiter) mit
öffentlicher Wochenansicht, Kantinen-Bereich zur Pflege der Gerichte und
Admin-Bereich für Benutzerverwaltung und Branding.
**Stack:** Next.js 16 (App Router, TypeScript, Tailwind CSS) · Supabase
(PostgreSQL, Auth inkl. Passkeys, Storage, Row Level Security)
**Stack:** Next.js 16 (App Router, TypeScript, Tailwind CSS) · selbst
gehostetes PostgreSQL (via `pg`, eigene Migrationen) · eigene Session-Auth
(Passwort via `node:crypto` scrypt, Passkeys via `@simplewebauthn`) ·
DeepL als einzige externe API (optional). Läuft vollständig selbst
gehostet, keine Cloud-Abhängigkeit außer optional DeepL.
Änderungen je Version stehen in [CHANGELOG.md](CHANGELOG.md), Git-Tags
markieren die jeweiligen Versionsstände (`git tag`).
@@ -14,17 +17,17 @@ markieren die jeweiligen Versionsstände (`git tag`).
| Bereich | Funktionen |
| --- | --- |
| Öffentlich (ohne Login) | Wochenplan Mo–Fr mit den Bereichen Brotzeit (09:00–10:00), Mittagessen, Mittagessen vegetarisch und Suppe (je 11:30–13:30); Navigation vorherige/nächste Woche/Heute; auf schmalen Bildschirmen umschaltbare Tages-/Wochenansicht (Tag ist Standard); Logo zentriert über der Karte; Allergen-/Zusatzstoff-Kürzel je Gericht, Legende unterhalb der Karte; Sprachumschalter Deutsch/Englisch/Polnisch; Rezeptvorschläge einreichen |
| Kantinen-Team (`/intern`) | Gerichte anlegen/bearbeiten/löschen (inkl. Live-Suche, Mehrfachauswahl zum Batch-Löschen, englischem/polnischem Namen mit automatischer DeepL-Übersetzung) mit Allergenen/Zusatzstoffen je Gericht; Gerichte Tagen/Bereichen zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen (ebenfalls mehrsprachig); eingereichte Rezeptvorschläge sichten, übernehmen oder ablehnen; eigenes Konto verwalten (Name, E-Mail, Passwort, Passkeys, Passwort-Anmeldung an/aus) |
| Öffentlich (ohne Login) | Wochenplan Mo–Fr mit frei konfigurierbaren Essenszeiten; Navigation vorherige/nächste Woche/Heute; auf schmalen Bildschirmen umschaltbare Tages-/Wochenansicht (Tag ist Standard); Logo zentriert über der Karte; Allergen-/Zusatzstoff-Kürzel je Gericht, Legende unterhalb der Karte; Sprachumschalter Deutsch/Englisch/Polnisch; Rezeptvorschläge einreichen |
| Kantinen-Team (`/intern`) | Essenszeiten anlegen/bearbeiten/löschen/neu anordnen; Gerichte anlegen/bearbeiten/löschen (inkl. Live-Suche, Mehrfachauswahl zum Batch-Löschen, englischem/polnischem Namen mit automatischer DeepL-Übersetzung) mit Allergenen/Zusatzstoffen je Gericht; Gerichte Tagen/Essenszeiten zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen (ebenfalls mehrsprachig); eingereichte Rezeptvorschläge sichten, übernehmen oder ablehnen; eigenes Konto verwalten (Name, E-Mail, Passwort, Passkeys, Passwort-Anmeldung an/aus) |
| Admin (`/admin`) | Kantinenmitarbeiter anlegen/bearbeiten/löschen (inkl. Passwort-Reset mit Wiederholung und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo und Favicon hochladen/entfernen |
Anmeldung mit E-Mail + Passwort oder alternativ per **Passkey**
(Registrierung im eigenen Konto nach dem ersten Login). Die
Passwort-Anmeldung kann pro Konto deaktiviert werden, sobald mindestens
ein Passkey registriert ist.
ein Passkey registriert ist (serverseitig erzwungen).
Die gesamte Oberfläche ist auf Deutsch, Englisch und Polnisch verfügbar
(Sprachumschalter, Cookie-basiert). Gerichte, Bereiche, Allergene und
(Sprachumschalter, Cookie-basiert). Gerichte, Essenszeiten, Allergene und
Zusatzstoffe lassen sich zusätzlich auf Englisch/Polnisch benennen –
mit optionaler automatischer Übersetzung über DeepL (siehe
`DEEPL_API_KEY` unten) und jederzeit manuell korrigierbar.
@@ -39,18 +42,29 @@ app/ Next.js-Routen
intern/ Kantinen-Bereich (Rolle: staff oder admin)
vorschlaege/ Rezeptvorschläge sichten/übernehmen
admin/ Verwaltung (Rolle: admin)
api/admin/users/ Benutzerverwaltung (serverseitig, Service-Role-Key)
branding/[filename]/ Logo/Favicon ausliefern (öffentlich)
api/auth/ Login, Logout, Passkeys
api/account/ Eigenes Konto (Name, E-Mail, Passwort, Toggle)
api/admin/users/ Benutzerverwaltung (nur Admin)
api/admin/branding/ Logo-/Favicon-Upload (nur Admin)
api/dishes/, api/sections/, api/allergens/, api/additives/,
api/menu-entries/, api/dish-suggestions/, api/settings/
CRUD-Routen für den Kantinen-/Admin-Bereich
api/translate/ DeepL-Anbindung (serverseitig)
api/locale/ Sprach-Cookie setzen
components/ Client-Komponenten (WeekEditor, DishManager, …)
components/i18n/ Sprachkontext, Sprachumschalter
lib/i18n/ Wörterbücher (de/en/pl), Locale-Helfer
lib/supabase/ Supabase-Clients (Browser / Server / Admin)
lib/auth/ Sessions, Passwort-Hashing, WebAuthn-Konfiguration
lib/db/ pg-Pool, Query-Helfer, gemeinsame Ladefunktionen
lib/branding.ts Lokale Datei-Ablage für Logo/Favicon
lib/ Typen, Datums-/Formatierungshelfer
proxy.ts Session-Refresh + Schutz von /intern und /admin
supabase/migrations/ Schema & Seed-Daten (Referenz/Doku)
supabase/local/ Skripte + Dumps für die lokale PostgreSQL-Kopie
scripts/ Hilfsskripte (DB-Migration, lokaler Sync)
proxy.ts Session-Prüfung + Schutz von /intern, /admin, /konto
db/migrations/ Datenbankschema (reines SQL, nummeriert)
db/migrate.mjs Wendet neue Migrationen an (idempotent, bei jedem Start)
scripts/bootstrap-admin.mjs
Legt bei leerer Datenbank automatisch einen
Admin mit Zufallspasswort an (siehe ZUGANGSDATEN.md)
```
## Entwicklung
@@ -66,25 +80,25 @@ docker compose --env-file .env.local --profile dev up dev
# http://localhost:3000 (Hot-Reload, Quellcode wird gemountet)
```
**Variante B – lokal installiertes Node.js:**
**Variante B – lokal installiertes Node.js + PostgreSQL:**
```bash
npm install
npm run dev # http://localhost:3000 (bindet an 0.0.0.0, auch im LAN erreichbar)
```
## Umgebungsvariablen (Live-Betrieb)
In beiden Varianten wird die Datenbank beim Start automatisch auf den
neuesten Stand migriert und – falls noch kein Benutzer existiert – ein
Admin-Konto mit Zufallspasswort angelegt (siehe [Zugangsdaten](#zugangsdaten)).
Beim Webserver/Hoster müssen genau diese drei Variablen hinterlegt werden
(Werte: Supabase-Dashboard → Project Settings → API Keys):
## Umgebungsvariablen (Live-Betrieb)
| Variable | Bedeutung | Geheim? |
| --- | --- | --- |
| `NEXT_PUBLIC_SUPABASE_URL` | URL des Supabase-Projekts, z. B. `https://hvzikwjoyhoakedlywhj.supabase.co` | nein |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Öffentlicher anon-Key (RLS schützt die Daten) | nein |
| `SUPABASE_SERVICE_ROLE_KEY` | Service-Role-Key für die Benutzerverwaltung | **ja – niemals im Browser/Repo!** |
| `APP_URL` | Öffentliche URL der App, z. B. `https://menu.example.de` (Origin-Check hinter Reverse-Proxy; zugleich Passkey-Domain) | nein |
| `DATABASE_URL` | Verbindung zur PostgreSQL-Datenbank, z. B. `postgres://postgres:<passwort>@db:5432/canteen_menu` | **ja** |
| `APP_URL` | Öffentliche URL der App, z. B. `https://menu.example.de` (Origin-Check hinter Reverse-Proxy; zugleich Passkey-RP-Domain – wird direkt daraus abgeleitet, kein separater Konfigurationsschritt nötig) | nein |
| `SITE_TITLE` | Titel im Browser-Tab und Überschrift ohne Logo, Default `Canteen Menu` | nein |
| `ADMIN_EMAIL` | Optional: E-Mail des automatisch angelegten Admin-Erstnutzers (nur relevant, solange die Datenbank leer ist). Ohne Angabe: `admin@<Hostname aus APP_URL>` | nein |
| `DEEPL_API_KEY` | Optional: aktiviert „Automatisch übersetzen“ für Gerichte/Allergene/Zusatzstoffe (DeepL, kostenloser Free-Tier). Ohne Key funktioniert alles weiter, nur automatisch übersetzen ist deaktiviert | **ja – niemals im Browser/Repo!** |
Build & Start auf dem Server – **mit Docker** (empfohlen, Node.js und
@@ -93,10 +107,15 @@ PostgreSQL müssen nicht installiert sein):
```bash
docker compose --env-file .env.local up -d --build
# App: Port 3000 (hinter Reverse-Proxy mit HTTPS betreiben)
# Lokale DB-Kopie: Port 5433 (Dienst "db", optional)
# PostgreSQL: Port 5433
```
Oder klassisch ohne Docker:
Vor dem ersten Start: eine leere `ZUGANGSDATEN.md` im Projektverzeichnis
anlegen (`touch ZUGANGSDATEN.md`), da diese Datei per Bind-Mount in den
Container eingebunden wird und die automatisch generierten Admin-
Zugangsdaten dort landen.
Oder klassisch ohne Docker (mit lokal erreichbarem PostgreSQL):
```bash
npm ci
@@ -104,71 +123,39 @@ npm run build
npm run start # Port 3000, bindet an 0.0.0.0 (hinter Reverse-Proxy betreiben)
```
### Passkeys für die Produktions-Domain freischalten
Passkeys sind an die Domain gebunden. Aktuell ist `localhost` konfiguriert.
Vor dem Livegang: `APP_URL` auf die öffentliche URL setzen und einmalig
```bash
SUPABASE_ACCESS_TOKEN=<personal-access-token> node scripts/configure-passkeys.mjs
```
ausführen (liest `APP_URL` aus der Umgebung; die URL kann alternativ als
Argument übergeben werden). Ohne lokales Node.js geht es auch über Docker:
```bash
docker run --rm -v .:/app -w /app \
-e SUPABASE_ACCESS_TOKEN=<token> -e APP_URL=https://menu.example.de \
node:24-alpine node scripts/configure-passkeys.mjs
```
Achtung: Bereits registrierte Passkeys der alten Domain verlieren dabei
ihre Gültigkeit und müssen neu registriert werden.
Passkeys sind an die Domain gebunden (RP-ID = Hostname aus `APP_URL`).
Vor dem Livegang `APP_URL` auf die öffentliche URL setzen – ein
zusätzlicher Konfigurationsschritt ist nicht nötig. Bereits registrierte
Passkeys einer vorherigen Domain verlieren dabei ihre Gültigkeit und
müssen neu registriert werden.
## Datenbank
### Supabase (führende Datenbank)
Reines PostgreSQL (kein Supabase, keine Row-Level-Security-Policies) –
alle Zugriffsrechte werden in den Route-Handlern geprüft (`lib/auth/session.ts`,
`requireRole`).
- Projekt-Ref: `hvzikwjoyhoakedlywhj` (Region `eu-central-1`, Frankfurt)
- Schema und Seed-Daten: `supabase/migrations/0001_init.sql` und `0002_seed.sql`
- Ausführen weiterer SQL-Dateien:
`SUPABASE_ACCESS_TOKEN=<token> node scripts/db-apply.mjs <datei.sql>`
- Schema: `db/migrations/*.sql`, nummeriert, wird von `db/migrate.mjs`
automatisch angewendet (idempotent, bei jedem Start des Servers bzw.
Docker-Containers).
- Neue Migration hinzufügen: `db/migrations/000N_beschreibung.sql`
anlegen, wird beim nächsten Start automatisch eingespielt.
- Manuell migrieren: `npm run db:migrate`
Sicherheitsmodell (Row Level Security):
Rechte-Modell (in Anwendungscode statt Datenbank-RLS):
- `anon` darf ausschließlich lesen (Speisekarte, Bereiche, Allergene,
Zusatzstoffe, Einstellungen).
- `staff`/`admin` (Rolle im `profiles`-Eintrag) pflegen Gerichte,
Wochenplan und Kennzeichnungen.
- Nur `admin` ändert Einstellungen (Farben/Logo) und verwaltet Benutzer;
das Anlegen/Löschen von Benutzern läuft serverseitig über den
Service-Role-Key (`app/api/admin/users`).
- Storage-Bucket `branding` (Logo): öffentlich lesbar, Schreibzugriff nur Admin.
### Lokale PostgreSQL-Kopie
Die Datenbank `canteen_menu` (Kopie von Schema + Daten aus Supabase, inkl.
Minimal-Stubs für `auth`/`storage`) gibt es in zwei Varianten:
**Per Docker:** Der Dienst `db` aus `docker-compose.yml` spielt die
Skripte aus `supabase/local/` beim ersten Start automatisch ein
(PostgreSQL 17, Host-Port 5433 – 5432 ist meist durch eine native
PostgreSQL-Installation belegt –, Passwort über `LOCAL_PG_PASSWORD`).
**Nativ:** Auf dem Entwicklungsrechner läuft PostgreSQL 17 mit derselben
Datenbank. Neu aufbauen bzw. aktualisieren:
```powershell
$env:SUPABASE_DB_PASSWORD = "<Supabase-DB-Passwort>"
$env:LOCAL_PG_PASSWORD = "<lokales postgres-Passwort>"
powershell -File scripts\sync-local-db.ps1
```
Hinweis: Die lokale Kopie dient als Ablage/Backup und für Auswertungen.
Logins (Passwort-Hashes, Passkeys) verbleiben ausschließlich in Supabase.
- Öffentlich lesbar (kein Login nötig): Speisekarte, Essenszeiten,
Allergene, Zusatzstoffe, Logo/Favicon.
- `staff`/`admin` pflegen Gerichte, Essenszeiten, Wochenplan und
Kennzeichnungen.
- Nur `admin` ändert Einstellungen (Farben/Logo) und verwaltet Benutzer.
- Logo/Favicon liegen als Dateien in `BRANDING_DIR` (Docker-Volume
`branding_data`), öffentlich ausgeliefert über `/branding/[filename]`.
## Zugangsdaten
Die Anmeldedaten (Admin, Demo-Mitarbeiter, Datenbank-Passwörter) stehen in
`ZUGANGSDATEN.md` – diese Datei ist bewusst **nicht** im Repository
(`.gitignore`). Nach dem ersten Login bitte die Startpasswörter ändern.
Beim allerersten Start mit leerer Datenbank legt `scripts/bootstrap-admin.mjs`
automatisch einen Admin-Benutzer mit zufällig generiertem Passwort an und
schreibt die Zugangsdaten nach `ZUGANGSDATEN.md` – diese Datei ist bewusst
**nicht** im Repository (`.gitignore`). Nach dem ersten Login bitte das
Startpasswort ändern.
+5 -14
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { getSession } from "@/lib/auth/session";
import AppHeader from "@/components/AppHeader";
export default async function AdminLayout({
@@ -7,24 +7,15 @@ export default async function AdminLayout({
}: {
children: React.ReactNode;
}) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: profile } = await supabase
.from("profiles")
.select("role, full_name")
.eq("id", user.id)
.single();
if (profile?.role !== "admin") redirect("/intern");
const session = await getSession();
if (!session) redirect("/login");
if (session.user.role !== "admin") redirect("/intern");
return (
<div className="flex-1 flex flex-col">
<AppHeader
role="admin"
displayName={profile.full_name || user.email || ""}
displayName={session.user.full_name || session.user.email}
/>
<main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
{children}
+4 -2
View File
@@ -1,5 +1,7 @@
import { getSession } from "@/lib/auth/session";
import UserManager from "@/components/UserManager";
export default function AdminPage() {
return <UserManager />;
export default async function AdminPage() {
const session = await getSession();
return <UserManager meId={session?.user.id ?? ""} />;
}
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
// Ändert die E-Mail-Adresse direkt nach Passwort-Bestätigung – ohne
// Bestätigungsmail, da kein SMTP-Server betrieben wird.
export async function PATCH(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const { email, password } = (await request.json().catch(() => ({}))) as {
email?: string;
password?: string;
};
if (!email || !password) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const user = await queryOne<{ password_hash: string }>(
"select password_hash from users where id = $1",
[session.user.id]
);
if (!user || !verifyPassword(password, user.password_hash)) {
return NextResponse.json({ error: "wrong_password" }, { status: 401 });
}
try {
await query("update users set email = $1 where id = $2", [
email.trim().toLowerCase(),
session.user.id,
]);
} catch (err) {
if (err instanceof Error && /unique/i.test(err.message)) {
return NextResponse.json({ error: "email_used" }, { status: 409 });
}
throw err;
}
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { hashPassword, verifyPassword } from "@/lib/auth/password";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function PATCH(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const { currentPassword, newPassword } = (await request.json().catch(() => ({}))) as {
currentPassword?: string;
newPassword?: string;
};
if (!currentPassword || !newPassword || newPassword.length < 8) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const user = await queryOne<{ password_hash: string }>(
"select password_hash from users where id = $1",
[session.user.id]
);
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
return NextResponse.json({ error: "wrong_password" }, { status: 401 });
}
await query("update users set password_hash = $1 where id = $2", [
hashPassword(newPassword),
session.user.id,
]);
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
// Nimmt bewusst NUR full_name/password_auth_enabled aus dem Body an –
// ersetzt den früheren Postgres-Column-Grant, der self-service-Updates
// auf "role"/"email" verhindert hat. Ein generisches "update the whole
// body"-Endpoint würde diese Absicherung wieder aufheben.
export async function PATCH(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const body = (await request.json().catch(() => ({}))) as {
full_name?: string;
password_auth_enabled?: boolean;
};
if (body.password_auth_enabled === false) {
const passkeys = await queryOne<{ count: string }>(
"select count(*)::int as count from passkey_credentials where user_id = $1",
[session.user.id]
);
if (!passkeys || Number(passkeys.count) === 0) {
return NextResponse.json({ error: "needs_passkey" }, { status: 400 });
}
}
if (body.full_name !== undefined) {
const name = body.full_name.trim();
if (!name) return NextResponse.json({ error: "name_required" }, { status: 400 });
await query("update users set full_name = $1 where id = $2", [name, session.user.id]);
}
if (body.password_auth_enabled !== undefined) {
await query("update users set password_auth_enabled = $1 where id = $2", [
body.password_auth_enabled,
session.user.id,
]);
}
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { deleteCode, updateCode } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { code } = await params;
const body = await request.json();
const item = await updateCode("additives", code, body);
if (!item) return NextResponse.json({ error: "not_found" }, { status: 404 });
return NextResponse.json(item);
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
if (err instanceof Error && /duplicate key/i.test(err.message)) {
return NextResponse.json({ error: "duplicate_code" }, { status: 409 });
}
throw err;
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { code } = await params;
await deleteCode("additives", code);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { createCode, listCodes } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function GET() {
return NextResponse.json(await listCodes("additives"));
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const body = await request.json();
const item = await createCode("additives", body);
return NextResponse.json(item, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
if (err instanceof Error && /duplicate key/i.test(err.message)) {
return NextResponse.json({ error: "duplicate_code" }, { status: 409 });
}
throw err;
}
}
+92
View File
@@ -0,0 +1,92 @@
import { NextResponse } from "next/server";
import { queryOne, query } from "@/lib/db";
import { removeBrandingFile, saveBrandingFile } from "@/lib/branding";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
type AssetKind = "logo" | "favicon";
const ASSET_COLUMN: Record<AssetKind, "logo_path" | "favicon_path"> = {
logo: "logo_path",
favicon: "favicon_path",
};
function isAssetKind(value: unknown): value is AssetKind {
return value === "logo" || value === "favicon";
}
// Whitelist statt "image/*": svg absichtlich ausgeschlossen. Eine SVG
// mit eingebettetem <script> würde als image/svg+xml auf der eigenen
// Origin ausgeliefert und bei direktem Aufruf der Datei-URL ausgeführt
// (inkl. Session-Cookie) – anders als Rasterformate. Die Dateiendung
// wird aus dem geprüften MIME-Typ abgeleitet statt aus dem vom Client
// gesendeten Dateinamen, damit der gespeicherte Dateiname nicht vom
// Client bestimmt wird.
const ALLOWED_IMAGE_TYPES: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
"image/x-icon": "ico",
"image/vnd.microsoft.icon": "ico",
};
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["admin"]);
const form = await request.formData();
const kind = form.get("kind");
const file = form.get("file");
if (!isAssetKind(kind) || !(file instanceof File)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const ext = ALLOWED_IMAGE_TYPES[file.type];
if (!ext) {
return NextResponse.json({ error: "invalid_image" }, { status: 400 });
}
const old = await queryOne<{ path: string | null }>(
`select ${ASSET_COLUMN[kind]} as path from settings where id = 1`
);
const filename = `${kind}-${Date.now()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
await saveBrandingFile(filename, buffer);
await query(`update settings set ${ASSET_COLUMN[kind]} = $1 where id = 1`, [filename]);
if (old?.path) await removeBrandingFile(old.path);
return NextResponse.json({ path: filename });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
export async function DELETE(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["admin"]);
const { kind } = (await request.json()) as { kind?: unknown };
if (!isAssetKind(kind)) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
const old = await queryOne<{ path: string | null }>(
`select ${ASSET_COLUMN[kind]} as path from settings where id = 1`
);
await query(`update settings set ${ASSET_COLUMN[kind]} = null where id = 1`);
if (old?.path) await removeBrandingFile(old.path);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+84 -50
View File
@@ -1,33 +1,48 @@
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { query, queryOne } from "@/lib/db";
import { hashPassword } from "@/lib/auth/password";
import { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Profile } from "@/lib/types";
// Benutzerverwaltung (nur Admin). Läuft serverseitig mit dem
// Service-Role-Key, da das Anlegen/Löschen von Auth-Benutzern
// nicht über den anon key möglich ist.
// Benutzerverwaltung (nur Admin).
async function requireAdmin(request: Request) {
if (!isSameOrigin(request)) return null;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return null;
if (!isSameOrigin(request)) throw new ForbiddenError();
return requireRole(["admin"]);
}
const admin = createAdminClient();
const { data: profile } = await admin
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
if (profile?.role !== "admin") return null;
return { user, admin };
function errorResponse(err: unknown) {
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
return null;
}
export async function GET(request: Request) {
try {
await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const profiles = await query<Profile>(
"select id, email, full_name, role, password_auth_enabled, created_at from users order by created_at"
);
return NextResponse.json(profiles);
}
export async function POST(request: Request) {
const ctx = await requireAdmin(request);
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
let session;
try {
session = await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
void session;
const body = await request.json().catch(() => null);
const { email, password, full_name, role } = body ?? {};
@@ -41,28 +56,33 @@ export async function POST(request: Request) {
);
}
const { data, error } = await ctx.admin.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { full_name: full_name ?? "", role },
});
if (error) {
try {
const user = await queryOne<{ id: string }>(
`insert into users (email, password_hash, full_name, role)
values ($1, $2, $3, $4) returning id`,
[String(email).trim().toLowerCase(), hashPassword(password), full_name ?? "", role]
);
return NextResponse.json({ id: user?.id });
} catch (err) {
if (err instanceof Error && /unique/i.test(err.message)) {
return NextResponse.json(
{
error: /already been registered/i.test(error.message)
? "Diese E-Mail-Adresse ist bereits registriert."
: error.message,
},
{ error: "Diese E-Mail-Adresse ist bereits registriert." },
{ status: 400 }
);
}
return NextResponse.json({ id: data.user?.id });
throw err;
}
}
export async function PATCH(request: Request) {
const ctx = await requireAdmin(request);
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
let session;
try {
session = await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const body = await request.json().catch(() => null);
const { id, full_name, role, password } = body ?? {};
@@ -71,7 +91,7 @@ export async function PATCH(request: Request) {
}
// Der Admin darf sich nicht selbst die Admin-Rolle entziehen,
// sonst sperrt er sich aus der Verwaltung aus.
if (id === ctx.user.id && role === "staff") {
if (id === session.user.id && role === "staff") {
return NextResponse.json(
{ error: "Du kannst dir nicht selbst die Admin-Rolle entziehen." },
{ status: 400 }
@@ -85,35 +105,49 @@ export async function PATCH(request: Request) {
{ status: 400 }
);
}
const { error } = await ctx.admin.auth.admin.updateUserById(id, { password });
if (error) return NextResponse.json({ error: error.message }, { status: 400 });
await query("update users set password_hash = $1 where id = $2", [
hashPassword(password),
id,
]);
}
const updates: Record<string, string> = {};
if (typeof full_name === "string") updates.full_name = full_name;
if (role) updates.role = role;
if (Object.keys(updates).length > 0) {
const { error } = await ctx.admin.from("profiles").update(updates).eq("id", id);
if (error) return NextResponse.json({ error: error.message }, { status: 400 });
const fields: string[] = [];
const values: unknown[] = [];
if (typeof full_name === "string") {
values.push(full_name);
fields.push(`full_name = $${values.length}`);
}
if (role) {
values.push(role);
fields.push(`role = $${values.length}`);
}
if (fields.length > 0) {
values.push(id);
await query(`update users set ${fields.join(", ")} where id = $${values.length}`, values);
}
return NextResponse.json({ ok: true });
}
export async function DELETE(request: Request) {
const ctx = await requireAdmin(request);
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
let session;
try {
session = await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const body = await request.json().catch(() => null);
const { id } = body ?? {};
if (!id) return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
if (id === ctx.user.id) {
if (id === session.user.id) {
return NextResponse.json(
{ error: "Du kannst dich nicht selbst löschen." },
{ status: 400 }
);
}
const { error } = await ctx.admin.auth.admin.deleteUser(id);
if (error) return NextResponse.json({ error: error.message }, { status: 400 });
await query("delete from users where id = $1", [id]);
return NextResponse.json({ ok: true });
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { deleteCode, updateCode } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { code } = await params;
const body = await request.json();
const item = await updateCode("allergens", code, body);
if (!item) return NextResponse.json({ error: "not_found" }, { status: 404 });
return NextResponse.json(item);
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
if (err instanceof Error && /duplicate key/i.test(err.message)) {
return NextResponse.json({ error: "duplicate_code" }, { status: 409 });
}
throw err;
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ code: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { code } = await params;
await deleteCode("allergens", code);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { createCode, listCodes } from "@/lib/db/code-lists";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function GET() {
return NextResponse.json(await listCodes("allergens"));
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const body = await request.json();
const item = await createCode("allergens", body);
return NextResponse.json(item, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
if (err instanceof Error && /duplicate key/i.test(err.message)) {
return NextResponse.json({ error: "duplicate_code" }, { status: 409 });
}
throw err;
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { queryOne } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Role } from "@/lib/types";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const { email, password } = (await request.json().catch(() => ({}))) as {
email?: string;
password?: string;
};
if (!email || !password) {
return NextResponse.json({ error: "invalid_credentials" }, { status: 400 });
}
const user = await queryOne<{
id: string;
password_hash: string;
role: Role;
password_auth_enabled: boolean;
}>(
"select id, password_hash, role, password_auth_enabled from users where email = $1",
[email.trim().toLowerCase()]
);
if (!user || !verifyPassword(password, user.password_hash)) {
return NextResponse.json({ error: "invalid_credentials" }, { status: 401 });
}
if (!user.password_auth_enabled) {
return NextResponse.json({ error: "password_disabled" }, { status: 403 });
}
await createSession(user.id);
return NextResponse.json({ role: user.role });
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const { id } = await params;
// user_id-Filter stellt sicher, dass nur eigene Credentials gelöscht
// werden können.
await query("delete from passkey_credentials where id = $1 and user_id = $2", [
id,
session.user.id,
]);
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { rpID } from "@/lib/auth/webauthn-config";
import { storeChallenge } from "@/lib/auth/webauthn-challenges";
// Passwortloser "usernameless"-Login: kein allowCredentials, der Browser
// fragt den Nutzer, welchen registrierten Passkey er verwenden möchte.
// user_id ist beim Speichern der Challenge noch unbekannt.
export async function POST() {
const options = await generateAuthenticationOptions({
rpID: rpID(),
userVerification: "preferred",
});
await storeChallenge(options.challenge, "authentication", null);
return NextResponse.json(options);
}
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
} from "@simplewebauthn/server";
import { query, queryOne } from "@/lib/db";
import { createSession } from "@/lib/auth/session";
import { origin, rpID } from "@/lib/auth/webauthn-config";
import { consumeChallenge } from "@/lib/auth/webauthn-challenges";
import { isSameOrigin } from "@/lib/origin";
import type { Role } from "@/lib/types";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const body = (await request.json()) as { response: AuthenticationResponseJSON };
const challenge = extractChallengeFromClientData(body.response);
const pending = await consumeChallenge(challenge, "authentication");
if (!pending) {
return NextResponse.json({ error: "challenge_expired" }, { status: 400 });
}
// Passkey-Login ist unabhängig von password_auth_enabled – dieses Flag
// betrifft ausschließlich den Passwort-Login.
const stored = await queryOne<{
user_id: string;
public_key: Buffer;
counter: string;
transports: AuthenticatorTransportFuture[];
role: Role;
}>(
`select pc.user_id, pc.public_key, pc.counter, pc.transports, u.role
from passkey_credentials pc join users u on u.id = pc.user_id
where pc.credential_id = $1`,
[body.response.id]
);
if (!stored) {
return NextResponse.json({ error: "unknown_credential" }, { status: 400 });
}
const verification = await verifyAuthenticationResponse({
response: body.response,
expectedChallenge: challenge,
expectedOrigin: origin(),
expectedRPID: rpID(),
credential: {
id: body.response.id,
publicKey: new Uint8Array(stored.public_key),
counter: Number(stored.counter),
transports: stored.transports,
},
});
if (!verification.verified) {
return NextResponse.json({ error: "verification_failed" }, { status: 400 });
}
await query(
"update passkey_credentials set counter = $1, last_used_at = now() where credential_id = $2",
[verification.authenticationInfo.newCounter, body.response.id]
);
await createSession(stored.user_id);
return NextResponse.json({ role: stored.role });
}
function extractChallengeFromClientData(response: AuthenticationResponseJSON): string {
const json = JSON.parse(
Buffer.from(response.response.clientDataJSON, "base64url").toString("utf8")
) as { challenge: string };
return json.challenge;
}
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
import { query } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { rpID, rpName } from "@/lib/auth/webauthn-config";
import { storeChallenge } from "@/lib/auth/webauthn-challenges";
export async function POST() {
try {
const session = await requireSession();
const existing = await query<{
credential_id: string;
transports: AuthenticatorTransportFuture[];
}>("select credential_id, transports from passkey_credentials where user_id = $1", [
session.user.id,
]);
const options = await generateRegistrationOptions({
rpName: rpName(),
rpID: rpID(),
userID: new TextEncoder().encode(session.user.id),
userName: session.user.email,
userDisplayName: session.user.full_name || session.user.email,
attestationType: "none",
excludeCredentials: existing.map((c) => ({
id: c.credential_id,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
await storeChallenge(options.challenge, "registration", session.user.id);
return NextResponse.json(options);
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
import { query } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
import { origin, rpID } from "@/lib/auth/webauthn-config";
import { consumeChallenge } from "@/lib/auth/webauthn-challenges";
import { isSameOrigin } from "@/lib/origin";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
const session = await requireSession();
const body = (await request.json()) as {
response: RegistrationResponseJSON;
name?: string;
};
const challenge = extractChallengeFromClientData(body.response);
const pending = await consumeChallenge(challenge, "registration");
if (!pending || pending.userId !== session.user.id) {
return NextResponse.json({ error: "challenge_expired" }, { status: 400 });
}
const verification = await verifyRegistrationResponse({
response: body.response,
expectedChallenge: challenge,
expectedOrigin: origin(),
expectedRPID: rpID(),
});
if (!verification.verified || !verification.registrationInfo) {
return NextResponse.json({ error: "verification_failed" }, { status: 400 });
}
const { credential } = verification.registrationInfo;
await query(
`insert into passkey_credentials (user_id, credential_id, public_key, counter, transports, name)
values ($1, $2, $3, $4, $5, $6)`,
[
session.user.id,
credential.id,
Buffer.from(credential.publicKey),
credential.counter,
credential.transports ?? [],
body.name ?? null,
]
);
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
// clientDataJSON ist base64url-kodiertes JSON mit dem Feld "challenge" –
// darüber wird die zugehörige, serverseitig gespeicherte Challenge
// gefunden, ohne sie zusätzlich im Request-Body mitschicken zu müssen.
function extractChallengeFromClientData(response: RegistrationResponseJSON): string {
const json = JSON.parse(
Buffer.from(response.response.clientDataJSON, "base64url").toString("utf8")
) as { challenge: string };
return json.challenge;
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
export async function GET() {
try {
const session = await requireSession();
const rows = await query<{
id: string;
name: string | null;
created_at: string;
last_used_at: string | null;
}>(
`select id, name, created_at, last_used_at from passkey_credentials
where user_id = $1 order by created_at`,
[session.user.id]
);
return NextResponse.json(rows);
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
throw err;
}
}
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { withTransaction } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { id } = await params;
const created = await withTransaction(async (client) => {
const suggestion = await client.query<{ name: string }>(
"select name from dish_suggestions where id = $1",
[id]
);
if (suggestion.rows.length === 0) return null;
await client.query("insert into dishes (name, price) values ($1, 0)", [
suggestion.rows[0].name,
]);
await client.query("delete from dish_suggestions where id = $1", [id]);
return true;
});
if (!created) return NextResponse.json({ error: "not_found" }, { status: 404 });
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { id } = await params;
await query("delete from dish_suggestions where id = $1", [id]);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { DishSuggestion } from "@/lib/types";
export async function GET() {
try {
await requireRole(["staff", "admin"]);
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
const items = await query<DishSuggestion>(
"select * from dish_suggestions order by created_at desc"
);
return NextResponse.json(items);
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const body = (await request.json().catch(() => ({}))) as {
name?: string;
description?: string;
};
const name = body.name?.trim();
if (!name) return NextResponse.json({ error: "name_required" }, { status: 400 });
// Serverseitig statt clientseitig (wie zuvor über den anon-Postgrest-
// Zugriff): es gibt keinen Browser→Postgres-Pfad mehr.
const existing = await queryOne<{ id: string }>(
"select id from dishes where name ilike $1 limit 1",
[name]
);
if (existing) return NextResponse.json({ error: "duplicate" }, { status: 409 });
await query("insert into dish_suggestions (name, description) values ($1, $2)", [
name,
body.description?.trim() || null,
]);
return NextResponse.json({ ok: true }, { status: 201 });
}
+57
View File
@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Dish } from "@/lib/types";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { id } = await params;
const body = (await request.json()) as {
name: string;
name_en: string | null;
name_pl: string | null;
price: number;
allergens: string[];
additives: string[];
};
const dish = await queryOne<Dish>(
`update dishes set name = $1, name_en = $2, name_pl = $3, price = $4,
allergens = $5, additives = $6
where id = $7 returning *`,
[body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives, id]
);
if (!dish) return NextResponse.json({ error: "not_found" }, { status: 404 });
return NextResponse.json(dish);
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { id } = await params;
await query("delete from dishes where id = $1", [id]);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { ids } = (await request.json()) as { ids: string[] };
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
await query("delete from dishes where id = any($1::uuid[])", [ids]);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Dish } from "@/lib/types";
export async function GET() {
const dishes = await query<Dish>("select * from dishes order by name");
return NextResponse.json(dishes);
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const body = (await request.json()) as {
name: string;
name_en: string | null;
name_pl: string | null;
price: number;
allergens: string[];
additives: string[];
};
const dish = await queryOne<Dish>(
`insert into dishes (name, name_en, name_pl, price, allergens, additives)
values ($1, $2, $3, $4, $5, $6) returning *`,
[body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives]
);
return NextResponse.json(dish, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { id } = await params;
await query("delete from menu_entries where id = $1", [id]);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { queryOne } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const from = searchParams.get("from");
const to = searchParams.get("to");
if (!from || !to) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
return NextResponse.json(await loadMenuEntries(from, to));
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const body = (await request.json()) as {
menu_date: string;
section_key: string;
dish_id: string;
};
const entry = await queryOne<{ id: string }>(
"insert into menu_entries (menu_date, section_key, dish_id) values ($1, $2, $3) returning id",
[body.menu_date, body.section_key, body.dish_id]
);
return NextResponse.json(entry, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
if (err instanceof Error && /duplicate key/i.test(err.message)) {
return NextResponse.json({ error: "duplicate_entry" }, { status: 409 });
}
throw err;
}
}
+62
View File
@@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Section } from "@/lib/types";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { key } = await params;
const body = (await request.json()) as Partial<
Pick<Section, "label" | "label_en" | "label_pl" | "time_range" | "sort">
>;
const fields: string[] = [];
const values: unknown[] = [];
for (const col of ["label", "label_en", "label_pl", "time_range", "sort"] as const) {
if (body[col] !== undefined) {
values.push(body[col]);
fields.push(`${col} = $${values.length}`);
}
}
if (fields.length === 0) return NextResponse.json({ error: "empty_update" }, { status: 400 });
values.push(key);
const section = await queryOne<Section>(
`update sections set ${fields.join(", ")} where key = $${values.length} returning *`,
values
);
if (!section) return NextResponse.json({ error: "not_found" }, { status: 404 });
return NextResponse.json(section);
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ key: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { key } = await params;
await query("delete from sections where key = $1", [key]);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { query, queryOne } from "@/lib/db";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Section } from "@/lib/types";
export async function GET() {
const sections = await query<Section>("select * from sections order by sort");
return NextResponse.json(sections);
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const body = (await request.json()) as {
label: string;
label_en: string | null;
label_pl: string | null;
time_range: string;
};
const maxRow = await queryOne<{ sort: number }>(
"select sort from sections order by sort desc limit 1"
);
const section = await queryOne<Section>(
`insert into sections (label, label_en, label_pl, time_range, sort)
values ($1, $2, $3, $4, $5) returning *`,
[body.label, body.label_en, body.label_pl, body.time_range, (maxRow?.sort ?? 0) + 1]
);
return NextResponse.json(section, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { query } from "@/lib/db";
import { getSettings } from "@/lib/db/settings";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function GET() {
return NextResponse.json(await getSettings());
}
export async function PATCH(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["admin"]);
const { colors } = (await request.json()) as { colors: Record<string, string> };
await query("update settings set colors = $1 where id = 1", [JSON.stringify(colors)]);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+19 -20
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { query } from "@/lib/db";
import { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
// Übersetzt einen deutschen Text nach Englisch/Polnisch via DeepL und
@@ -43,21 +43,17 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
const admin = createAdminClient();
const { data: profile } = await admin
.from("profiles")
.select("role")
.eq("id", user.id)
.single();
if (!profile || !["staff", "admin"].includes(profile.role)) {
try {
await requireRole(["staff", "admin"]);
} catch (err) {
if (err instanceof UnauthorizedError) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
if (err instanceof ForbiddenError) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
throw err;
}
const body = await request.json().catch(() => null);
const { table, id, text } = body ?? {};
@@ -83,11 +79,14 @@ export async function POST(request: Request) {
if (!en || !pl) throw new Error("Keine Übersetzung erhalten.");
if (persist) {
const { error } = await admin
.from(table)
.update({ [`${config.textColumn}_en`]: en, [`${config.textColumn}_pl`]: pl })
.eq(config.idColumn, id);
if (error) throw new Error(error.message);
// table/idColumn/textColumn kommen ausschließlich aus dem oben
// geprüften ALLOWED_TABLES-Whitelist, nie aus Nutzereingaben –
// sicher für die String-Interpolation in der Query.
await query(
`update ${table} set ${config.textColumn}_en = $1, ${config.textColumn}_pl = $2
where ${config.idColumn} = $3`,
[en, pl, id]
);
}
return NextResponse.json({ configured: true, en, pl });
+36
View File
@@ -0,0 +1,36 @@
import { readBrandingFile } from "@/lib/branding";
// Kein svg: würde mit Content-Type image/svg+xml ausgeliefert und ist
// bei direkter Navigation auf die Datei aktiv ausführbar (eingebettetes
// <script> läuft dann same-origin inkl. Session-Cookie) – anders als bei
// Einbettung per <img>/<link rel="icon">. Nur ungefährliche Rasterformate
// zulassen, siehe auch die Whitelist beim Upload in api/admin/branding.
const CONTENT_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
ico: "image/x-icon",
gif: "image/gif",
};
export async function GET(
request: Request,
{ params }: { params: Promise<{ filename: string }> }
) {
const { filename } = await params;
const data = await readBrandingFile(filename);
if (!data) return new Response("Not found", { status: 404 });
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
return new Response(new Uint8Array(data), {
headers: {
"Content-Type": contentType,
// Dateinamen tragen bereits einen Date.now()-Suffix, ein neuer
// Upload bekommt also automatisch einen neuen Namen.
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
@import "tailwindcss";
/* Farb-Variablen: Defaults entsprechen r-hofmann.de; werden zur Laufzeit
aus den Einstellungen (Supabase settings.colors) überschrieben. */
aus den Einstellungen (settings.colors) überschrieben. */
:root {
--c-primary: #003388;
--c-primary-dark: #0d315c;
+5
View File
@@ -0,0 +1,5 @@
import SectionManager from "@/components/SectionManager";
export default function EssenszeitenPage() {
return <SectionManager />;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import DishManager from "@/components/DishManager";
export default function GerichtePage() {
return <DishManager />;
return <DishManager translateEnabled={!!process.env.DEEPL_API_KEY} />;
}
+3 -2
View File
@@ -1,10 +1,11 @@
import CodeListManager from "@/components/CodeListManager";
export default function KennzeichnungPage() {
const translateEnabled = !!process.env.DEEPL_API_KEY;
return (
<div className="grid gap-6 lg:grid-cols-2">
<CodeListManager table="allergens" />
<CodeListManager table="additives" />
<CodeListManager table="allergens" translateEnabled={translateEnabled} />
<CodeListManager table="additives" translateEnabled={translateEnabled} />
</div>
);
}
+5 -15
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { getSession } from "@/lib/auth/session";
import AppHeader from "@/components/AppHeader";
export default async function InternLayout({
@@ -7,24 +7,14 @@ export default async function InternLayout({
}: {
children: React.ReactNode;
}) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: profile } = await supabase
.from("profiles")
.select("role, full_name")
.eq("id", user.id)
.single();
if (!profile || !["staff", "admin"].includes(profile.role)) redirect("/");
const session = await getSession();
if (!session) redirect("/login");
return (
<div className="flex-1 flex flex-col">
<AppHeader
role={profile.role}
displayName={profile.full_name || user.email || ""}
role={session.user.role}
displayName={session.user.full_name || session.user.email}
/>
<main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
{children}
+9 -19
View File
@@ -1,34 +1,24 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { getSession } from "@/lib/auth/session";
import AppHeader from "@/components/AppHeader";
import AccountManager from "@/components/AccountManager";
export default async function KontoPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: profile } = await supabase
.from("profiles")
.select("role, full_name, password_auth_enabled")
.eq("id", user.id)
.single();
if (!profile || !["staff", "admin"].includes(profile.role)) redirect("/");
const session = await getSession();
if (!session) redirect("/login");
return (
<div className="flex-1 flex flex-col">
<AppHeader
role={profile.role}
displayName={profile.full_name || user.email || ""}
role={session.user.role}
displayName={session.user.full_name || session.user.email}
/>
<main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
<AccountManager
role={profile.role}
initialFullName={profile.full_name ?? ""}
initialEmail={user.email ?? ""}
initialPasswordAuthEnabled={profile.password_auth_enabled}
role={session.user.role}
initialFullName={session.user.full_name}
initialEmail={session.user.email}
initialPasswordAuthEnabled={session.user.password_auth_enabled}
/>
</main>
</div>
+4 -13
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import "./globals.css";
import { createClient } from "@/lib/supabase/server";
import { getSettings } from "@/lib/db/settings";
import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types";
import { getDictionary, getLocale } from "@/lib/i18n";
import { LocaleProvider } from "@/components/i18n/LocaleProvider";
@@ -16,12 +16,7 @@ const geistSans = Geist({
});
export async function generateMetadata(): Promise<Metadata> {
const supabase = await createClient();
const { data: settings } = await supabase
.from("settings")
.select("favicon_path")
.eq("id", 1)
.maybeSingle();
const settings = await getSettings();
return {
title: SITE_TITLE,
@@ -38,16 +33,12 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const supabase = await createClient();
const [{ data: settings }, locale] = await Promise.all([
supabase.from("settings").select("colors").eq("id", 1).maybeSingle(),
getLocale(),
]);
const [settings, locale] = await Promise.all([getSettings(), getLocale()]);
const dict = getDictionary(locale);
const colors: Record<string, string> = {
...DEFAULT_COLORS,
...((settings?.colors as Record<string, string>) ?? {}),
...(settings?.colors ?? {}),
};
const style = Object.fromEntries(
Object.entries(COLOR_VARS).map(([key, cssVar]) => [cssVar, colors[key]])
+32 -43
View File
@@ -3,67 +3,48 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { startAuthentication } from "@simplewebauthn/browser";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
import type { Dict } from "@/lib/i18n/shared";
function authErrorMessage(dict: Dict, message: string): string {
if (/invalid login credentials/i.test(message))
return dict.login.errorInvalidCredentials;
if (/email not confirmed/i.test(message))
return dict.login.errorEmailNotConfirmed;
if (/cancelled|aborted|NotAllowedError/i.test(message))
function authErrorMessage(dict: Dict, code: string): string {
if (code === "invalid_credentials") return dict.login.errorInvalidCredentials;
if (code === "password_disabled") return dict.login.errorPasswordDisabled;
if (/cancelled|aborted|NotAllowedError/i.test(code))
return dict.login.errorPasskeyCancelled;
return formatMessage(dict.login.errorGeneric, { message });
return formatMessage(dict.login.errorGeneric, { message: code });
}
function redirectPath(role: string): string {
return role === "admin" ? "/admin" : "/intern";
}
export default function LoginPage() {
const router = useRouter();
const supabase = createClient();
const dict = useDict();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function redirectByRole(userId: string) {
const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", userId)
.single();
router.replace(profile?.role === "admin" ? "/admin" : "/intern");
router.refresh();
}
async function signInWithPassword(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (error || !data.user) {
setError(authErrorMessage(dict, error?.message ?? "Unbekannter Fehler"));
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(authErrorMessage(dict, data.error ?? "unknown"));
setBusy(false);
return;
}
const { data: profile } = await supabase
.from("profiles")
.select("role, password_auth_enabled")
.eq("id", data.user.id)
.single();
if (profile?.password_auth_enabled === false) {
await supabase.auth.signOut();
setError(dict.login.errorPasswordDisabled);
setBusy(false);
return;
}
router.replace(profile?.role === "admin" ? "/admin" : "/intern");
router.replace(redirectPath(data.role));
router.refresh();
}
@@ -71,15 +52,23 @@ export default function LoginPage() {
setError(null);
setBusy(true);
try {
const { data, error } = await supabase.auth.signInWithPasskey();
if (error || !data?.user) {
setError(
authErrorMessage(dict, error?.message ?? "Passkey-Anmeldung fehlgeschlagen")
);
const optionsRes = await fetch("/api/auth/passkeys/login-options", { method: "POST" });
const options = await optionsRes.json();
const response = await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch("/api/auth/passkeys/login-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ response }),
});
const data = await verifyRes.json().catch(() => ({}));
if (!verifyRes.ok) {
setError(authErrorMessage(dict, data.error ?? "unknown"));
setBusy(false);
return;
}
await redirectByRole(data.user.id);
router.replace(redirectPath(data.role));
router.refresh();
} catch (err) {
setError(authErrorMessage(dict, err instanceof Error ? err.message : String(err)));
setBusy(false);
+2 -3
View File
@@ -1,13 +1,12 @@
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { destroySession } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const supabase = await createClient();
await supabase.auth.signOut();
await destroySession();
// Relative Weiterleitung: bei einer absoluten URL aus request.url landet
// sonst die Server-Bindung (0.0.0.0) statt des aufgerufenen Hosts im
// Location-Header.
+15 -23
View File
@@ -1,5 +1,7 @@
import Link from "next/link";
import { createClient } from "@/lib/supabase/server";
import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries";
import { getSettings } from "@/lib/db/settings";
import {
addDays,
dishCodes,
@@ -11,7 +13,7 @@ import {
} from "@/lib/menu";
import { getDictionary, getLocale } from "@/lib/i18n";
import { localized, logoUrl, SITE_TITLE } from "@/lib/types";
import type { CodeItem, MenuEntry, Section } from "@/lib/types";
import type { CodeItem, Section } from "@/lib/types";
import PublicMobileMenu from "@/components/PublicMobileMenu";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
@@ -31,29 +33,15 @@ export default async function Home({
iso: addDays(monday, i),
}));
const supabase = await createClient();
const [sectionsRes, entriesRes, allergensRes, additivesRes, settingsRes] =
await Promise.all([
supabase.from("sections").select("*").order("sort"),
supabase
.from("menu_entries")
.select(
"id, menu_date, section_key, position, dish:dishes(id, name, name_en, name_pl, price, allergens, additives)"
)
.gte("menu_date", monday)
.lte("menu_date", friday)
.order("position")
.order("created_at"),
supabase.from("allergens").select("*").order("sort"),
supabase.from("additives").select("*").order("sort"),
supabase.from("settings").select("logo_path").eq("id", 1).maybeSingle(),
const [sections, entries, allergens, additives, settings] = await Promise.all([
query<Section>("select * from sections order by sort"),
loadMenuEntries(monday, friday),
query<CodeItem>("select * from allergens order by sort"),
query<CodeItem>("select * from additives order by sort"),
getSettings(),
]);
const sections = (sectionsRes.data ?? []) as Section[];
const entries = (entriesRes.data ?? []) as unknown as MenuEntry[];
const allergens = (allergensRes.data ?? []) as CodeItem[];
const additives = (additivesRes.data ?? []) as CodeItem[];
const logo = logoUrl(settingsRes.data?.logo_path);
const logo = logoUrl(settings?.logo_path);
return (
<div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full">
@@ -63,7 +51,11 @@ export default async function Home({
kollidieren. */}
<header className="mb-6">
<div className="flex justify-end items-center gap-3 mb-2">
{/* Auf Mobilgeräten übernimmt PublicMobileMenu den
Sprachumschalter, hier würde er sonst doppelt erscheinen. */}
<div className="hidden sm:block">
<LanguageSwitcher variant="light" />
</div>
<Link
href="/login"
className="text-sm text-gray-500 hover:text-[var(--c-primary)]"
+72 -45
View File
@@ -1,20 +1,20 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { formatMessage } from "@/lib/i18n/shared";
import type { Dict } from "@/lib/i18n/shared";
import PasskeyManager from "@/components/PasskeyManager";
import { useDict } from "@/components/i18n/LocaleProvider";
function authErrorMessage(dict: Dict, message: string): string {
if (/invalid login credentials/i.test(message))
return dict.account.wrongCurrentPassword;
if (/should be at least/i.test(message)) return dict.account.passwordTooShort;
if (/email.*already|already.*registered/i.test(message))
return dict.account.emailAlreadyUsed;
return message;
function errorMessage(
dict: ReturnType<typeof useDict>,
code: string,
fallback: string
): string {
if (code === "wrong_password") return dict.account.wrongCurrentPassword;
if (code === "email_used") return dict.account.emailAlreadyUsed;
if (code === "needs_passkey") return dict.account.passwordAuthNeedsPasskey;
return fallback;
}
export default function AccountManager({
@@ -28,7 +28,6 @@ export default function AccountManager({
initialEmail: string;
initialPasswordAuthEnabled: boolean;
}) {
const supabase = useMemo(() => createClient(), []);
const router = useRouter();
const dict = useDict();
const roleLabel = role === "admin" ? dict.common.roleAdmin : dict.common.roleStaff;
@@ -41,6 +40,7 @@ export default function AccountManager({
// E-Mail-Adresse
const [email, setEmail] = useState(initialEmail);
const [emailPassword, setEmailPassword] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
const [emailInfo, setEmailInfo] = useState<string | null>(null);
const [emailBusy, setEmailBusy] = useState(false);
@@ -63,10 +63,10 @@ export default function AccountManager({
const [authBusy, setAuthBusy] = useState(false);
useEffect(() => {
supabase.auth.passkey
.list()
.then(({ data }) => setPasskeyCount((data ?? []).length));
}, [supabase]);
fetch("/api/auth/passkeys")
.then((res) => (res.ok ? res.json() : []))
.then((data) => setPasskeyCount(data.length));
}, []);
async function saveName(e: React.FormEvent) {
e.preventDefault();
@@ -78,13 +78,19 @@ export default function AccountManager({
return;
}
setNameBusy(true);
const { error } = await supabase
.from("profiles")
.update({ full_name: name })
.eq("id", (await supabase.auth.getUser()).data.user?.id ?? "");
if (error)
setNameError(formatMessage(dict.account.saveError, { message: error.message }));
else {
const res = await fetch("/api/account", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ full_name: name }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setNameError(
formatMessage(dict.account.saveError, {
message: errorMessage(dict, data.error, data.error ?? String(res.status)),
})
);
} else {
setNameInfo(dict.account.nameSaved);
router.refresh();
}
@@ -98,15 +104,22 @@ export default function AccountManager({
const next = email.trim();
if (!next || next === initialEmail) return;
setEmailBusy(true);
const { error } = await supabase.auth.updateUser({ email: next });
if (error) {
const res = await fetch("/api/account/email", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: next, password: emailPassword }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setEmailError(
formatMessage(dict.account.changeError, {
message: authErrorMessage(dict, error.message),
message: errorMessage(dict, data.error, data.error ?? String(res.status)),
})
);
} else {
setEmailInfo(dict.account.emailSent);
setEmailInfo(dict.account.emailChanged);
setEmailPassword("");
router.refresh();
}
setEmailBusy(false);
}
@@ -125,22 +138,16 @@ export default function AccountManager({
}
setPwBusy(true);
// Zur Verifizierung: aktuelles Passwort gegenprüfen, bevor geändert wird.
const { error: verifyError } = await supabase.auth.signInWithPassword({
email: initialEmail,
password: currentPassword,
const res = await fetch("/api/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
if (verifyError) {
setPwError(authErrorMessage(dict, verifyError.message));
setPwBusy(false);
return;
}
const { error } = await supabase.auth.updateUser({ password: newPassword });
if (error) {
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setPwError(
formatMessage(dict.account.changeError, {
message: authErrorMessage(dict, error.message),
message: errorMessage(dict, data.error, data.error ?? String(res.status)),
})
);
} else {
@@ -163,12 +170,18 @@ export default function AccountManager({
if (!next && !window.confirm(dict.account.confirmDisablePasswordAuth)) return;
setAuthBusy(true);
const { error } = await supabase
.from("profiles")
.update({ password_auth_enabled: next })
.eq("id", (await supabase.auth.getUser()).data.user?.id ?? "");
if (error) {
setAuthError(formatMessage(dict.account.saveError, { message: error.message }));
const res = await fetch("/api/account", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password_auth_enabled: next }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setAuthError(
formatMessage(dict.account.saveError, {
message: errorMessage(dict, data.error, data.error ?? String(res.status)),
})
);
} else {
setPasswordAuthEnabled(next);
setAuthInfo(
@@ -232,6 +245,20 @@ export default function AccountManager({
required
/>
</div>
<div className="max-w-80">
<label className="label" htmlFor="account-email-password">
{dict.account.currentPassword}
</label>
<input
id="account-email-password"
type="password"
className="input"
value={emailPassword}
onChange={(e) => setEmailPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
{emailError && (
<p className="text-sm text-[var(--c-del)]" role="alert">
{emailError}
+1
View File
@@ -23,6 +23,7 @@ export default function AppHeader({
const menuLinks = [
{ href: "/intern", label: dict.header.weekPlan },
{ href: "/intern/essenszeiten", label: dict.header.sections },
{ href: "/intern/gerichte", label: dict.header.dishes },
{ href: "/intern/kennzeichnung", label: dict.header.labels },
{ href: "/intern/vorschlaege", label: dict.header.suggestions },
+30 -37
View File
@@ -3,8 +3,7 @@
// Pflege der globalen Allergen-/Zusatzstofflisten, die unterhalb
// der öffentlichen Speisekarte angezeigt werden.
import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
import type { CodeItem } from "@/lib/types";
@@ -12,10 +11,11 @@ import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
export default function CodeListManager({
table,
translateEnabled,
}: {
table: "allergens" | "additives";
translateEnabled: boolean;
}) {
const supabase = useMemo(() => createClient(), []);
const dict = useDict();
const locale = useLocale();
const title =
@@ -34,15 +34,15 @@ export default function CodeListManager({
namePl: string;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [translateState, setTranslateState] = useState<
"idle" | "busy" | "done" | "unavailable"
>("idle");
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle"
);
const load = useCallback(async () => {
const { data, error } = await supabase.from(table).select("*").order("sort");
if (error) setError(formatMessage(dict.codeList.loadError, { message: error.message }));
else setItems(data as CodeItem[]);
}, [supabase, table, dict]);
const res = await fetch(`/api/${table}`);
if (!res.ok) setError(formatMessage(dict.codeList.loadError, { message: String(res.status) }));
else setItems(await res.json());
}, [table, dict]);
useEffect(() => {
load();
@@ -62,23 +62,23 @@ export default function CodeListManager({
name_pl: editing.namePl.trim() || null,
};
let dbError;
if (editing.original) {
({ error: dbError } = await supabase
.from(table)
.update(payload)
.eq("code", editing.original));
} else {
const maxSort = items.reduce((m, i) => Math.max(m, i.sort), 0);
({ error: dbError } = await supabase
.from(table)
.insert({ ...payload, sort: maxSort + 1 }));
}
if (dbError) {
const res = editing.original
? await fetch(`/api/${table}/${editing.original}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
: await fetch(`/api/${table}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
/duplicate key/i.test(dbError.message)
data.error === "duplicate_code"
? formatMessage(dict.codeList.duplicateCode, { code })
: formatMessage(dict.codeList.saveError, { message: dbError.message })
: formatMessage(dict.codeList.saveError, { message: data.error ?? String(res.status) })
);
return;
}
@@ -93,9 +93,9 @@ export default function CodeListManager({
)
)
return;
const { error } = await supabase.from(table).delete().eq("code", item.code);
if (error)
setError(formatMessage(dict.codeList.deleteError, { message: error.message }));
const res = await fetch(`/api/${table}/${item.code}`, { method: "DELETE" });
if (!res.ok)
setError(formatMessage(dict.codeList.deleteError, { message: String(res.status) }));
await load();
}
@@ -115,10 +115,6 @@ export default function CodeListManager({
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
if (data.configured === false) {
setTranslateState("unavailable");
return;
}
setEditing((prev) =>
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev
);
@@ -191,6 +187,7 @@ export default function CodeListManager({
/>
</div>
</div>
{translateEnabled && (
<div>
<button
type="button"
@@ -207,12 +204,8 @@ export default function CodeListManager({
{dict.dishManager.translated}
</span>
)}
{translateState === "unavailable" && (
<span className="ml-2 text-sm text-gray-500">
{dict.dishManager.translateUnavailable}
</span>
)}
</div>
)}
<div className="flex gap-2">
<button type="submit" className="btn btn-add">
{dict.common.save}
+34 -50
View File
@@ -1,21 +1,14 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { formatMessage } from "@/lib/i18n/shared";
import { DEFAULT_COLORS, faviconUrl, logoUrl } from "@/lib/types";
import { useDict } from "@/components/i18n/LocaleProvider";
type AssetKind = "logo" | "favicon";
const ASSET_COLUMN: Record<AssetKind, "logo_path" | "favicon_path"> = {
logo: "logo_path",
favicon: "favicon_path",
};
export default function DesignEditor() {
const supabase = useMemo(() => createClient(), []);
const router = useRouter();
const dict = useDict();
const colorLabels: Record<string, string> = {
@@ -36,19 +29,16 @@ export default function DesignEditor() {
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
const { data, error } = await supabase
.from("settings")
.select("colors, logo_path, favicon_path")
.eq("id", 1)
.maybeSingle();
if (error) {
setError(formatMessage(dict.designEditor.loadError, { message: error.message }));
const res = await fetch("/api/settings");
if (!res.ok) {
setError(formatMessage(dict.designEditor.loadError, { message: String(res.status) }));
return;
}
const data = await res.json();
setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) });
setLogoPath(data?.logo_path ?? null);
setFaviconPath(data?.favicon_path ?? null);
}, [supabase, dict]);
}, [dict]);
useEffect(() => {
load();
@@ -58,12 +48,13 @@ export default function DesignEditor() {
setBusy(true);
setError(null);
setInfo(null);
const { error } = await supabase
.from("settings")
.update({ colors })
.eq("id", 1);
if (error)
setError(formatMessage(dict.designEditor.saveError, { message: error.message }));
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ colors }),
});
if (!res.ok)
setError(formatMessage(dict.designEditor.saveError, { message: String(res.status) }));
else {
setInfo(dict.designEditor.colorsSaved);
router.refresh();
@@ -87,28 +78,21 @@ export default function DesignEditor() {
setError(null);
setInfo(null);
const oldPath = kind === "logo" ? logoPath : faviconPath;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "png";
const path = `${kind}-${Date.now()}.${ext}`;
const { error: uploadError } = await supabase.storage
.from("branding")
.upload(path, file, { contentType: file.type });
if (uploadError) {
setError(formatMessage(dict.designEditor.uploadError, { message: uploadError.message }));
const formData = new FormData();
formData.append("kind", kind);
formData.append("file", file);
const res = await fetch("/api/admin/branding", { method: "POST", body: formData });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
formatMessage(dict.designEditor.uploadError, {
message: data.error ?? String(res.status),
})
);
setBusy(false);
return;
}
const { error: settingsError } = await supabase
.from("settings")
.update({ [ASSET_COLUMN[kind]]: path })
.eq("id", 1);
if (settingsError) {
setError(formatMessage(dict.designEditor.saveError, { message: settingsError.message }));
setBusy(false);
return;
}
if (oldPath) await supabase.storage.from("branding").remove([oldPath]);
const { path } = await res.json();
if (kind === "logo") setLogoPath(path);
else setFaviconPath(path);
@@ -130,14 +114,14 @@ export default function DesignEditor() {
return;
setBusy(true);
setError(null);
const { error } = await supabase
.from("settings")
.update({ [ASSET_COLUMN[kind]]: null })
.eq("id", 1);
if (error) {
setError(formatMessage(dict.designEditor.saveError, { message: error.message }));
const res = await fetch("/api/admin/branding", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind }),
});
if (!res.ok) {
setError(formatMessage(dict.designEditor.saveError, { message: String(res.status) }));
} else {
await supabase.storage.from("branding").remove([path]);
if (kind === "logo") setLogoPath(null);
else setFaviconPath(null);
setInfo(kind === "logo" ? dict.designEditor.logoRemoved : dict.designEditor.faviconRemoved);
@@ -180,7 +164,7 @@ export default function DesignEditor() {
{dict.designEditor.logoUpload}
<input
type="file"
accept="image/*"
accept="image/png,image/jpeg,image/webp,image/gif,image/x-icon"
className="hidden"
onChange={(e) => uploadAsset("logo", e)}
disabled={busy}
@@ -217,7 +201,7 @@ export default function DesignEditor() {
{dict.designEditor.faviconUpload}
<input
type="file"
accept="image/*"
accept="image/png,image/jpeg,image/webp,image/gif,image/x-icon"
className="hidden"
onChange={(e) => uploadAsset("favicon", e)}
disabled={busy}
+43 -40
View File
@@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { dishCodes, formatPrice } from "@/lib/menu";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
@@ -29,8 +28,11 @@ const EMPTY_FORM: DishForm = {
additives: [],
};
export default function DishManager() {
const supabase = useMemo(() => createClient(), []);
export default function DishManager({
translateEnabled,
}: {
translateEnabled: boolean;
}) {
const dict = useDict();
const locale = useLocale();
const [dishes, setDishes] = useState<Dish[]>([]);
@@ -42,9 +44,9 @@ export default function DishManager() {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [search, setSearch] = useState("");
const [translateState, setTranslateState] = useState<
"idle" | "busy" | "done" | "unavailable"
>("idle");
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle"
);
// Live-Suche: filtert die Liste bereits während des Tippens
const visible = useMemo(() => {
@@ -54,20 +56,19 @@ export default function DishManager() {
const load = useCallback(async () => {
const [dishesRes, allergensRes, additivesRes] = await Promise.all([
supabase.from("dishes").select("*").order("name"),
supabase.from("allergens").select("*").order("sort"),
supabase.from("additives").select("*").order("sort"),
fetch("/api/dishes"),
fetch("/api/allergens"),
fetch("/api/additives"),
]);
const problem = dishesRes.error ?? allergensRes.error ?? additivesRes.error;
if (problem)
setError(formatMessage(dict.dishManager.loadError, { message: problem.message }));
else {
setDishes(dishesRes.data as Dish[]);
setAllergens(allergensRes.data as CodeItem[]);
setAdditives(additivesRes.data as CodeItem[]);
if (!dishesRes.ok || !allergensRes.ok || !additivesRes.ok) {
setError(formatMessage(dict.dishManager.loadError, { message: "load failed" }));
} else {
setDishes(await dishesRes.json());
setAllergens(await allergensRes.json());
setAdditives(await additivesRes.json());
}
setLoading(false);
}, [supabase, dict]);
}, [dict]);
useEffect(() => {
load();
@@ -90,11 +91,19 @@ export default function DishManager() {
allergens: form.allergens,
additives: form.additives,
};
const { error } = form.id
? await supabase.from("dishes").update(payload).eq("id", form.id)
: await supabase.from("dishes").insert(payload);
if (error) {
setError(formatMessage(dict.dishManager.saveError, { message: error.message }));
const res = form.id
? await fetch(`/api/dishes/${form.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
: await fetch("/api/dishes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
setError(formatMessage(dict.dishManager.saveError, { message: String(res.status) }));
return;
}
setForm(null);
@@ -108,9 +117,9 @@ export default function DishManager() {
)
)
return;
const { error } = await supabase.from("dishes").delete().eq("id", dish.id);
if (error)
setError(formatMessage(dict.dishManager.deleteError, { message: error.message }));
const res = await fetch(`/api/dishes/${dish.id}`, { method: "DELETE" });
if (!res.ok)
setError(formatMessage(dict.dishManager.deleteError, { message: String(res.status) }));
setSelected((prev) => {
const next = new Set(prev);
next.delete(dish.id);
@@ -152,12 +161,13 @@ export default function DishManager() {
return;
setBulkBusy(true);
setError(null);
const { error } = await supabase
.from("dishes")
.delete()
.in("id", Array.from(selected));
if (error)
setError(formatMessage(dict.dishManager.deleteError, { message: error.message }));
const res = await fetch("/api/dishes/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: Array.from(selected) }),
});
if (!res.ok)
setError(formatMessage(dict.dishManager.deleteError, { message: String(res.status) }));
setSelected(new Set());
setBulkBusy(false);
await load();
@@ -199,10 +209,6 @@ export default function DishManager() {
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
if (data.configured === false) {
setTranslateState("unavailable");
return;
}
setForm((prev) =>
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev
);
@@ -296,6 +302,7 @@ export default function DishManager() {
/>
</div>
</div>
{translateEnabled && (
<div>
<button
type="button"
@@ -312,12 +319,8 @@ export default function DishManager() {
{dict.dishManager.translated}
</span>
)}
{translateState === "unavailable" && (
<span className="ml-2 text-sm text-gray-500">
{dict.dishManager.translateUnavailable}
</span>
)}
</div>
)}
<ChipPicker
label={dict.codeList.allergensTitle}
+29 -21
View File
@@ -1,19 +1,18 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { useCallback, useEffect, useState } from "react";
import { startRegistration } from "@simplewebauthn/browser";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
interface PasskeyItem {
id: string;
friendly_name?: string;
name: string | null;
created_at: string;
last_used_at?: string;
last_used_at: string | null;
}
export default function PasskeyManager() {
const supabase = useMemo(() => createClient(), []);
const dict = useDict();
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
const [error, setError] = useState<string | null>(null);
@@ -21,11 +20,13 @@ export default function PasskeyManager() {
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
const { data, error } = await supabase.auth.passkey.list();
if (error)
setError(formatMessage(dict.passkeys.loadError, { message: error.message }));
else setPasskeys((data ?? []) as PasskeyItem[]);
}, [supabase, dict]);
const res = await fetch("/api/auth/passkeys");
if (!res.ok) {
setError(formatMessage(dict.passkeys.loadError, { message: String(res.status) }));
return;
}
setPasskeys(await res.json());
}, [dict]);
useEffect(() => {
load();
@@ -36,13 +37,22 @@ export default function PasskeyManager() {
setInfo(null);
setBusy(true);
try {
const { error } = await supabase.auth.registerPasskey();
if (error) {
setError(formatMessage(dict.passkeys.registerError, { message: error.message }));
} else {
const optionsRes = await fetch("/api/auth/passkeys/register-options", { method: "POST" });
if (!optionsRes.ok) throw new Error(String(optionsRes.status));
const options = await optionsRes.json();
const response = await startRegistration({ optionsJSON: options });
const verifyRes = await fetch("/api/auth/passkeys/register-verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ response }),
});
if (!verifyRes.ok) {
const data = await verifyRes.json().catch(() => ({}));
throw new Error(data.error ?? String(verifyRes.status));
}
setInfo(dict.passkeys.registered);
await load();
}
} catch (err) {
setError(
/NotAllowedError|abort/i.test(String(err))
@@ -57,9 +67,9 @@ export default function PasskeyManager() {
async function remove(pk: PasskeyItem) {
if (!window.confirm(dict.passkeys.confirmDelete)) return;
const { error } = await supabase.auth.passkey.delete({ passkeyId: pk.id });
if (error)
setError(formatMessage(dict.passkeys.deleteError, { message: error.message }));
const res = await fetch(`/api/auth/passkeys/${pk.id}`, { method: "DELETE" });
if (!res.ok)
setError(formatMessage(dict.passkeys.deleteError, { message: String(res.status) }));
await load();
}
@@ -88,9 +98,7 @@ export default function PasskeyManager() {
{passkeys.map((pk) => (
<li key={pk.id} className="flex items-center gap-3 py-1.5 text-sm">
<span className="mr-auto">
<span className="font-semibold">
{pk.friendly_name || "Passkey"}
</span>{" "}
<span className="font-semibold">{pk.name || "Passkey"}</span>{" "}
<span className="text-gray-500">
{formatMessage(dict.passkeys.createdOn, {
date: new Date(pk.created_at).toLocaleDateString(dict.dateLocale),
+295
View File
@@ -0,0 +1,295 @@
"use client";
// Verwaltung der Essenszeiten (Brotzeit, Mittagessen, …):
// anlegen, umbenennen, Uhrzeit ändern, löschen, Reihenfolge anpassen.
import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
import type { Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
interface SectionForm {
key: string | null; // null = neue Essenszeit
label: string;
labelEn: string;
labelPl: string;
timeFrom: string;
timeTo: string;
}
const EMPTY_FORM: SectionForm = {
key: null,
label: "",
labelEn: "",
labelPl: "",
timeFrom: "",
timeTo: "",
};
function splitTimeRange(range: string): { from: string; to: string } {
const [from, to] = range.split("-").map((s) => s.trim());
return { from: from ?? "", to: to ?? "" };
}
export default function SectionManager() {
const dict = useDict();
const locale = useLocale();
const [sections, setSections] = useState<Section[]>([]);
const [form, setForm] = useState<SectionForm | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
const res = await fetch("/api/sections");
if (!res.ok)
setError(formatMessage(dict.sectionManager.loadError, { message: String(res.status) }));
else setSections(await res.json());
setLoading(false);
}, [dict]);
useEffect(() => {
load();
}, [load]);
async function save(e: React.FormEvent) {
e.preventDefault();
if (!form) return;
setError(null);
const label = form.label.trim();
if (!label || !form.timeFrom || !form.timeTo) return;
const payload = {
label,
label_en: form.labelEn.trim() || null,
label_pl: form.labelPl.trim() || null,
time_range: `${form.timeFrom} - ${form.timeTo}`,
};
const res = form.key
? await fetch(`/api/sections/${form.key}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
: await fetch("/api/sections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
setError(formatMessage(dict.sectionManager.saveError, { message: String(res.status) }));
return;
}
setForm(null);
await load();
}
async function remove(section: Section) {
if (
!window.confirm(
formatMessage(dict.sectionManager.confirmDelete, { name: section.label })
)
)
return;
const res = await fetch(`/api/sections/${section.key}`, { method: "DELETE" });
if (!res.ok)
setError(formatMessage(dict.sectionManager.deleteError, { message: String(res.status) }));
await load();
}
async function move(section: Section, direction: -1 | 1) {
const idx = sections.findIndex((s) => s.key === section.key);
const neighbor = sections[idx + direction];
if (!neighbor) return;
setBusy(true);
setError(null);
const [r1, r2] = await Promise.all([
fetch(`/api/sections/${section.key}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sort: neighbor.sort }),
}),
fetch(`/api/sections/${neighbor.key}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sort: section.sort }),
}),
]);
if (!r1.ok || !r2.ok)
setError(formatMessage(dict.sectionManager.saveError, { message: "move failed" }));
setBusy(false);
await load();
}
return (
<div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
{dict.sectionManager.title}
</h1>
<button
type="button"
className="btn btn-add"
onClick={() => setForm(EMPTY_FORM)}
>
{dict.sectionManager.newSection}
</button>
</div>
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{form && (
<form onSubmit={save} className="card mb-4 space-y-4">
<h2 className="font-semibold">
{form.key ? dict.sectionManager.editSection : dict.sectionManager.newSection}
</h2>
<div>
<label className="label" htmlFor="section-label">
{dict.sectionManager.nameLabel}
</label>
<input
id="section-label"
className="input"
value={form.label}
onChange={(e) => setForm({ ...form, label: e.target.value })}
required
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label" htmlFor="section-label-en">
{dict.sectionManager.nameEnLabel}
</label>
<input
id="section-label-en"
className="input"
value={form.labelEn}
onChange={(e) => setForm({ ...form, labelEn: e.target.value })}
/>
</div>
<div>
<label className="label" htmlFor="section-label-pl">
{dict.sectionManager.namePlLabel}
</label>
<input
id="section-label-pl"
className="input"
value={form.labelPl}
onChange={(e) => setForm({ ...form, labelPl: e.target.value })}
/>
</div>
</div>
<div className="flex gap-3">
<div className="max-w-32">
<label className="label" htmlFor="section-time-from">
{dict.sectionManager.timeFrom}
</label>
<input
id="section-time-from"
type="time"
className="input"
value={form.timeFrom}
onChange={(e) => setForm({ ...form, timeFrom: e.target.value })}
required
/>
</div>
<div className="max-w-32">
<label className="label" htmlFor="section-time-to">
{dict.sectionManager.timeTo}
</label>
<input
id="section-time-to"
type="time"
className="input"
value={form.timeTo}
onChange={(e) => setForm({ ...form, timeTo: e.target.value })}
required
/>
</div>
</div>
<div className="flex gap-2">
<button type="submit" className="btn btn-add">
{dict.common.save}
</button>
<button
type="button"
className="btn btn-neutral"
onClick={() => setForm(null)}
>
{dict.common.cancel}
</button>
</div>
</form>
)}
{loading ? (
<p className="text-gray-500">{dict.common.loading}</p>
) : (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{sections.map((section, idx) => (
<li key={section.key} className="flex items-center gap-3 px-4 py-2">
<div className="flex flex-col">
<button
type="button"
className="text-gray-400 hover:text-[var(--c-primary)] disabled:opacity-30"
onClick={() => move(section, -1)}
disabled={busy || idx === 0}
aria-label={dict.sectionManager.moveUp}
title={dict.sectionManager.moveUp}
>
▲
</button>
<button
type="button"
className="text-gray-400 hover:text-[var(--c-primary)] disabled:opacity-30"
onClick={() => move(section, 1)}
disabled={busy || idx === sections.length - 1}
aria-label={dict.sectionManager.moveDown}
title={dict.sectionManager.moveDown}
>
▼
</button>
</div>
<span className="mr-auto">
<span className="font-semibold">{localized(section, locale)}</span>{" "}
<span className="text-gray-500">– {section.time_range}</span>
</span>
<button
type="button"
className="btn btn-primary"
onClick={() => {
const { from, to } = splitTimeRange(section.time_range);
setForm({
key: section.key,
label: section.label,
labelEn: section.label_en ?? "",
labelPl: section.label_pl ?? "",
timeFrom: from,
timeTo: to,
});
}}
>
{dict.common.edit}
</button>
<button
type="button"
className="btn btn-del"
onClick={() => remove(section)}
>
{dict.common.delete}
</button>
</li>
))}
{sections.length === 0 && (
<li className="px-4 py-3 text-gray-500">{dict.sectionManager.empty}</li>
)}
</ul>
)}
</div>
);
}
+13 -18
View File
@@ -2,13 +2,11 @@
import { useState } from "react";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
export default function SuggestionForm() {
const supabase = createClient();
const dict = useDict();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
@@ -34,23 +32,20 @@ export default function SuggestionForm() {
}
setBusy(true);
const { data: existing } = await supabase
.from("dishes")
.select("id")
.ilike("name", trimmedName)
.limit(1);
if (existing && existing.length > 0) {
setError(dict.suggestions.duplicate);
setBusy(false);
return;
}
const { error: insertError } = await supabase.from("dish_suggestions").insert({
name: trimmedName,
description: description.trim() || null,
const res = await fetch("/api/dish-suggestions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmedName, description: description.trim() || null }),
});
if (insertError) {
setError(formatMessage(dict.suggestions.genericError, { message: insertError.message }));
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
data.error === "duplicate"
? dict.suggestions.duplicate
: formatMessage(dict.suggestions.genericError, {
message: data.error ?? String(res.status),
})
);
setBusy(false);
return;
}
+12 -33
View File
@@ -1,13 +1,11 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
import type { DishSuggestion } from "@/lib/types";
export default function SuggestionReview() {
const supabase = useMemo(() => createClient(), []);
const dict = useDict();
const [items, setItems] = useState<DishSuggestion[]>([]);
const [loading, setLoading] = useState(true);
@@ -16,15 +14,12 @@ export default function SuggestionReview() {
const [busyId, setBusyId] = useState<string | null>(null);
const load = useCallback(async () => {
const { data, error } = await supabase
.from("dish_suggestions")
.select("*")
.order("created_at", { ascending: false });
if (error)
setError(formatMessage(dict.suggestions.loadError, { message: error.message }));
else setItems(data as DishSuggestion[]);
const res = await fetch("/api/dish-suggestions");
if (!res.ok)
setError(formatMessage(dict.suggestions.loadError, { message: String(res.status) }));
else setItems(await res.json());
setLoading(false);
}, [supabase, dict]);
}, [dict]);
useEffect(() => {
load();
@@ -34,25 +29,9 @@ export default function SuggestionReview() {
setBusyId(item.id);
setError(null);
setInfo(null);
const { error: insertError } = await supabase.from("dishes").insert({
name: item.name,
price: 0,
});
if (insertError) {
setError(
formatMessage(dict.suggestions.actionError, { message: insertError.message })
);
setBusyId(null);
return;
}
const { error: deleteError } = await supabase
.from("dish_suggestions")
.delete()
.eq("id", item.id);
if (deleteError) {
setError(
formatMessage(dict.suggestions.actionError, { message: deleteError.message })
);
const res = await fetch(`/api/dish-suggestions/${item.id}/accept`, { method: "POST" });
if (!res.ok) {
setError(formatMessage(dict.suggestions.actionError, { message: String(res.status) }));
setBusyId(null);
return;
}
@@ -67,9 +46,9 @@ export default function SuggestionReview() {
setBusyId(item.id);
setError(null);
setInfo(null);
const { error } = await supabase.from("dish_suggestions").delete().eq("id", item.id);
if (error) {
setError(formatMessage(dict.suggestions.actionError, { message: error.message }));
const res = await fetch(`/api/dish-suggestions/${item.id}`, { method: "DELETE" });
if (!res.ok) {
setError(formatMessage(dict.suggestions.actionError, { message: String(res.status) }));
} else {
setInfo(dict.suggestions.rejected);
}
+9 -17
View File
@@ -1,7 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import type { Profile, Role } from "@/lib/types";
import { useDict } from "@/components/i18n/LocaleProvider";
@@ -24,30 +23,23 @@ const EMPTY_FORM: UserForm = {
passwordRepeat: "",
};
export default function UserManager() {
const supabase = useMemo(() => createClient(), []);
export default function UserManager({ meId }: { meId: string }) {
const dict = useDict();
const roleLabels: Record<Role, string> = {
admin: dict.common.roleAdmin,
staff: dict.common.roleStaff,
};
const [profiles, setProfiles] = useState<Profile[]>([]);
const [me, setMe] = useState<string | null>(null);
const [form, setForm] = useState<UserForm | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
const [{ data: profileData, error: profileError }, { data: userData }] =
await Promise.all([
supabase.from("profiles").select("*").order("created_at"),
supabase.auth.getUser(),
]);
if (profileError)
setError(formatMessage(dict.userManager.loadError, { message: profileError.message }));
else setProfiles(profileData as Profile[]);
setMe(userData.user?.id ?? null);
}, [supabase, dict]);
const res = await fetch("/api/admin/users");
if (!res.ok)
setError(formatMessage(dict.userManager.loadError, { message: String(res.status) }));
else setProfiles(await res.json());
}, [dict]);
useEffect(() => {
load();
@@ -235,7 +227,7 @@ export default function UserManager() {
<span className="text-gray-500">
– {p.email} – {roleLabels[p.role]}
</span>
{p.id === me && <span className="text-gray-400"> {dict.common.you}</span>}
{p.id === meId && <span className="text-gray-400"> {dict.common.you}</span>}
</span>
<button
type="button"
@@ -253,7 +245,7 @@ export default function UserManager() {
>
{dict.common.edit}
</button>
{p.id !== me && (
{p.id !== meId && (
<button
type="button"
className="btn btn-del"
+90 -46
View File
@@ -1,8 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import {
addDays,
dishCodes,
@@ -18,7 +17,6 @@ import type { Dish, MenuEntry, Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
export default function WeekEditor({ monday }: { monday: string }) {
const supabase = useMemo(() => createClient(), []);
const dict = useDict();
const locale = useLocale();
const friday = addDays(monday, 4);
@@ -37,32 +35,36 @@ export default function WeekEditor({ monday }: { monday: string }) {
null
);
const [selectedDish, setSelectedDish] = useState("");
const [dishSearch, setDishSearch] = useState("");
function startAdding(iso: string, section: string) {
setAdding({ iso, section });
setSelectedDish("");
setDishSearch("");
}
function cancelAdding() {
setAdding(null);
setSelectedDish("");
setDishSearch("");
}
const load = useCallback(async () => {
setError(null);
const [se, di, en] = await Promise.all([
supabase.from("sections").select("*").order("sort"),
supabase.from("dishes").select("*").order("name"),
supabase
.from("menu_entries")
.select(
"id, menu_date, section_key, position, dish:dishes(id, name, name_en, name_pl, price, allergens, additives)"
)
.gte("menu_date", monday)
.lte("menu_date", friday)
.order("position")
.order("created_at"),
fetch("/api/sections"),
fetch("/api/dishes"),
fetch(`/api/menu-entries?from=${monday}&to=${friday}`),
]);
const problem = se.error ?? di.error ?? en.error;
if (problem) {
setError(formatMessage(dict.weekEditor.loadError, { message: problem.message }));
if (!se.ok || !di.ok || !en.ok) {
setError(formatMessage(dict.weekEditor.loadError, { message: "load failed" }));
} else {
setSections(se.data as Section[]);
setDishes(di.data as Dish[]);
setEntries(en.data as unknown as MenuEntry[]);
setSections(await se.json());
setDishes(await di.json());
setEntries(await en.json());
}
setLoading(false);
}, [supabase, monday, friday, dict]);
}, [monday, friday, dict]);
useEffect(() => {
setLoading(true);
@@ -71,31 +73,40 @@ export default function WeekEditor({ monday }: { monday: string }) {
async function addEntry() {
if (!adding || !selectedDish) return;
const { error } = await supabase.from("menu_entries").insert({
const res = await fetch("/api/menu-entries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
menu_date: adding.iso,
section_key: adding.section,
dish_id: selectedDish,
}),
});
if (error) {
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(
/duplicate key/i.test(error.message)
data.error === "duplicate_entry"
? dict.weekEditor.duplicateEntry
: formatMessage(dict.weekEditor.saveError, { message: error.message })
: formatMessage(dict.weekEditor.saveError, { message: data.error ?? String(res.status) })
);
}
setAdding(null);
setSelectedDish("");
cancelAdding();
await load();
}
async function removeEntry(id: string) {
const { error } = await supabase.from("menu_entries").delete().eq("id", id);
if (error)
setError(formatMessage(dict.weekEditor.deleteError, { message: error.message }));
const res = await fetch(`/api/menu-entries/${id}`, { method: "DELETE" });
if (!res.ok)
setError(formatMessage(dict.weekEditor.deleteError, { message: String(res.status) }));
await load();
}
const [hintBefore, hintAfter] = dict.weekEditor.newDishesHint.split("{link}");
const selectedDishObj = dishes.find((d) => d.id === selectedDish) ?? null;
const query = dishSearch.trim().toLowerCase();
const filteredDishes = query
? dishes.filter((d) => localized(d, locale).toLowerCase().includes(query))
: dishes;
return (
<div>
@@ -174,18 +185,57 @@ export default function WeekEditor({ monday }: { monday: string }) {
})}
{isAdding ? (
<div className="mt-2 space-y-1 text-left">
<select
{selectedDishObj ? (
<div className="flex items-center gap-1 text-sm">
<span className="mr-auto">
{localized(selectedDishObj, locale)} (
{formatPrice(selectedDishObj.price)})
</span>
<button
type="button"
className="text-gray-400 hover:text-[var(--c-primary)]"
onClick={() => {
setSelectedDish("");
setDishSearch("");
}}
aria-label={dict.weekEditor.changeDish}
title={dict.weekEditor.changeDish}
>
×
</button>
</div>
) : (
<>
<input
type="search"
className="input"
value={selectedDish}
onChange={(e) => setSelectedDish(e.target.value)}
autoFocus
placeholder={dict.dishManager.searchPlaceholder}
value={dishSearch}
onChange={(e) => setDishSearch(e.target.value)}
/>
<ul className="max-h-32 overflow-y-auto border border-gray-200 rounded-md bg-white divide-y divide-gray-100">
{filteredDishes.map((dish) => (
<li key={dish.id}>
<button
type="button"
className="w-full text-left px-2 py-1 text-sm hover:bg-[var(--c-row-bg-alt)]"
onClick={() => setSelectedDish(dish.id)}
>
<option value="">{dict.weekEditor.selectDish}</option>
{dishes.map((dish) => (
<option key={dish.id} value={dish.id}>
{localized(dish, locale)} ({formatPrice(dish.price)})
</option>
</button>
</li>
))}
</select>
{filteredDishes.length === 0 && (
<li className="px-2 py-1 text-sm text-gray-500">
{formatMessage(dict.dishManager.noResults, {
query: dishSearch,
})}
</li>
)}
</ul>
</>
)}
<div className="flex gap-1 justify-center">
<button
type="button"
@@ -198,10 +248,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
<button
type="button"
className="btn btn-neutral"
onClick={() => {
setAdding(null);
setSelectedDish("");
}}
onClick={cancelAdding}
>
{dict.common.cancel}
</button>
@@ -211,10 +258,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
<button
type="button"
className="btn btn-add mt-1"
onClick={() => {
setAdding({ iso: d.iso, section: s.key });
setSelectedDish("");
}}
onClick={() => startAdding(d.iso, s.key)}
>
{dict.weekEditor.addMeal}
</button>
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
// Wendet alle noch nicht angewendeten Dateien aus db/migrations/*.sql auf
// die per DATABASE_URL erreichbare Postgres-Datenbank an. Idempotent –
// kann bei jedem App-Start gefahrlos erneut laufen (Docker-Entrypoint,
// npm predev/prestart).
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import pg from "pg";
const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), "migrations");
async function main() {
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
try {
await client.query(`
create table if not exists schema_migrations (
filename text primary key,
applied_at timestamptz not null default now()
)
`);
const { rows } = await client.query("select filename from schema_migrations");
const applied = new Set(rows.map((r) => r.filename));
const files = readdirSync(migrationsDir)
.filter((f) => f.endsWith(".sql"))
.sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = readFileSync(join(migrationsDir, file), "utf8");
console.log(`db:migrate – wende ${file} an…`);
await client.query("begin");
try {
await client.query(sql);
await client.query("insert into schema_migrations (filename) values ($1)", [file]);
await client.query("commit");
} catch (err) {
await client.query("rollback");
throw new Error(`Migration ${file} fehlgeschlagen: ${err.message}`, { cause: err });
}
}
console.log("db:migrate – Datenbank ist aktuell.");
} finally {
await client.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+156
View File
@@ -0,0 +1,156 @@
-- =============================================================
-- Canteen Menu – Initiales Schema (selbst gehostet, ohne Supabase)
-- Rollen: admin (Benutzerverwaltung, Branding) / staff (Kantine)
-- Öffentliche Speisekarte: lesbar ohne Login
-- =============================================================
-- ---------- Benutzer & Authentifizierung ----------
create table users (
id uuid primary key default gen_random_uuid(),
email text not null unique,
password_hash text not null,
full_name text not null default '',
role text not null default 'staff' check (role in ('admin', 'staff')),
password_auth_enabled boolean not null default true,
created_at timestamptz not null default now()
);
-- Angemeldete Sitzungen. Es wird nur der Hash des Tokens gespeichert –
-- der Rohwert im Cookie ist damit auch bei einem DB-Zugriff allein nicht
-- verwendbar.
create table sessions (
token_hash text primary key,
user_id uuid not null references users (id) on delete cascade,
created_at timestamptz not null default now(),
expires_at timestamptz not null,
last_seen_at timestamptz not null default now()
);
create index sessions_user_id_idx on sessions (user_id);
-- WebAuthn-Credentials (ersetzt Supabase's internes, für die App bisher
-- unsichtbares Passkey-Schema).
create table passkey_credentials (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users (id) on delete cascade,
credential_id text not null unique,
public_key bytea not null,
counter bigint not null default 0,
transports text[] not null default '{}',
name text,
created_at timestamptz not null default now(),
last_used_at timestamptz
);
create index passkey_credentials_user_id_idx on passkey_credentials (user_id);
-- Kurzlebige Challenges für laufende WebAuthn-Zeremonien (Registrierung
-- oder Login). user_id ist beim Login-Flow anfangs null (Discoverable-
-- Credential-Ansatz: die Identität steht erst nach der Antwort fest).
create table webauthn_challenges (
id uuid primary key default gen_random_uuid(),
user_id uuid references users (id) on delete cascade,
challenge text not null,
type text not null check (type in ('registration', 'authentication')),
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
-- ---------- Essenszeiten der Speisekarte ----------
create table sections (
key text primary key default (gen_random_uuid())::text,
label text not null,
label_en text,
label_pl text,
time_range text not null,
sort int not null
);
-- ---------- Allergene & Zusatzstoffe ----------
create table allergens (
code text primary key,
name text not null,
name_en text,
name_pl text,
sort int not null default 0
);
create table additives (
code text primary key,
name text not null,
name_en text,
name_pl text,
sort int not null default 0
);
-- ---------- Gerichte ----------
create table dishes (
id uuid primary key default gen_random_uuid(),
name text not null,
name_en text,
name_pl text,
price numeric(6, 2) not null default 0,
allergens text[] not null default '{}',
additives text[] not null default '{}',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create or replace function touch_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at := now();
return new;
end;
$$;
create trigger dishes_touch_updated_at
before update on dishes
for each row execute function touch_updated_at();
-- ---------- Zuweisung Gericht -> Tag/Essenszeit ----------
create table menu_entries (
id uuid primary key default gen_random_uuid(),
menu_date date not null,
section_key text not null references sections (key) on delete cascade,
dish_id uuid not null references dishes (id) on delete cascade,
position int not null default 0,
created_at timestamptz not null default now(),
unique (menu_date, section_key, dish_id)
);
create index menu_entries_date_idx on menu_entries (menu_date);
-- ---------- Einstellungen (Farben, Logo, Favicon) – genau eine Zeile ----------
create table settings (
id int primary key check (id = 1),
colors jsonb not null default '{}'::jsonb,
logo_path text,
favicon_path text,
updated_at timestamptz not null default now()
);
insert into settings (id, colors) values (
1,
'{
"primary": "#003388",
"primaryDark": "#0d315c",
"accent": "#ff6900",
"headerText": "#ffffff",
"rowBg": "#e3e4f1",
"rowBgAlt": "#f4f4fa",
"addBtn": "#6da54c",
"deleteBtn": "#e2371d"
}'::jsonb
);
create trigger settings_touch_updated_at
before update on settings
for each row execute function touch_updated_at();
-- ---------- Rezeptvorschläge ----------
create table dish_suggestions (
id uuid primary key default gen_random_uuid(),
name text not null,
description text,
created_at timestamptz not null default now()
);
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
// Spielt db/seed-demo.sql ein: ersetzt Essenszeiten/Gerichte/Allergene/
// Zusatzstoffe/Wochenplan durch den Beispieldatensatz. Nur für lokale
// Entwicklung/Demos gedacht, läuft nicht automatisch beim Start.
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import pg from "pg";
const sql = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "seed-demo.sql"),
"utf8"
);
async function main() {
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
try {
await client.query(sql);
console.log("db:seed-demo – Beispieldaten eingespielt.");
} finally {
await client.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+130
View File
@@ -0,0 +1,130 @@
-- =============================================================
-- Beispieldaten für die lokale Entwicklung/Demo (NICHT Teil der
-- automatischen Migrationen – frische Deployments starten bewusst
-- leer, s. db/migrations/0001_init.sql). Manuell anwendbar über
-- "npm run db:seed-demo".
--
-- Ersetzt vorhandene Essenszeiten/Gerichte/Allergene/Zusatzstoffe/
-- Wochenplan-Einträge durch den ursprünglichen Beispieldatensatz.
-- =============================================================
delete from menu_entries;
delete from dish_suggestions;
delete from dishes;
delete from sections;
delete from allergens;
delete from additives;
-- ---------- Essenszeiten ----------
insert into sections (key, label, label_en, label_pl, time_range, sort) values
('brotzeit', 'Brotzeit', 'Breakfast', 'Śniadanie', '09:00 - 10:00', 1),
('mittagessen', 'Mittagessen', 'Lunch', 'Obiad', '11:30 - 13:30', 2),
('mittagessen_veg', 'Mittagessen vegetarisch', 'Lunch (vegetarian)', 'Obiad (wegetariański)', '11:30 - 13:30', 3),
('suppe', 'Suppe', 'Soup', 'Zupa', '11:30 - 13:30', 4);
-- ---------- Allergene (EU-Kennzeichnung, Buchstaben) ----------
insert into allergens (code, name, name_en, name_pl, sort) values
('A', 'Glutenhaltiges Getreide', 'Cereals containing gluten', 'Zboża zawierające gluten', 1),
('B', 'Krebstiere', 'Crustaceans', 'Skorupiaki', 2),
('C', 'Eier', 'Eggs', 'Jaja', 3),
('D', 'Fisch', 'Fish', 'Ryby', 4),
('E', 'Erdnüsse', 'Peanuts', 'Orzeszki ziemne', 5),
('F', 'Sojabohnen', 'Soybeans', 'Soja', 6),
('G', 'Milch (einschließlich Laktose)', 'Milk (including lactose)', 'Mleko (w tym laktoza)', 7),
('H', 'Schalenfrüchte (Nüsse)', 'Tree nuts', 'Orzechy', 8),
('I', 'Sellerie', 'Celery', 'Seler', 9),
('J', 'Senf', 'Mustard', 'Gorczyca', 10),
('K', 'Sesamsamen', 'Sesame seeds', 'Nasiona sezamu', 11),
('L', 'Schwefeldioxid und Sulfite', 'Sulphur dioxide and sulphites', 'Dwutlenek siarki i siarczyny', 12),
('M', 'Lupinen', 'Lupin', 'Łubin', 13),
('N', 'Weichtiere', 'Molluscs', 'Mięczaki', 14);
-- ---------- Zusatzstoffe (Ziffern) ----------
insert into additives (code, name, name_en, name_pl, sort) values
('1', 'mit Farbstoff', 'with colouring', 'z barwnikiem', 1),
('2', 'mit Konservierungsstoff', 'with preservative', 'z konserwantem', 2),
('3', 'mit Antioxidationsmittel', 'with antioxidant', 'z przeciwutleniaczem', 3),
('4', 'mit Geschmacksverstärker', 'with flavour enhancer', 'ze wzmacniaczem smaku', 4),
('5', 'geschwefelt', 'sulphured', 'siarkowany', 5),
('6', 'geschwärzt', 'blackened', 'barwiony na czarno', 6),
('7', 'gewachst', 'waxed', 'woskowany', 7),
('8', 'mit Phosphat', 'with phosphate', 'z fosforanem', 8),
('9', 'mit Süßungsmittel(n)', 'with sweetener(s)', 'ze słodzikiem/słodzikami', 9),
('10', 'enthält eine Phenylalaninquelle', 'contains a source of phenylalanine', 'zawiera źródło fenyloalaniny', 10),
('11', 'koffeinhaltig', 'caffeinated', 'zawiera kofeinę', 11);
-- ---------- Testgerichte ----------
insert into dishes (name, price) values
-- Brotzeit
('1 Stk. Rindskäsewurst mit Brötchen', 2.30),
('Hackfleischbaguette', 3.20),
('Knusperhähnchen Wrap', 3.70),
('Leberkäse im Brötchen', 2.30),
('Chili con Carne mit Brötchen', 3.90),
-- Mittagessen
('Schnitzel Baguette', 3.50),
('Penne mit Grünen Spargel und Garnelenspieße', 5.90),
('Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip', 5.90),
('Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle', 5.90),
-- Mittagessen vegetarisch
('Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten', 5.30),
('Spaghetti mit fruchtiger Tomatensauce und Parmesan', 5.30),
('Gebackener Feta mit griechischen Bauernsalat und Baguette', 5.30),
('Gemüse-Curry mit Basmatireis', 4.90),
-- Suppen
('Kartoffelcremesuppe', 1.50),
('Gulaschsuppe', 2.50),
('Tomatensuppe mit Croutons', 1.80),
('Kürbiscremesuppe', 1.80),
('Linseneintopf mit Würstchen', 2.20);
-- ---------- Beispielwoche 13.–17. Juli 2026 ----------
with d as (select id, name from dishes)
insert into menu_entries (menu_date, section_key, dish_id)
select x.menu_date::date, x.section_key, d.id
from (values
('2026-07-13', 'brotzeit', '1 Stk. Rindskäsewurst mit Brötchen'),
('2026-07-14', 'brotzeit', 'Hackfleischbaguette'),
('2026-07-15', 'brotzeit', 'Knusperhähnchen Wrap'),
('2026-07-16', 'brotzeit', 'Leberkäse im Brötchen'),
('2026-07-17', 'brotzeit', 'Chili con Carne mit Brötchen'),
('2026-07-13', 'mittagessen', 'Schnitzel Baguette'),
('2026-07-14', 'mittagessen', 'Penne mit Grünen Spargel und Garnelenspieße'),
('2026-07-15', 'mittagessen', 'Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip'),
('2026-07-16', 'mittagessen', 'Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle'),
('2026-07-14', 'mittagessen_veg', 'Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten'),
('2026-07-15', 'mittagessen_veg', 'Spaghetti mit fruchtiger Tomatensauce und Parmesan'),
('2026-07-16', 'mittagessen_veg', 'Gebackener Feta mit griechischen Bauernsalat und Baguette'),
('2026-07-13', 'suppe', 'Kartoffelcremesuppe'),
('2026-07-14', 'suppe', 'Gulaschsuppe'),
('2026-07-15', 'suppe', 'Tomatensuppe mit Croutons'),
('2026-07-16', 'suppe', 'Kürbiscremesuppe'),
('2026-07-17', 'suppe', 'Linseneintopf mit Würstchen')
) as x (menu_date, section_key, name)
join d on d.name = x.name;
-- ---------- Beispielwoche 20.–24. Juli 2026 ("Nächste Woche") ----------
with d as (select id, name from dishes)
insert into menu_entries (menu_date, section_key, dish_id)
select x.menu_date::date, x.section_key, d.id
from (values
('2026-07-20', 'brotzeit', 'Leberkäse im Brötchen'),
('2026-07-21', 'brotzeit', 'Knusperhähnchen Wrap'),
('2026-07-22', 'brotzeit', '1 Stk. Rindskäsewurst mit Brötchen'),
('2026-07-23', 'brotzeit', 'Hackfleischbaguette'),
('2026-07-24', 'brotzeit', 'Chili con Carne mit Brötchen'),
('2026-07-20', 'mittagessen', 'Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip'),
('2026-07-21', 'mittagessen', 'Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle'),
('2026-07-22', 'mittagessen', 'Schnitzel Baguette'),
('2026-07-23', 'mittagessen', 'Penne mit Grünen Spargel und Garnelenspieße'),
('2026-07-20', 'mittagessen_veg', 'Gemüse-Curry mit Basmatireis'),
('2026-07-21', 'mittagessen_veg', 'Spaghetti mit fruchtiger Tomatensauce und Parmesan'),
('2026-07-22', 'mittagessen_veg', 'Gebackener Feta mit griechischen Bauernsalat und Baguette'),
('2026-07-23', 'mittagessen_veg', 'Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten'),
('2026-07-20', 'suppe', 'Gulaschsuppe'),
('2026-07-21', 'suppe', 'Kartoffelcremesuppe'),
('2026-07-22', 'suppe', 'Linseneintopf mit Würstchen'),
('2026-07-23', 'suppe', 'Tomatensuppe mit Croutons'),
('2026-07-24', 'suppe', 'Kürbiscremesuppe')
) as x (menu_date, section_key, name)
join d on d.name = x.name;
+19 -20
View File
@@ -1,15 +1,12 @@
# =============================================================
# Canteen Menu – Docker-Setup
#
# Produktion (App + lokale PostgreSQL-Kopie):
# Produktion (App + PostgreSQL):
# docker compose --env-file .env.local up -d --build
#
# Entwicklung (Dev-Server mit Hot-Reload, kein lokales Node nötig):
# docker compose --env-file .env.local --profile dev up dev
#
# Die --env-file-Angabe speist NEXT_PUBLIC_* in den Build und
# SUPABASE_SERVICE_ROLE_KEY in die Laufzeit-Umgebung.
#
# "name" fixiert den Compose-Projektnamen explizit auf "canteen-menu" –
# ohne diese Angabe leitet Compose ihn aus dem Ordnernamen ab.
# =============================================================
@@ -21,32 +18,32 @@ services:
app:
build:
context: .
args:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
image: canteen-menu-app
container_name: canteen-menu-app
environment:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
DATABASE_URL: postgres://postgres:${LOCAL_PG_PASSWORD:-canteen_menu}@db:5432/canteen_menu
# Öffentliche URL der App (Domain hinter dem Reverse-Proxy).
# Wird für den CSRF-Origin-Check genutzt; dieselbe URL ist die
# Passkey-Domain (siehe scripts/configure-passkeys.mjs).
# Wird für den CSRF-Origin-Check genutzt; dieselbe URL ist auch die
# Passkey-Domain (RP-ID/Origin werden direkt daraus abgeleitet).
APP_URL: ${APP_URL:-http://localhost:3000}
# Titel im Browser-Tab und Überschrift, solange kein Logo hochgeladen ist.
SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
# Optional: E-Mail des automatisch angelegten Admin-Erstnutzers.
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
# Optional: DeepL-API-Key für automatische Übersetzung von Gerichten
# (Kürzel-Erklärungen, Bereichen) nach Englisch/Polnisch. Ohne Key
# (Essenszeiten, Kürzel-Erklärungen) nach Englisch/Polnisch. Ohne Key
# bleibt "Automatisch übersetzen" inaktiv, manuelle Eingabe geht immer.
DEEPL_API_KEY: ${DEEPL_API_KEY:-}
ports:
- "0.0.0.0:3000:3000"
volumes:
- branding_data:/data/branding
- ./ZUGANGSDATEN.md:/app/ZUGANGSDATEN.md
depends_on:
- db
restart: unless-stopped
# ---------- Lokale PostgreSQL-Kopie (Ablage/Auswertungen) ----------
# Beim ersten Start werden die Skripte aus supabase/local
# automatisch in alphabetischer Reihenfolge eingespielt.
# ---------- PostgreSQL (primäre Datenbank) ----------
db:
image: postgres:17-alpine
container_name: canteen-menu-db
@@ -59,7 +56,6 @@ services:
- "5433:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./supabase/local:/docker-entrypoint-initdb.d:ro
restart: unless-stopped
# ---------- Dev-Server (nur mit --profile dev) ----------
@@ -70,18 +66,21 @@ services:
working_dir: /app
command: sh -c "npm install && npm run dev"
environment:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
DATABASE_URL: postgres://postgres:${LOCAL_PG_PASSWORD:-canteen_menu}@db:5432/canteen_menu
APP_URL: ${APP_URL:-http://localhost:3000}
SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
DEEPL_API_KEY: ${DEEPL_API_KEY:-}
ports:
- "0.0.0.0:3000:3000"
volumes:
- .:/app
- dev_node_modules:/app/node_modules
- branding_data:/app/data/branding
depends_on:
- db
volumes:
pgdata:
dev_node_modules:
branding_data:
+35
View File
@@ -0,0 +1,35 @@
import "server-only";
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
// scrypt-Parameter fest verankert statt "der aktuelle Node-Default" zu
// nutzen, damit ein künftiges Node-Upgrade die Hash-Stärke nicht
// stillschweigend ändert. Speicherbedarf = 128 * N * r = 16 MiB.
const N = 16384;
const r = 8;
const p = 1;
const KEY_LEN = 64;
const MAX_MEM = 32 * 1024 * 1024;
// Selbstbeschreibendes Format "scrypt$N$r$p$salt$hash" (hex), damit die
// Parameter später erhöht werden können, ohne bestehende Hashes zu
// invalidieren.
export function hashPassword(plain: string): string {
const salt = randomBytes(16);
const hash = scryptSync(plain, salt, KEY_LEN, { N, r, p, maxmem: MAX_MEM });
return `scrypt$${N}$${r}$${p}$${salt.toString("hex")}$${hash.toString("hex")}`;
}
export function verifyPassword(plain: string, stored: string): boolean {
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
const [, nStr, rStr, pStr, saltHex, hashHex] = parts;
const salt = Buffer.from(saltHex, "hex");
const expected = Buffer.from(hashHex, "hex");
const actual = scryptSync(plain, salt, expected.length, {
N: Number(nStr),
r: Number(rStr),
p: Number(pStr),
maxmem: MAX_MEM,
});
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
+131
View File
@@ -0,0 +1,131 @@
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import { query, queryOne } from "@/lib/db";
import type { Role } from "@/lib/types";
export const SESSION_COOKIE = "session";
const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage
const REFRESH_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // unter 7 Tage Restlaufzeit verlängern
export interface SessionUser {
id: string;
email: string;
full_name: string;
role: Role;
password_auth_enabled: boolean;
}
export interface Session {
user: SessionUser;
expiresAt: Date;
}
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
function isSecureCookie(): boolean {
return (process.env.APP_URL ?? "").startsWith("https://");
}
/** Nur lesend – sicher in Server Components, Route Handlern und Server Actions. */
export async function getSession(): Promise<Session | null> {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
const row = await queryOne<{
expires_at: Date;
id: string;
email: string;
full_name: string;
role: Role;
password_auth_enabled: boolean;
}>(
`select s.expires_at, u.id, u.email, u.full_name, u.role, u.password_auth_enabled
from sessions s join users u on u.id = s.user_id
where s.token_hash = $1 and s.expires_at > now()`,
[hashToken(token)]
);
if (!row) return null;
return {
expiresAt: row.expires_at,
user: {
id: row.id,
email: row.email,
full_name: row.full_name,
role: row.role,
password_auth_enabled: row.password_auth_enabled,
},
};
}
export class UnauthorizedError extends Error {}
export class ForbiddenError extends Error {}
export async function requireSession(): Promise<Session> {
const session = await getSession();
if (!session) throw new UnauthorizedError();
return session;
}
export async function requireRole(roles: Role[]): Promise<Session> {
const session = await requireSession();
if (!roles.includes(session.user.role)) throw new ForbiddenError();
return session;
}
/** Legt eine neue Sitzung an und setzt das Session-Cookie. Nur in Route
* Handlern/Server Actions aufrufbar (dort ist cookies().set() erlaubt). */
export async function createSession(userId: string): Promise<void> {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS);
await query(
"insert into sessions (token_hash, user_id, expires_at) values ($1, $2, $3)",
[hashToken(token), userId, expiresAt]
);
(await cookies()).set(SESSION_COOKIE, token, {
httpOnly: true,
secure: isSecureCookie(),
sameSite: "lax",
path: "/",
expires: expiresAt,
});
}
/** Löscht die Sitzung serverseitig (nicht nur das Cookie), damit ein
* abgefangener Token nicht mehr verwendbar ist. */
export async function destroySession(): Promise<void> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (token) {
await query("delete from sessions where token_hash = $1", [hashToken(token)]);
}
store.delete(SESSION_COOKIE);
}
/** Wandelt UnauthorizedError/ForbiddenError in die passende JSON-Antwort
* um; gibt null zurück, wenn err ein anderer Fehler ist (dann soll der
* Aufrufer ihn weiterwerfen). Reduziert Wiederholung des immer gleichen
* try/catch-Musters in den Route-Handlern. */
export function authErrorResponse(err: unknown): Response | null {
if (err instanceof UnauthorizedError) {
return NextResponseJson({ error: "unauthorized" }, 401);
}
if (err instanceof ForbiddenError) {
return NextResponseJson({ error: "forbidden" }, 403);
}
return null;
}
function NextResponseJson(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
export { REFRESH_THRESHOLD_MS, hashToken, isSecureCookie };
+34
View File
@@ -0,0 +1,34 @@
import "server-only";
import { query, queryOne } from "@/lib/db";
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
export type ChallengeType = "registration" | "authentication";
export async function storeChallenge(
challenge: string,
type: ChallengeType,
userId: string | null
): Promise<void> {
// Kein Cron nötig: abgelaufene Zeilen bei jeder neuen Challenge
// gleich mit aufräumen.
await query("delete from webauthn_challenges where expires_at < now()");
await query(
"insert into webauthn_challenges (user_id, challenge, type, expires_at) values ($1, $2, $3, $4)",
[userId, challenge, type, new Date(Date.now() + CHALLENGE_TTL_MS)]
);
}
/** Einmalig verwendbar: die Zeile wird beim Abruf sofort gelöscht. */
export async function consumeChallenge(
challenge: string,
type: ChallengeType
): Promise<{ userId: string | null } | null> {
const row = await queryOne<{ user_id: string | null }>(
`delete from webauthn_challenges
where challenge = $1 and type = $2 and expires_at > now()
returning user_id`,
[challenge, type]
);
return row ? { userId: row.user_id } : null;
}
+21
View File
@@ -0,0 +1,21 @@
import "server-only";
// RP-Konfiguration wird direkt aus APP_URL/SITE_TITLE abgeleitet – kein
// externer Konfigurationsschritt mehr nötig (ersetzt das bisherige
// scripts/configure-passkeys.mjs, das die Supabase-Management-API
// aufrief).
function appUrl(): URL {
return new URL(process.env.APP_URL ?? "http://localhost:3000");
}
export function rpID(): string {
return appUrl().hostname;
}
export function origin(): string {
return appUrl().origin;
}
export function rpName(): string {
return process.env.SITE_TITLE?.trim() || "Canteen Menu";
}
+48
View File
@@ -0,0 +1,48 @@
import "server-only";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
// Ersetzt den früheren Supabase-Storage-Bucket "branding" durch ein
// lokales Verzeichnis (Docker-Volume in Produktion, s. docker-compose.yml).
export function brandingDir(): string {
// turbopackIgnore: process.cwd() ist hier dynamisch, aber der Pfad
// bleibt statisch unter "data/branding" – ohne den Kommentar versucht
// Turbopack sonst, das gesamte Projektverzeichnis nach möglichen
// dynamischen require()-Zielen zu durchsuchen (harmlos, aber unnötig).
return (
process.env.BRANDING_DIR ??
path.join(/*turbopackIgnore: true*/ process.cwd(), "data", "branding")
);
}
// Nur einfache, von uns selbst vergebene Dateinamen zulassen
// (`${kind}-${Date.now()}.${ext}`) – verhindert Path-Traversal über
// Dateinamen aus der DB-Spalte logo_path/favicon_path.
const SAFE_FILENAME = /^[a-zA-Z0-9_.-]+$/;
export function isSafeFilename(filename: string): boolean {
return SAFE_FILENAME.test(filename) && !filename.includes("..");
}
export async function saveBrandingFile(filename: string, data: Buffer): Promise<void> {
const dir = brandingDir();
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, filename), data);
}
export async function readBrandingFile(filename: string): Promise<Buffer | null> {
if (!isSafeFilename(filename)) return null;
try {
return await readFile(path.join(brandingDir(), filename));
} catch {
return null;
}
}
export async function removeBrandingFile(filename: string): Promise<void> {
try {
await unlink(path.join(brandingDir(), filename));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
}
}
+41
View File
@@ -0,0 +1,41 @@
import "server-only";
import { query, queryOne } from "@/lib/db";
import type { CodeItem } from "@/lib/types";
// "allergens"/"additives" sind identisch aufgebaute Tabellen (Kürzel +
// Bezeichnung in DE/EN/PL). Tabellenname kommt nie aus Nutzereingaben,
// sondern immer aus dem literal getypten Parameter unten – sicher für
// String-Interpolation in der Query.
export type CodeTable = "allergens" | "additives";
export async function listCodes(table: CodeTable): Promise<CodeItem[]> {
return query<CodeItem>(`select * from ${table} order by sort`);
}
export async function createCode(
table: CodeTable,
input: { code: string; name: string; name_en: string | null; name_pl: string | null }
): Promise<CodeItem> {
const maxRow = await queryOne<{ sort: number }>(
`select sort from ${table} order by sort desc limit 1`
);
return (await queryOne<CodeItem>(
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5) returning *`,
[input.code, input.name, input.name_en, input.name_pl, (maxRow?.sort ?? 0) + 1]
))!;
}
export async function updateCode(
table: CodeTable,
originalCode: string,
input: { code: string; name: string; name_en: string | null; name_pl: string | null }
): Promise<CodeItem | null> {
return queryOne<CodeItem>(
`update ${table} set code = $1, name = $2, name_en = $3, name_pl = $4 where code = $5 returning *`,
[input.code, input.name, input.name_en, input.name_pl, originalCode]
);
}
export async function deleteCode(table: CodeTable, code: string): Promise<void> {
await query(`delete from ${table} where code = $1`, [code]);
}
+36
View File
@@ -0,0 +1,36 @@
import "server-only";
import type { PoolClient, QueryResultRow } from "pg";
import { pool } from "./pool";
export async function query<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<T[]> {
const result = await pool.query<T>(text, params);
return result.rows;
}
export async function queryOne<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<T | null> {
const rows = await query<T>(text, params);
return rows[0] ?? null;
}
export async function withTransaction<T>(
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query("begin");
const result = await fn(client);
await client.query("commit");
return result;
} catch (err) {
await client.query("rollback");
throw err;
} finally {
client.release();
}
}
+48
View File
@@ -0,0 +1,48 @@
import "server-only";
import { query } from "@/lib/db";
import type { MenuEntry } from "@/lib/types";
interface MenuEntryRow {
id: string;
menu_date: string;
section_key: string;
position: number;
dish_id: string;
name: string;
name_en: string | null;
name_pl: string | null;
price: string;
allergens: string[];
additives: string[];
}
/** Wochenplan-Einträge inkl. zugehörigem Gericht für den Zeitraum laden –
* gemeinsam von der öffentlichen Speisekarte (Server Component) und
* /api/menu-entries genutzt, damit der JOIN nur einmal gepflegt wird. */
export async function loadMenuEntries(fromISO: string, toISO: string): Promise<MenuEntry[]> {
const rows = await query<MenuEntryRow>(
`select me.id, me.menu_date, me.section_key, me.position,
d.id as dish_id, d.name, d.name_en, d.name_pl, d.price, d.allergens, d.additives
from menu_entries me
join dishes d on d.id = me.dish_id
where me.menu_date between $1 and $2
order by me.position, me.created_at`,
[fromISO, toISO]
);
return rows.map((r) => ({
id: r.id,
menu_date: r.menu_date,
section_key: r.section_key,
position: r.position,
dish: {
id: r.dish_id,
name: r.name,
name_en: r.name_en,
name_pl: r.name_pl,
price: Number(r.price),
allergens: r.allergens,
additives: r.additives,
},
}));
}
+28
View File
@@ -0,0 +1,28 @@
import "server-only";
import { Pool, types } from "pg";
// pg gibt "date"-Spalten sonst als lokale Date-Objekte zurück (Zeitzonen-
// Verschiebung möglich); die App vergleicht Datumswerte überall als reine
// "YYYY-MM-DD"-Strings (siehe lib/menu.ts), daher Rohstring beibehalten.
types.setTypeParser(types.builtins.DATE, (val) => val);
// pg gibt "numeric" (dishes.price) sonst als String zurück (Precision-
// Schutz), die App erwartet aber überall eine JS-Zahl (wie zuvor von
// Postgrest geliefert) – für Preise im niedrigen vierstelligen Bereich
// mit 2 Nachkommastellen unproblematisch.
types.setTypeParser(types.builtins.NUMERIC, (val) => Number(val));
// Next.js' Dev-Server lädt Module bei Hot-Reloads neu; ohne den
// globalThis-Cache würde bei jedem Speichern ein neuer Pool (und damit
// neue offene Verbindungen) entstehen.
declare global {
var __pgPool: Pool | undefined;
}
export const pool =
globalThis.__pgPool ??
new Pool({ connectionString: process.env.DATABASE_URL });
if (process.env.NODE_ENV !== "production") {
globalThis.__pgPool = pool;
}
+14
View File
@@ -0,0 +1,14 @@
import "server-only";
import { queryOne } from "@/lib/db";
export interface Settings {
colors: Record<string, string>;
logo_path: string | null;
favicon_path: string | null;
}
export async function getSettings(): Promise<Settings | null> {
return queryOne<Settings>(
"select colors, logo_path, favicon_path from settings where id = 1"
);
}
+66 -21
View File
@@ -30,6 +30,7 @@ const de = {
canteen: "Kantine",
menuGroup: "Speiseplan",
weekPlan: "Wochenplan",
sections: "Essenszeiten",
dishes: "Gerichte",
labels: "Allergene & Zusatzstoffe",
adminGroup: "Verwaltung",
@@ -46,7 +47,6 @@ const de = {
passkeyButton: "Mit Passkey anmelden",
backToMenu: "← Zur Speisekarte",
errorInvalidCredentials: "E-Mail-Adresse oder Passwort ist falsch.",
errorEmailNotConfirmed: "Die E-Mail-Adresse wurde noch nicht bestätigt.",
errorPasskeyCancelled: "Passkey-Anmeldung wurde abgebrochen.",
errorGeneric: "Anmeldung fehlgeschlagen: {message}",
errorPasswordDisabled:
@@ -68,7 +68,7 @@ const de = {
weekEditor: {
title: "Wochenplan",
loading: "Lade Wochenplan…",
selectDish: "– Gericht wählen –",
changeDish: "Anderes Gericht wählen",
addMeal: "Mahlzeit hinzufügen",
newDishesHint: "Neue Gerichte legst du unter {link} an.",
newDishesLinkLabel: "Gerichte",
@@ -87,8 +87,6 @@ const de = {
autoTranslate: "Automatisch übersetzen",
translating: "Übersetze…",
translated: "Übersetzt.",
translateUnavailable:
"Automatische Übersetzung ist nicht eingerichtet. Bitte Englisch/Polnisch manuell ausfüllen.",
priceLabel: "Preis in €",
pricePlaceholder: "3,50",
searchPlaceholder: "Gericht suchen…",
@@ -120,6 +118,24 @@ const de = {
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
},
sectionManager: {
title: "Essenszeiten",
newSection: "Neue Essenszeit",
editSection: "Essenszeit bearbeiten",
nameLabel: "Bezeichnung",
nameEnLabel: "Bezeichnung (Englisch)",
namePlLabel: "Bezeichnung (Polnisch)",
timeFrom: "Von",
timeTo: "Bis",
moveUp: "Nach oben",
moveDown: "Nach unten",
empty: "Noch keine Essenszeiten angelegt.",
confirmDelete:
"„{name}“ wirklich löschen? Zugewiesene Gerichte werden auch aus allen Wochenplänen entfernt.",
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
},
userManager: {
title: "Benutzer",
newUser: "Neuer Benutzer",
@@ -169,7 +185,7 @@ const de = {
},
passkeys: {
title: "Passkeys",
hint: "Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden. Passkeys gelten pro Domain – für den Live-Betrieb muss die Domain in der Supabase-Konfiguration hinterlegt sein.",
hint: "Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden. Passkeys gelten pro Domain – nach einem Domainwechsel müssen sie neu registriert werden.",
register: "Neuen Passkey registrieren",
empty: "Noch keine Passkeys registriert.",
createdOn: "– angelegt am {date}",
@@ -188,8 +204,7 @@ const de = {
nameRequired: "Bitte einen Namen angeben.",
emailTitle: "E-Mail-Adresse",
changeEmail: "E-Mail-Adresse ändern",
emailSent:
"Bestätigungs-E-Mails wurden verschickt. Die neue Adresse wird erst nach Bestätigung des Links aktiv.",
emailChanged: "E-Mail-Adresse geändert.",
passwordTitle: "Passwort ändern",
currentPassword: "Aktuelles Passwort (zur Verifizierung)",
newPassword: "Neues Passwort",
@@ -269,6 +284,7 @@ const en: Dict = {
canteen: "Canteen",
menuGroup: "Menu",
weekPlan: "Weekly plan",
sections: "Meal times",
dishes: "Dishes",
labels: "Allergens & Additives",
adminGroup: "Administration",
@@ -285,7 +301,6 @@ const en: Dict = {
passkeyButton: "Sign in with passkey",
backToMenu: "← Back to menu",
errorInvalidCredentials: "Email address or password is incorrect.",
errorEmailNotConfirmed: "This email address has not been confirmed yet.",
errorPasskeyCancelled: "Passkey sign-in was cancelled.",
errorGeneric: "Sign-in failed: {message}",
errorPasswordDisabled:
@@ -307,7 +322,7 @@ const en: Dict = {
weekEditor: {
title: "Weekly plan",
loading: "Loading weekly plan…",
selectDish: "– Select dish –",
changeDish: "Choose a different dish",
addMeal: "Add meal",
newDishesHint: "Add new dishes under {link}.",
newDishesLinkLabel: "Dishes",
@@ -326,8 +341,6 @@ const en: Dict = {
autoTranslate: "Auto-translate",
translating: "Translating…",
translated: "Translated.",
translateUnavailable:
"Automatic translation is not set up. Please fill in English/Polish manually.",
priceLabel: "Price in €",
pricePlaceholder: "3.50",
searchPlaceholder: "Search dishes…",
@@ -359,6 +372,24 @@ const en: Dict = {
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
},
sectionManager: {
title: "Meal times",
newSection: "New meal time",
editSection: "Edit meal time",
nameLabel: "Name",
nameEnLabel: "Name (English)",
namePlLabel: "Name (Polish)",
timeFrom: "From",
timeTo: "To",
moveUp: "Move up",
moveDown: "Move down",
empty: "No meal times yet.",
confirmDelete:
"Really delete “{name}”? Assigned dishes will also be removed from all weekly plans.",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
},
userManager: {
title: "Users",
newUser: "New user",
@@ -408,7 +439,7 @@ const en: Dict = {
},
passkeys: {
title: "Passkeys",
hint: "With a passkey (fingerprint, face recognition, or a security key) you can sign in without a password in the future. Passkeys are bound to a domain – for production use, the domain must be configured in Supabase.",
hint: "With a passkey (fingerprint, face recognition, or a security key) you can sign in without a password in the future. Passkeys are bound to a domain – after a domain change they need to be registered again.",
register: "Register new passkey",
empty: "No passkeys registered yet.",
createdOn: "– created on {date}",
@@ -427,8 +458,7 @@ const en: Dict = {
nameRequired: "Please enter a name.",
emailTitle: "Email address",
changeEmail: "Change email address",
emailSent:
"Confirmation emails have been sent. The new address becomes active only after confirming the link.",
emailChanged: "Email address changed.",
passwordTitle: "Change password",
currentPassword: "Current password (for verification)",
newPassword: "New password",
@@ -506,6 +536,7 @@ const pl: Dict = {
canteen: "Stołówka",
menuGroup: "Jadłospis",
weekPlan: "Plan tygodnia",
sections: "Pory posiłków",
dishes: "Dania",
labels: "Alergeny i dodatki",
adminGroup: "Administracja",
@@ -522,7 +553,6 @@ const pl: Dict = {
passkeyButton: "Zaloguj się kluczem dostępu",
backToMenu: "← Powrót do jadłospisu",
errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.",
errorEmailNotConfirmed: "Adres e-mail nie został jeszcze potwierdzony.",
errorPasskeyCancelled: "Logowanie kluczem dostępu zostało anulowane.",
errorGeneric: "Logowanie nie powiodło się: {message}",
errorPasswordDisabled:
@@ -544,7 +574,7 @@ const pl: Dict = {
weekEditor: {
title: "Plan tygodnia",
loading: "Wczytywanie planu tygodnia…",
selectDish: "– Wybierz danie –",
changeDish: "Wybierz inne danie",
addMeal: "Dodaj posiłek",
newDishesHint: "Nowe dania możesz dodać w zakładce {link}.",
newDishesLinkLabel: "Dania",
@@ -563,8 +593,6 @@ const pl: Dict = {
autoTranslate: "Przetłumacz automatycznie",
translating: "Tłumaczenie…",
translated: "Przetłumaczono.",
translateUnavailable:
"Automatyczne tłumaczenie nie jest skonfigurowane. Wypełnij angielski/polski ręcznie.",
priceLabel: "Cena w €",
pricePlaceholder: "3,50",
searchPlaceholder: "Szukaj dania…",
@@ -596,6 +624,24 @@ const pl: Dict = {
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
},
sectionManager: {
title: "Pory posiłków",
newSection: "Nowa pora posiłku",
editSection: "Edytuj porę posiłku",
nameLabel: "Nazwa",
nameEnLabel: "Nazwa (angielski)",
namePlLabel: "Nazwa (polski)",
timeFrom: "Od",
timeTo: "Do",
moveUp: "Przesuń w górę",
moveDown: "Przesuń w dół",
empty: "Nie dodano jeszcze żadnych pór posiłków.",
confirmDelete:
"Czy na pewno usunąć „{name}”? Przypisane dania zostaną również usunięte ze wszystkich planów tygodniowych.",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
},
userManager: {
title: "Użytkownicy",
newUser: "Nowy użytkownik",
@@ -645,7 +691,7 @@ const pl: Dict = {
},
passkeys: {
title: "Klucze dostępu",
hint: "Dzięki kluczowi dostępu (odcisk palca, rozpoznawanie twarzy lub klucz bezpieczeństwa) możesz w przyszłości logować się bez hasła. Klucze dostępu są przypisane do domeny – w środowisku produkcyjnym domena musi być skonfigurowana w Supabase.",
hint: "Dzięki kluczowi dostępu (odcisk palca, rozpoznawanie twarzy lub klucz bezpieczeństwa) możesz w przyszłości logować się bez hasła. Klucze dostępu są przypisane do domeny – po zmianie domeny trzeba je zarejestrować ponownie.",
register: "Zarejestruj nowy klucz dostępu",
empty: "Nie zarejestrowano jeszcze żadnych kluczy dostępu.",
createdOn: "– utworzono {date}",
@@ -664,8 +710,7 @@ const pl: Dict = {
nameRequired: "Podaj nazwę.",
emailTitle: "Adres e-mail",
changeEmail: "Zmień adres e-mail",
emailSent:
"Wysłano e-maile potwierdzające. Nowy adres zacznie obowiązywać dopiero po potwierdzeniu linku.",
emailChanged: "Adres e-mail zmieniony.",
passwordTitle: "Zmień hasło",
currentPassword: "Obecne hasło (do weryfikacji)",
newPassword: "Nowe hasło",
+1 -1
View File
@@ -64,7 +64,7 @@ export function dishCodes(dish: { allergens: string[]; additives: string[] }): s
return [...(dish.allergens ?? []), ...(dish.additives ?? [])].join(",");
}
/** Einträge eines Tages/Bereichs aus der geladenen Wochenliste herausfiltern. */
/** Einträge eines Tages/einer Essenszeit aus der geladenen Wochenliste herausfiltern. */
export function entriesFor(
entries: MenuEntry[],
iso: string,
+2 -2
View File
@@ -1,8 +1,8 @@
// CSRF-Schutz (Defense-in-Depth) für schreibende Route-Handler:
// Browser senden bei Cross-Site-POSTs immer einen Origin-Header –
// stimmt er nicht mit dem Host überein, wird die Anfrage abgelehnt.
// (Erste Verteidigungslinie sind bereits die SameSite=Lax-Session-
// Cookies von @supabase/ssr sowie der JSON-Content-Type.)
// (Erste Verteidigungslinie ist bereits das SameSite=Lax-Session-Cookie,
// siehe lib/auth/session.ts, sowie der JSON-Content-Type.)
export function isSameOrigin(request: Request): boolean {
const origin = request.headers.get("origin");
// Kein Origin-Header: kein Browser-Cross-Site-Kontext (z. B. curl);
-14
View File
@@ -1,14 +0,0 @@
import "server-only";
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
// Service-Role-Client: umgeht RLS. Ausschließlich serverseitig verwenden
// (Benutzerverwaltung durch den Admin).
export function createAdminClient() {
return createSupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: { persistSession: false, autoRefreshToken: false },
}
);
}
-15
View File
@@ -1,15 +0,0 @@
import { createBrowserClient } from "@supabase/ssr";
// Browser-Client (Client Components). Passkey-Support ist bei Supabase
// derzeit als experimentelles Feature markiert und muss aktiviert werden.
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
experimental: { passkey: true },
},
}
);
}
-30
View File
@@ -1,30 +0,0 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
// Server-Client (Server Components, Route Handler, Server Actions).
// Muss pro Request neu erzeugt werden.
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// In Server Components dürfen keine Cookies gesetzt werden –
// das Session-Refresh übernimmt proxy.ts.
}
},
},
}
);
}
+2 -2
View File
@@ -87,12 +87,12 @@ export const COLOR_VARS: Record<string, string> = {
export function logoUrl(path: string | null | undefined): string | null {
if (!path) return null;
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/branding/${path}`;
return `/branding/${path}`;
}
export function faviconUrl(path: string | null | undefined): string | null {
if (!path) return null;
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/branding/${path}`;
return `/branding/${path}`;
}
export const SITE_TITLE = process.env.SITE_TITLE?.trim() || "Canteen Menu";
+417 -114
View File
@@ -1,16 +1,17 @@
{
"name": "canteen-menu",
"version": "1.3.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "canteen-menu",
"version": "1.3.0",
"version": "1.5.0",
"dependencies": {
"@supabase/ssr": "^0.12.3",
"@supabase/supabase-js": "^2.110.7",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"next": "16.2.10",
"pg": "^8.22.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"server-only": "^0.0.1"
@@ -18,6 +19,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
@@ -456,6 +458,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1086,6 +1094,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -1309,6 +1323,174 @@
"node": ">=12.4.0"
}
},
"node_modules/@peculiar/asn1-android": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz",
"integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-rsa": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pfx": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1316,100 +1498,29 @@
"dev": true,
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.110.7",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.7.tgz",
"integrity": "sha512-M5Bpl4hCv6kHcOO/xM06Dyfg1mYLHljMkp1plhzG9IRZPc3czvyMsSN1XpL5+GKisOKM3lSN59zhpcm6sMVXfA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.110.7",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.7.tgz",
"integrity": "sha512-megYmexlYEoR/0qlsr4Snh9wtzAodO7MAri3NMevZrXzNvQRKlvmTcSBoKGLQEPDakgDZMqbMdf9DwoZz6qfoA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.110.7",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.7.tgz",
"integrity": "sha512-ban6YV0djhVaqVYezlOARKLIuOBSvLLhyQVZjA2nxPrtswhxHCl1+gI4giFgI9ATQAaMNbUZb4JXiuL5lEA/5g==",
"node_modules/@simplewebauthn/server": {
"version": "13.3.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz",
"integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.1",
"@peculiar/asn1-rsa": "^2.6.1",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"@peculiar/x509": "^1.14.3"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.110.7",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.7.tgz",
"integrity": "sha512-AMtZjyFA2gsmjuxopPNS/sRznLQHG0Ht5x+ytTPTOh3vAcOTUlVRLx7gW4/CONNnbb3PKOkE+HmM35HOSbmomQ==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "0.4.5",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/ssr": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.3.tgz",
"integrity": "sha512-qWXJ/dI7CiYDKyTgIPqJ4Qkh8y5edh2LdF/nkN48z47mmEVzAO2OE+YErYeQ/UemVfonn/F4kFz+lhLqoVMdpw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.110.5"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.110.7",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.7.tgz",
"integrity": "sha512-2tcDE8cjEDy1uKxKavBpKQod1JdMV1jDXQag48TCa+kycmJOltc0yVabC0BUlhOwAl6WykXU2aOsH3ELMtZrmQ==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.110.7",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.7.tgz",
"integrity": "sha512-AnfO3A230Shy6RMO7cya3Wl1OcXnABJrzH8vP+fY7/RFjhzcchB7DjKkkTIAntlwekD+GkSFzEvt2tC+D4Fp8w==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.110.7",
"@supabase/functions-js": "2.110.7",
"@supabase/postgrest-js": "2.110.7",
"@supabase/realtime-js": "2.110.7",
"@supabase/storage-js": "2.110.7"
},
"engines": {
"node": ">=22.0.0"
"node": ">=20.0.0"
}
},
"node_modules/@swc/helpers": {
@@ -1746,6 +1857,18 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -2648,6 +2771,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -2915,19 +3052,6 @@
"dev": true,
"license": "MIT"
},
"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/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4215,15 +4339,6 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5661,6 +5776,95 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5719,6 +5923,45 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5751,6 +5994,24 @@
"node": ">=6"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5800,6 +6061,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6215,6 +6482,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6549,6 +6825,24 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -6898,6 +7192,15 @@
"node": ">=0.10.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+10 -4
View File
@@ -1,17 +1,22 @@
{
"name": "canteen-menu",
"version": "1.3.0",
"version": "1.5.0",
"private": true,
"scripts": {
"predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs",
"dev": "next dev -H 0.0.0.0",
"build": "next build",
"prestart": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs",
"start": "next start -H 0.0.0.0",
"lint": "eslint"
"lint": "eslint",
"db:migrate": "node --env-file-if-exists=.env.local db/migrate.mjs",
"db:seed-demo": "node --env-file-if-exists=.env.local db/seed-demo.mjs"
},
"dependencies": {
"@supabase/ssr": "^0.12.3",
"@supabase/supabase-js": "^2.110.7",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"next": "16.2.10",
"pg": "^8.22.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"server-only": "^0.0.1"
@@ -19,6 +24,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
+58 -28
View File
@@ -1,36 +1,36 @@
import { createServerClient } from "@supabase/ssr";
import { createHash } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";
import { Pool } from "pg";
// Session-Refresh + Zugriffsschutz für die internen Bereiche.
// Next 16 führt Proxy standardmäßig im Node.js-Runtime aus, direkter
// pg-Zugriff ist hier also möglich (kein Edge-Workaround nötig).
//
// Der Cookie-Name ist hier bewusst dupliziert statt aus lib/auth/session
// importiert: dieses Modul zieht next/headers (cookies()) nach, das
// außerhalb von Server Components/Route Handlern/Server Actions nicht
// nutzbar ist – Proxy läuft in einem eigenen Ausführungskontext.
const SESSION_COOKIE = "session";
declare global {
var __pgPoolProxy: Pool | undefined;
}
const pool =
globalThis.__pgPoolProxy ??
new Pool({ connectionString: process.env.DATABASE_URL });
if (process.env.NODE_ENV !== "production") globalThis.__pgPoolProxy = pool;
const REFRESH_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const token = request.cookies.get(SESSION_COOKIE)?.value;
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
function redirectToLogin() {
// Absolute URL aus dem Host-Header des Browsers bauen: request.nextUrl
// trägt bei "-H 0.0.0.0" die Server-Bindung statt des aufgerufenen
// Hosts, und relative Location-Header lehnt die Middleware ab.
@@ -41,6 +41,36 @@ export async function proxy(request: NextRequest) {
return NextResponse.redirect(`${proto}://${host}/login`, 303);
}
if (!token) return redirectToLogin();
const tokenHash = hashToken(token);
const { rows } = await pool.query<{ expires_at: Date }>(
"select expires_at from sessions where token_hash = $1 and expires_at > now()",
[tokenHash]
);
const session = rows[0];
if (!session) return redirectToLogin();
const response = NextResponse.next();
// Sliding-Refresh: bei weniger als 7 Tagen Restlaufzeit Sitzung und
// Cookie verlängern, statt bei jedem Request zu schreiben.
const msRemaining = session.expires_at.getTime() - Date.now();
if (msRemaining < REFRESH_THRESHOLD_MS) {
const newExpiry = new Date(Date.now() + SESSION_LIFETIME_MS);
await pool.query("update sessions set expires_at = $1, last_seen_at = now() where token_hash = $2", [
newExpiry,
tokenHash,
]);
response.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: (process.env.APP_URL ?? "").startsWith("https://"),
sameSite: "lax",
path: "/",
expires: newExpiry,
});
}
return response;
}
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
// Legt beim allerersten Start (leere users-Tabelle) genau einen
// Admin-Benutzer mit zufällig generiertem Passwort an und hängt die
// Zugangsdaten an ZUGANGSDATEN.md an. Idempotent: bei bereits
// vorhandenem Benutzer passiert nichts.
//
// Eigenständiges Skript statt Import aus lib/auth/password.ts, damit
// dieses Vor-Build-Skript nicht an Next's Build-Output gekoppelt ist.
// Die Hash-Logik ist bewusst identisch zur kanonischen Implementierung
// in lib/auth/password.ts gehalten – Änderungen dort auch hier nachziehen.
import { appendFileSync, existsSync } from "node:fs";
import { randomBytes, scryptSync } from "node:crypto";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import pg from "pg";
const N = 16384;
const r = 8;
const p = 1;
const KEY_LEN = 64;
const MAX_MEM = 32 * 1024 * 1024;
function hashPassword(plain) {
const salt = randomBytes(16);
const hash = scryptSync(plain, salt, KEY_LEN, { N, r, p, maxmem: MAX_MEM });
return `scrypt$${N}$${r}$${p}$${salt.toString("hex")}$${hash.toString("hex")}`;
}
function defaultAdminEmail() {
if (process.env.ADMIN_EMAIL) return process.env.ADMIN_EMAIL;
try {
const host = new URL(process.env.APP_URL ?? "http://localhost").hostname;
return `admin@${host}`;
} catch {
return "admin@localhost";
}
}
async function main() {
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
try {
const { rows } = await client.query("select count(*)::int as count from users");
if (rows[0].count > 0) {
console.log("bootstrap-admin – Admin bereits vorhanden, überspringe.");
return;
}
const email = defaultAdminEmail();
const password = randomBytes(18).toString("base64url");
const passwordHash = hashPassword(password);
await client.query(
`insert into users (email, password_hash, full_name, role, password_auth_enabled)
values ($1, $2, '', 'admin', true)`,
[email, passwordHash]
);
const zugangsdatenPath = join(
dirname(dirname(fileURLToPath(import.meta.url))),
"ZUGANGSDATEN.md"
);
const entry = `\n## Admin-Zugang (automatisch angelegt, ${new Date().toISOString().slice(0, 10)})\n\n- E-Mail: \`${email}\`\n- Passwort: \`${password}\`\n\nBitte nach dem ersten Login ändern.\n`;
if (existsSync(zugangsdatenPath)) {
appendFileSync(zugangsdatenPath, entry);
} else {
console.warn(
`bootstrap-admin – ${zugangsdatenPath} existiert nicht, Zugangsdaten werden nur geloggt:`
);
}
console.log(`bootstrap-admin – Admin angelegt: ${email} (Passwort siehe ZUGANGSDATEN.md)`);
} finally {
await client.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
-47
View File
@@ -1,47 +0,0 @@
// Konfiguriert die Passkey-Domain (WebAuthn Relying Party) des
// Supabase-Projekts auf die öffentliche App-URL. Passkeys sind an die
// Domain gebunden – nach einem Domain-Wechsel einmal ausführen.
//
// Aufruf:
// SUPABASE_ACCESS_TOKEN=... APP_URL=https://menu.example.de node scripts/configure-passkeys.mjs
// oder mit der URL als Argument:
// SUPABASE_ACCESS_TOKEN=... node scripts/configure-passkeys.mjs https://menu.example.de
const PROJECT_REF = "hvzikwjoyhoakedlywhj";
const token = process.env.SUPABASE_ACCESS_TOKEN;
const rawUrl = process.argv[2] ?? process.env.APP_URL;
if (!token || !rawUrl) {
console.error(
"Nutzung: SUPABASE_ACCESS_TOKEN=... [APP_URL=https://…] node scripts/configure-passkeys.mjs [url]"
);
process.exit(1);
}
const url = new URL(rawUrl);
const res = await fetch(
`https://api.supabase.com/v1/projects/${PROJECT_REF}/config/auth`,
{
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
passkey_enabled: true,
webauthn_rp_id: url.hostname,
webauthn_rp_display_name: "Canteen Menu",
webauthn_rp_origins: url.origin,
}),
}
);
if (!res.ok) {
console.error(`Fehler ${res.status}: ${await res.text()}`);
process.exit(1);
}
console.log(`Passkey-Domain gesetzt: rp_id=${url.hostname}, origin=${url.origin}`);
console.log(
"Hinweis: Bereits auf einer anderen Domain registrierte Passkeys verlieren ihre Gültigkeit."
);
-32
View File
@@ -1,32 +0,0 @@
// Führt eine SQL-Datei gegen das Supabase-Projekt aus (Management API).
// Aufruf: SUPABASE_ACCESS_TOKEN=... node scripts/db-apply.mjs <pfad-zur-sql-datei>
import { readFileSync } from "node:fs";
const PROJECT_REF = "hvzikwjoyhoakedlywhj";
const token = process.env.SUPABASE_ACCESS_TOKEN;
const file = process.argv[2];
if (!token || !file) {
console.error("Nutzung: SUPABASE_ACCESS_TOKEN=... node scripts/db-apply.mjs <datei.sql>");
process.exit(1);
}
const query = readFileSync(file, "utf8");
const res = await fetch(
`https://api.supabase.com/v1/projects/${PROJECT_REF}/database/query`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query }),
}
);
const body = await res.text();
if (!res.ok) {
console.error(`Fehler ${res.status}: ${body}`);
process.exit(1);
}
console.log("OK:", body.slice(0, 500));
-47
View File
@@ -1,47 +0,0 @@
# Synchronisiert die lokale PostgreSQL-Kopie mit der Supabase-Datenbank.
# Voraussetzungen:
# - PostgreSQL 17 lokal installiert (Standardpfad, Dienst läuft)
# - Umgebungsvariablen:
# SUPABASE_DB_PASSWORD = Datenbank-Passwort des Supabase-Projekts
# LOCAL_PG_PASSWORD = Passwort des lokalen postgres-Benutzers
# Aufruf: powershell -File scripts\sync-local-db.ps1
$ErrorActionPreference = "Stop"
$PgBin = "C:\Program Files\PostgreSQL\17\bin"
$RemoteHost = "aws-0-eu-central-1.pooler.supabase.com"
$RemoteUser = "postgres.hvzikwjoyhoakedlywhj"
$LocalDir = Join-Path $PSScriptRoot "..\supabase\local"
if (-not $env:SUPABASE_DB_PASSWORD) { throw "SUPABASE_DB_PASSWORD ist nicht gesetzt." }
if (-not $env:LOCAL_PG_PASSWORD) { throw "LOCAL_PG_PASSWORD ist nicht gesetzt." }
# 1. Aktuellen Stand aus Supabase ziehen (Session-Pooler, Port 5432)
$env:PGPASSWORD = $env:SUPABASE_DB_PASSWORD
$SchemaFile = Join-Path $LocalDir "10_schema.sql"
& "$PgBin\pg_dump" -h $RemoteHost -p 5432 -U $RemoteUser -d postgres `
--schema=public --schema-only --no-owner --no-privileges `
-f $SchemaFile
& "$PgBin\pg_dump" -h $RemoteHost -p 5432 -U $RemoteUser -d postgres `
--schema=public --data-only --no-owner --disable-triggers `
-f (Join-Path $LocalDir "20_data.sql")
# pg_dump legt "CREATE SCHEMA public;" und \restrict/\unrestrict-Marker an.
# Das offizielle postgres-Docker-Image besitzt "public" bereits und bricht
# mit ON_ERROR_STOP=1 sonst das komplette Init-Skript ab.
(Get-Content $SchemaFile) `
-replace '^CREATE SCHEMA public;$', 'CREATE SCHEMA IF NOT EXISTS public;' `
-replace '^\\restrict .*$', '' `
-replace '^\\unrestrict .*$', '' |
Set-Content $SchemaFile
# 2. Lokale Datenbank neu aufbauen
$env:PGPASSWORD = $env:LOCAL_PG_PASSWORD
& "$PgBin\psql" -h localhost -U postgres -c "drop database if exists canteen_menu;"
& "$PgBin\psql" -h localhost -U postgres -c "create database canteen_menu;"
foreach ($file in @("00_stubs.sql", "10_schema.sql", "15_auth_users.sql", "20_data.sql")) {
& "$PgBin\psql" -h localhost -U postgres -d canteen_menu -q -f (Join-Path $LocalDir $file)
}
Write-Host "Lokale Datenbank 'canteen_menu' wurde aktualisiert."
-58
View File
@@ -1,58 +0,0 @@
-- =============================================================
-- Stubs für die LOKALE PostgreSQL-Kopie (ohne Supabase-Stack).
-- Stellt die minimalen Supabase-Objekte bereit, die das public-
-- Schema referenziert (auth.users, auth.uid(), Rollen, storage).
-- Reihenfolge: 00_stubs.sql -> Schema-Dump -> Daten-Dump
-- =============================================================
-- Rollen, die in den RLS-Policies referenziert werden
do $$
begin
if not exists (select from pg_roles where rolname = 'anon') then
create role anon nologin;
end if;
if not exists (select from pg_roles where rolname = 'authenticated') then
create role authenticated nologin;
end if;
if not exists (select from pg_roles where rolname = 'service_role') then
create role service_role nologin bypassrls;
end if;
end
$$;
-- ---------- auth-Schema (Minimal-Stub) ----------
create schema if not exists auth;
create table if not exists auth.users (
id uuid primary key,
email text,
raw_user_meta_data jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
-- Entspricht supabase auth.uid(); liefert lokal null,
-- sofern kein JWT-Claim gesetzt ist.
create or replace function auth.uid()
returns uuid
language sql
stable
as $$
select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid
$$;
-- ---------- storage-Schema (Minimal-Stub) ----------
create schema if not exists storage;
create table if not exists storage.buckets (
id text primary key,
name text not null,
public boolean not null default false
);
create table if not exists storage.objects (
id uuid primary key default gen_random_uuid(),
bucket_id text references storage.buckets (id),
name text,
owner uuid,
created_at timestamptz not null default now()
);
-498
View File
@@ -1,498 +0,0 @@
--
-- PostgreSQL database dump
--
-- Dumped from database version 17.6
-- Dumped by pg_dump version 17.10
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET transaction_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
--
-- Das "public"-Schema existiert im offiziellen postgres-Image bereits;
-- CREATE SCHEMA würde dort mit ON_ERROR_STOP=1 das komplette Init-Skript
-- abbrechen.
CREATE SCHEMA IF NOT EXISTS public;
--
-- Name: SCHEMA public; Type: COMMENT; Schema: -; Owner: -
--
COMMENT ON SCHEMA public IS 'standard public schema';
--
-- Name: app_role(); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION public.app_role() RETURNS text
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
select role from public.profiles where id = auth.uid()
$$;
--
-- Name: handle_new_user(); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION public.handle_new_user() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
begin
insert into public.profiles (id, email, full_name, role)
values (
new.id,
new.email,
coalesce(new.raw_user_meta_data ->> 'full_name', ''),
coalesce(new.raw_user_meta_data ->> 'role', 'staff')
)
on conflict (id) do nothing;
return new;
end;
$$;
--
-- Name: handle_user_email_change(); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION public.handle_user_email_change() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
begin
update public.profiles set email = new.email where id = new.id;
return new;
end;
$$;
--
-- Name: touch_updated_at(); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION public.touch_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
new.updated_at := now();
return new;
end;
$$;
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: additives; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.additives (
code text NOT NULL,
name text NOT NULL,
sort integer DEFAULT 0 NOT NULL,
name_en text,
name_pl text
);
--
-- Name: allergens; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.allergens (
code text NOT NULL,
name text NOT NULL,
sort integer DEFAULT 0 NOT NULL,
name_en text,
name_pl text
);
--
-- Name: dish_suggestions; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.dish_suggestions (
id uuid DEFAULT gen_random_uuid() NOT NULL,
name text NOT NULL,
description text,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: dishes; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.dishes (
id uuid DEFAULT gen_random_uuid() NOT NULL,
name text NOT NULL,
price numeric(6,2) DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
allergens text[] DEFAULT '{}'::text[] NOT NULL,
additives text[] DEFAULT '{}'::text[] NOT NULL,
name_en text,
name_pl text
);
--
-- Name: menu_entries; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.menu_entries (
id uuid DEFAULT gen_random_uuid() NOT NULL,
menu_date date NOT NULL,
section_key text NOT NULL,
dish_id uuid NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
--
-- Name: profiles; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.profiles (
id uuid NOT NULL,
email text NOT NULL,
full_name text DEFAULT ''::text NOT NULL,
role text DEFAULT 'staff'::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
password_auth_enabled boolean DEFAULT true NOT NULL,
CONSTRAINT profiles_role_check CHECK ((role = ANY (ARRAY['admin'::text, 'staff'::text])))
);
--
-- Name: sections; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.sections (
key text NOT NULL,
label text NOT NULL,
time_range text NOT NULL,
sort integer NOT NULL,
label_en text,
label_pl text
);
--
-- Name: settings; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.settings (
id integer NOT NULL,
colors jsonb DEFAULT '{}'::jsonb NOT NULL,
logo_path text,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
favicon_path text,
CONSTRAINT settings_id_check CHECK ((id = 1))
);
--
-- Name: additives additives_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.additives
ADD CONSTRAINT additives_pkey PRIMARY KEY (code);
--
-- Name: allergens allergens_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.allergens
ADD CONSTRAINT allergens_pkey PRIMARY KEY (code);
--
-- Name: dish_suggestions dish_suggestions_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.dish_suggestions
ADD CONSTRAINT dish_suggestions_pkey PRIMARY KEY (id);
--
-- Name: dishes dishes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.dishes
ADD CONSTRAINT dishes_pkey PRIMARY KEY (id);
--
-- Name: menu_entries menu_entries_menu_date_section_key_dish_id_key; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.menu_entries
ADD CONSTRAINT menu_entries_menu_date_section_key_dish_id_key UNIQUE (menu_date, section_key, dish_id);
--
-- Name: menu_entries menu_entries_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.menu_entries
ADD CONSTRAINT menu_entries_pkey PRIMARY KEY (id);
--
-- Name: profiles profiles_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.profiles
ADD CONSTRAINT profiles_pkey PRIMARY KEY (id);
--
-- Name: sections sections_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.sections
ADD CONSTRAINT sections_pkey PRIMARY KEY (key);
--
-- Name: settings settings_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.settings
ADD CONSTRAINT settings_pkey PRIMARY KEY (id);
--
-- Name: menu_entries_date_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX menu_entries_date_idx ON public.menu_entries USING btree (menu_date);
--
-- Name: dishes dishes_touch_updated_at; Type: TRIGGER; Schema: public; Owner: -
--
CREATE TRIGGER dishes_touch_updated_at BEFORE UPDATE ON public.dishes FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
--
-- Name: settings settings_touch_updated_at; Type: TRIGGER; Schema: public; Owner: -
--
CREATE TRIGGER settings_touch_updated_at BEFORE UPDATE ON public.settings FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
--
-- Name: menu_entries menu_entries_dish_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.menu_entries
ADD CONSTRAINT menu_entries_dish_id_fkey FOREIGN KEY (dish_id) REFERENCES public.dishes(id) ON DELETE CASCADE;
--
-- Name: menu_entries menu_entries_section_key_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.menu_entries
ADD CONSTRAINT menu_entries_section_key_fkey FOREIGN KEY (section_key) REFERENCES public.sections(key);
--
-- Name: profiles profiles_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.profiles
ADD CONSTRAINT profiles_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE;
--
-- Name: additives; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.additives ENABLE ROW LEVEL SECURITY;
--
-- Name: additives additives_public_read; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY additives_public_read ON public.additives FOR SELECT TO authenticated, anon USING (true);
--
-- Name: additives additives_staff_write; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY additives_staff_write ON public.additives TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
--
-- Name: allergens; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.allergens ENABLE ROW LEVEL SECURITY;
--
-- Name: allergens allergens_public_read; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY allergens_public_read ON public.allergens FOR SELECT TO authenticated, anon USING (true);
--
-- Name: allergens allergens_staff_write; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY allergens_staff_write ON public.allergens TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
--
-- Name: dish_suggestions; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.dish_suggestions ENABLE ROW LEVEL SECURITY;
--
-- Name: dish_suggestions dish_suggestions_public_insert; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY dish_suggestions_public_insert ON public.dish_suggestions FOR INSERT TO authenticated, anon WITH CHECK (true);
--
-- Name: dish_suggestions dish_suggestions_staff_delete; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY dish_suggestions_staff_delete ON public.dish_suggestions FOR DELETE TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
--
-- Name: dish_suggestions dish_suggestions_staff_select; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY dish_suggestions_staff_select ON public.dish_suggestions FOR SELECT TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
--
-- Name: dishes; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.dishes ENABLE ROW LEVEL SECURITY;
--
-- Name: dishes dishes_public_read; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY dishes_public_read ON public.dishes FOR SELECT TO authenticated, anon USING (true);
--
-- Name: dishes dishes_staff_write; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY dishes_staff_write ON public.dishes TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
--
-- Name: menu_entries; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.menu_entries ENABLE ROW LEVEL SECURITY;
--
-- Name: menu_entries menu_entries_public_read; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY menu_entries_public_read ON public.menu_entries FOR SELECT TO authenticated, anon USING (true);
--
-- Name: menu_entries menu_entries_staff_write; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY menu_entries_staff_write ON public.menu_entries TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
--
-- Name: profiles; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
--
-- Name: profiles profiles_select_own_or_admin; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY profiles_select_own_or_admin ON public.profiles FOR SELECT TO authenticated USING (((id = ( SELECT auth.uid() AS uid)) OR (public.app_role() = 'admin'::text)));
--
-- Name: profiles profiles_update_own; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY profiles_update_own ON public.profiles FOR UPDATE TO authenticated USING ((id = ( SELECT auth.uid() AS uid))) WITH CHECK ((id = ( SELECT auth.uid() AS uid)));
--
-- Name: sections; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.sections ENABLE ROW LEVEL SECURITY;
--
-- Name: sections sections_public_read; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY sections_public_read ON public.sections FOR SELECT TO authenticated, anon USING (true);
--
-- Name: settings; Type: ROW SECURITY; Schema: public; Owner: -
--
ALTER TABLE public.settings ENABLE ROW LEVEL SECURITY;
--
-- Name: settings settings_admin_update; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY settings_admin_update ON public.settings FOR UPDATE TO authenticated USING ((public.app_role() = 'admin'::text)) WITH CHECK ((public.app_role() = 'admin'::text));
--
-- Name: settings settings_public_read; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY settings_public_read ON public.settings FOR SELECT TO authenticated, anon USING (true);
--
-- PostgreSQL database dump complete
--
-6
View File
@@ -1,6 +0,0 @@
-- Benutzer-Stammdaten für die lokale Kopie (ohne Passwort-Hashes –
-- Login ist nur gegen die Supabase-Instanz möglich).
insert into auth.users (id, email, raw_user_meta_data) values
('cee4160a-ab3a-4109-b706-b9f27d515596', 'fw@wildeflorian.de', '{"full_name": "Florian Wilde", "role": "admin"}'),
('102d405c-b010-41f6-9409-244004a3c567', 'kantine@example.com', '{"full_name": "Kantinen-Team", "role": "staff"}')
on conflict (id) do nothing;
-212
View File
@@ -1,212 +0,0 @@
--
-- PostgreSQL database dump
--
\restrict m2Vnc4kNXrHeKWS4dSX5SqMU5nUOxMfipXDVyLuYabYYzEbAHutmaRS8m10Dsdh
-- Dumped from database version 17.6
-- Dumped by pg_dump version 17.10
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET transaction_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Data for Name: additives; Type: TABLE DATA; Schema: public; Owner: -
--
SET SESSION AUTHORIZATION DEFAULT;
ALTER TABLE public.additives DISABLE TRIGGER ALL;
COPY public.additives (code, name, sort, name_en, name_pl) FROM stdin;
1 mit Farbstoff 1 with colouring z barwnikiem
2 mit Konservierungsstoff 2 with preservative z konserwantem
3 mit Antioxidationsmittel 3 with antioxidant z przeciwutleniaczem
4 mit Geschmacksverstärker 4 with flavour enhancer ze wzmacniaczem smaku
5 geschwefelt 5 sulphured siarkowany
6 geschwärzt 6 blackened barwiony na czarno
7 gewachst 7 waxed woskowany
8 mit Phosphat 8 with phosphate z fosforanem
9 mit Süßungsmittel(n) 9 with sweetener(s) ze słodzikiem/słodzikami
10 enthält eine Phenylalaninquelle 10 contains a source of phenylalanine zawiera źródło fenyloalaniny
11 koffeinhaltig 11 caffeinated zawiera kofeinę
\.
ALTER TABLE public.additives ENABLE TRIGGER ALL;
--
-- Data for Name: allergens; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.allergens DISABLE TRIGGER ALL;
COPY public.allergens (code, name, sort, name_en, name_pl) FROM stdin;
A Glutenhaltiges Getreide 1 Cereals containing gluten Zboża zawierające gluten
B Krebstiere 2 Crustaceans Skorupiaki
C Eier 3 Eggs Jaja
D Fisch 4 Fish Ryby
E Erdnüsse 5 Peanuts Orzeszki ziemne
F Sojabohnen 6 Soybeans Soja
G Milch (einschließlich Laktose) 7 Milk (including lactose) Mleko (w tym laktoza)
H Schalenfrüchte (Nüsse) 8 Tree nuts Orzechy
I Sellerie 9 Celery Seler
J Senf 10 Mustard Gorczyca
K Sesamsamen 11 Sesame seeds Nasiona sezamu
L Schwefeldioxid und Sulfite 12 Sulphur dioxide and sulphites Dwutlenek siarki i siarczyny
M Lupinen 13 Lupin Łubin
N Weichtiere 14 Molluscs Mięczaki
\.
ALTER TABLE public.allergens ENABLE TRIGGER ALL;
--
-- Data for Name: dish_suggestions; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.dish_suggestions DISABLE TRIGGER ALL;
COPY public.dish_suggestions (id, name, description, created_at) FROM stdin;
\.
ALTER TABLE public.dish_suggestions ENABLE TRIGGER ALL;
--
-- Data for Name: dishes; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.dishes DISABLE TRIGGER ALL;
COPY public.dishes (id, name, price, created_at, updated_at, allergens, additives, name_en, name_pl) FROM stdin;
bf612ae7-8cdd-4149-8b21-f651631ccc02 Hackfleischbaguette 3.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
81c58cc6-7298-4d4a-9997-71d17df5aedf Knusperhähnchen Wrap 3.70 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 Leberkäse im Brötchen 2.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
14ef6893-93a7-4649-afe3-d6b6bdf5903c Schnitzel Baguette 3.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 Penne mit Grünen Spargel und Garnelenspieße 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
dbe5ee6e-8b04-4bf7-99bb-f936f6564217 Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
c0613fe7-e5c6-489e-afe5-6da2533b2bd4 Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
c5cfa5b4-e502-4b56-b579-b9fbbf817f25 Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
f8ba1844-1f23-4b23-9e03-f59148f8b2ed Spaghetti mit fruchtiger Tomatensauce und Parmesan 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a Gebackener Feta mit griechischen Bauernsalat und Baguette 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
ff40ea65-63bc-4d01-b626-e0c9e5d2acb3 Gemüse-Curry mit Basmatireis 4.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
ea26f18b-ddf3-4899-95e7-5dbd0878d90c Kartoffelcremesuppe 1.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 Gulaschsuppe 2.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
3c8cf0c2-b0a9-43e3-8233-723d5718a157 Tomatensuppe mit Croutons 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
2dd85835-5425-4271-9fc0-e5215bbce476 Kürbiscremesuppe 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
06df753a-65ed-4bd0-a325-d32c78ec88fa Linseneintopf mit Würstchen 2.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {} \N \N
a987c96f-25a0-4e5e-b8a8-aab683a01feb Chili con Carne mit Brötchen 3.90 2026-07-17 15:17:02.979184+00 2026-07-17 22:21:35.276672+00 {A} {4} \N \N
7ebdf56a-9482-4bde-bf60-e6e5424e397b Kürbisrisotto 0.00 2026-07-17 23:57:48.351754+00 2026-07-17 23:57:48.351754+00 {} {} \N \N
fd356340-e10f-42d7-ae5a-3521a9151bb8 Test 0.00 2026-07-17 23:58:04.141825+00 2026-07-17 23:58:19.411846+00 {} {} abc bca
\.
ALTER TABLE public.dishes ENABLE TRIGGER ALL;
--
-- Data for Name: sections; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.sections DISABLE TRIGGER ALL;
COPY public.sections (key, label, time_range, sort, label_en, label_pl) FROM stdin;
brotzeit Brotzeit 09:00 - 10:00 1 Breakfast Śniadanie
mittagessen Mittagessen 11:30 - 13:30 2 Lunch Obiad
mittagessen_veg Mittagessen vegetarisch 11:30 - 13:30 3 Lunch (vegetarian) Obiad (wegetariański)
suppe Suppe 11:30 - 13:30 4 Soup Zupa
\.
ALTER TABLE public.sections ENABLE TRIGGER ALL;
--
-- Data for Name: menu_entries; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.menu_entries DISABLE TRIGGER ALL;
COPY public.menu_entries (id, menu_date, section_key, dish_id, "position", created_at) FROM stdin;
70469091-417b-47ed-aab5-747ff97e4ad4 2026-07-14 brotzeit bf612ae7-8cdd-4149-8b21-f651631ccc02 0 2026-07-17 15:17:02.979184+00
1e1f9b4e-75e7-413f-a747-c2884675d9f7 2026-07-15 brotzeit 81c58cc6-7298-4d4a-9997-71d17df5aedf 0 2026-07-17 15:17:02.979184+00
7b2089f7-7c01-4d6a-9e68-ebfa594bb713 2026-07-16 brotzeit cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 0 2026-07-17 15:17:02.979184+00
d6e24a0d-cc32-438d-9d91-e39ecbb82d5d 2026-07-17 brotzeit a987c96f-25a0-4e5e-b8a8-aab683a01feb 0 2026-07-17 15:17:02.979184+00
c165765a-1eb3-4d7e-9566-3768e9007793 2026-07-13 mittagessen 14ef6893-93a7-4649-afe3-d6b6bdf5903c 0 2026-07-17 15:17:02.979184+00
78be0a94-1b31-47ef-af62-40548d75aa4e 2026-07-14 mittagessen ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 0 2026-07-17 15:17:02.979184+00
0635198c-6aa9-4811-a8fd-67e31174eb73 2026-07-15 mittagessen dbe5ee6e-8b04-4bf7-99bb-f936f6564217 0 2026-07-17 15:17:02.979184+00
f02305b6-7650-4b52-b440-3df7045e17a7 2026-07-16 mittagessen c0613fe7-e5c6-489e-afe5-6da2533b2bd4 0 2026-07-17 15:17:02.979184+00
b838a6a4-1a58-469e-9201-af686c7a780a 2026-07-14 mittagessen_veg c5cfa5b4-e502-4b56-b579-b9fbbf817f25 0 2026-07-17 15:17:02.979184+00
4886ee1a-f8fd-4548-8483-893a0f2dd9aa 2026-07-15 mittagessen_veg f8ba1844-1f23-4b23-9e03-f59148f8b2ed 0 2026-07-17 15:17:02.979184+00
1f6400bb-0b9a-444c-b793-e1cbd96ec215 2026-07-16 mittagessen_veg 7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a 0 2026-07-17 15:17:02.979184+00
7df88a12-6fb8-457e-8cf8-5ddc43e06583 2026-07-13 suppe ea26f18b-ddf3-4899-95e7-5dbd0878d90c 0 2026-07-17 15:17:02.979184+00
e2bfe62e-12c4-4f68-a7e7-ca1ee30a6039 2026-07-14 suppe 6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 0 2026-07-17 15:17:02.979184+00
662902e0-bb5e-4268-b977-cef2ac20dc35 2026-07-15 suppe 3c8cf0c2-b0a9-43e3-8233-723d5718a157 0 2026-07-17 15:17:02.979184+00
6083a3f3-a54e-40c5-be1f-c96e474eb8a0 2026-07-16 suppe 2dd85835-5425-4271-9fc0-e5215bbce476 0 2026-07-17 15:17:02.979184+00
36279cd1-b0e2-46a8-b46c-c23f9f5dbb1b 2026-07-17 suppe 06df753a-65ed-4bd0-a325-d32c78ec88fa 0 2026-07-17 15:17:02.979184+00
bfa36cb8-82d6-466c-b954-bf82c6a67b95 2026-07-23 brotzeit bf612ae7-8cdd-4149-8b21-f651631ccc02 0 2026-07-17 15:17:02.979184+00
b86caf93-34eb-46a3-bbe4-3a67dcf79be0 2026-07-21 brotzeit 81c58cc6-7298-4d4a-9997-71d17df5aedf 0 2026-07-17 15:17:02.979184+00
d8a962cd-f1cf-4f87-95fb-f5af79cbd0a5 2026-07-20 brotzeit cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 0 2026-07-17 15:17:02.979184+00
c800ef28-70ba-4439-8f1d-e5dcec3ce1c6 2026-07-24 brotzeit a987c96f-25a0-4e5e-b8a8-aab683a01feb 0 2026-07-17 15:17:02.979184+00
a5c54296-1667-4d5d-a62d-3983e1643959 2026-07-22 mittagessen 14ef6893-93a7-4649-afe3-d6b6bdf5903c 0 2026-07-17 15:17:02.979184+00
4a06dd6c-7de8-4628-b491-3dafd409f344 2026-07-23 mittagessen ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 0 2026-07-17 15:17:02.979184+00
ea9a2936-95bf-4cbd-a013-db7ba995efce 2026-07-20 mittagessen dbe5ee6e-8b04-4bf7-99bb-f936f6564217 0 2026-07-17 15:17:02.979184+00
64bc501f-dbcd-474c-b47b-1e99670a3c3e 2026-07-21 mittagessen c0613fe7-e5c6-489e-afe5-6da2533b2bd4 0 2026-07-17 15:17:02.979184+00
dd4105b4-76b8-4ea1-8ee0-6db663a1ffa8 2026-07-23 mittagessen_veg c5cfa5b4-e502-4b56-b579-b9fbbf817f25 0 2026-07-17 15:17:02.979184+00
bac80cc9-ad0e-4cee-81ae-8aa405cad1b9 2026-07-21 mittagessen_veg f8ba1844-1f23-4b23-9e03-f59148f8b2ed 0 2026-07-17 15:17:02.979184+00
62bbb0f9-88e2-4b9f-a7e5-c0dab5ff54c8 2026-07-22 mittagessen_veg 7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a 0 2026-07-17 15:17:02.979184+00
167e0869-126d-4ab7-8e74-051e5c782865 2026-07-20 mittagessen_veg ff40ea65-63bc-4d01-b626-e0c9e5d2acb3 0 2026-07-17 15:17:02.979184+00
34031197-4c3a-4b7e-bc05-7ad7254e8348 2026-07-21 suppe ea26f18b-ddf3-4899-95e7-5dbd0878d90c 0 2026-07-17 15:17:02.979184+00
a6f66639-7a0a-4129-bcea-c6ac9cbccac3 2026-07-20 suppe 6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 0 2026-07-17 15:17:02.979184+00
b0dbf487-c704-4ab2-af67-082addc88039 2026-07-23 suppe 3c8cf0c2-b0a9-43e3-8233-723d5718a157 0 2026-07-17 15:17:02.979184+00
d7b5d414-85c2-495a-9945-2ffc10de32ea 2026-07-24 suppe 2dd85835-5425-4271-9fc0-e5215bbce476 0 2026-07-17 15:17:02.979184+00
7a025e66-44b7-43e7-a51b-5564ab8150b1 2026-07-22 suppe 06df753a-65ed-4bd0-a325-d32c78ec88fa 0 2026-07-17 15:17:02.979184+00
2eb81b52-3c50-4449-bef9-bb12aac9991e 2026-07-13 brotzeit 6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 0 2026-07-17 15:43:22.829239+00
\.
ALTER TABLE public.menu_entries ENABLE TRIGGER ALL;
--
-- Data for Name: profiles; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.profiles DISABLE TRIGGER ALL;
COPY public.profiles (id, email, full_name, role, created_at, password_auth_enabled) FROM stdin;
cee4160a-ab3a-4109-b706-b9f27d515596 fw@wildeflorian.de Florian Wilde admin 2026-07-17 15:17:33.46696+00 t
6e3ebbd4-a00a-4b08-a799-1a445a3f9eaa test@test.de Hans admin 2026-07-17 16:56:13.331443+00 t
102d405c-b010-41f6-9409-244004a3c567 kantine@example.com Kantinen-Team staff 2026-07-17 15:17:34.233848+00 t
952b21ad-65a0-4a12-aed1-6c86709e0eb2 kantine@kantine.de Kantine staff 2026-07-17 22:02:36.854844+00 t
\.
ALTER TABLE public.profiles ENABLE TRIGGER ALL;
--
-- Data for Name: settings; Type: TABLE DATA; Schema: public; Owner: -
--
ALTER TABLE public.settings DISABLE TRIGGER ALL;
COPY public.settings (id, colors, logo_path, updated_at, favicon_path) FROM stdin;
1 {"rowBg": "#e3e4f1", "accent": "#ff6900", "addBtn": "#6da54c", "primary": "#003388", "rowBgAlt": "#f4f4fa", "deleteBtn": "#e2371d", "headerText": "#ffffff", "primaryDark": "#0d315c"} logo-1784305413466.webp 2026-07-17 22:40:52.997491+00 \N
\.
ALTER TABLE public.settings ENABLE TRIGGER ALL;
--
-- PostgreSQL database dump complete
--
\unrestrict m2Vnc4kNXrHeKWS4dSX5SqMU5nUOxMfipXDVyLuYabYYzEbAHutmaRS8m10Dsdh
-217
View File
@@ -1,217 +0,0 @@
-- =============================================================
-- Speisekarte – Initiales Schema
-- Rollen: admin (Benutzerverwaltung, Branding) / staff (Kantine)
-- Öffentliche Speisekarte: lesbar ohne Login (anon)
-- =============================================================
-- ---------- Profile (1:1 zu auth.users) ----------
create table public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
email text not null,
full_name text not null default '',
role text not null default 'staff' check (role in ('admin', 'staff')),
created_at timestamptz not null default now()
);
-- Rolle des eingeloggten Benutzers ermitteln.
-- security definer: umgeht RLS auf profiles und verhindert Policy-Rekursion.
create or replace function public.app_role()
returns text
language sql
stable
security definer
set search_path = public
as $$
select role from public.profiles where id = auth.uid()
$$;
-- Profil automatisch anlegen, wenn ein Auth-Benutzer erstellt wird.
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, email, full_name, role)
values (
new.id,
new.email,
coalesce(new.raw_user_meta_data ->> 'full_name', ''),
coalesce(new.raw_user_meta_data ->> 'role', 'staff')
)
on conflict (id) do nothing;
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- ---------- Bereiche der Speisekarte ----------
create table public.sections (
key text primary key,
label text not null,
time_range text not null,
sort int not null
);
insert into public.sections (key, label, time_range, sort) values
('brotzeit', 'Brotzeit', '09:00 - 10:00', 1),
('mittagessen', 'Mittagessen', '11:30 - 13:30', 2),
('mittagessen_veg', 'Mittagessen vegetarisch', '11:30 - 13:30', 3),
('suppe', 'Suppe', '11:30 - 13:30', 4);
-- ---------- Allergene & Zusatzstoffe ----------
-- Gelten für die komplette Speisekarte (keine Zuordnung pro Gericht)
-- und werden unterhalb der Karte angezeigt. Pflege durch Kantinen-Team.
create table public.allergens (
code text primary key,
name text not null,
sort int not null default 0
);
create table public.additives (
code text primary key,
name text not null,
sort int not null default 0
);
-- ---------- Gerichte ----------
create table public.dishes (
id uuid primary key default gen_random_uuid(),
name text not null,
price numeric(6, 2) not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create or replace function public.touch_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at := now();
return new;
end;
$$;
create trigger dishes_touch_updated_at
before update on public.dishes
for each row execute function public.touch_updated_at();
-- ---------- Zuweisung Gericht -> Tag/Bereich ----------
create table public.menu_entries (
id uuid primary key default gen_random_uuid(),
menu_date date not null,
section_key text not null references public.sections (key),
dish_id uuid not null references public.dishes (id) on delete cascade,
position int not null default 0,
created_at timestamptz not null default now(),
unique (menu_date, section_key, dish_id)
);
create index menu_entries_date_idx on public.menu_entries (menu_date);
-- ---------- Einstellungen (Farben, Logo) – genau eine Zeile ----------
create table public.settings (
id int primary key check (id = 1),
colors jsonb not null default '{}'::jsonb,
logo_path text,
updated_at timestamptz not null default now()
);
insert into public.settings (id, colors) values (
1,
'{
"primary": "#003388",
"primaryDark": "#0d315c",
"accent": "#ff6900",
"headerText": "#ffffff",
"rowBg": "#e3e4f1",
"rowBgAlt": "#f4f4fa",
"addBtn": "#6da54c",
"deleteBtn": "#e2371d"
}'::jsonb
);
create trigger settings_touch_updated_at
before update on public.settings
for each row execute function public.touch_updated_at();
-- =============================================================
-- Row Level Security
-- =============================================================
alter table public.profiles enable row level security;
alter table public.sections enable row level security;
alter table public.allergens enable row level security;
alter table public.additives enable row level security;
alter table public.dishes enable row level security;
alter table public.menu_entries enable row level security;
alter table public.settings enable row level security;
-- Profiles: eigenes Profil lesen, Admin liest alle.
-- Anlegen/Ändern/Löschen von Benutzern läuft serverseitig über den
-- Service-Role-Key (umgeht RLS) und wird dort auf Admins beschränkt.
create policy "profiles_select_own_or_admin"
on public.profiles for select
to authenticated
using (id = (select auth.uid()) or public.app_role() = 'admin');
-- Öffentlich lesbare Tabellen (Speisekarte ohne Login)
create policy "sections_public_read" on public.sections for select to anon, authenticated using (true);
create policy "allergens_public_read" on public.allergens for select to anon, authenticated using (true);
create policy "additives_public_read" on public.additives for select to anon, authenticated using (true);
create policy "dishes_public_read" on public.dishes for select to anon, authenticated using (true);
create policy "menu_entries_public_read" on public.menu_entries for select to anon, authenticated using (true);
create policy "settings_public_read" on public.settings for select to anon, authenticated using (true);
-- Kantinenmitarbeiter (staff) und Admin: Gerichte & Zuweisungen pflegen
create policy "dishes_staff_write" on public.dishes
for all to authenticated
using (public.app_role() in ('staff', 'admin'))
with check (public.app_role() in ('staff', 'admin'));
create policy "menu_entries_staff_write" on public.menu_entries
for all to authenticated
using (public.app_role() in ('staff', 'admin'))
with check (public.app_role() in ('staff', 'admin'));
create policy "allergens_staff_write" on public.allergens
for all to authenticated
using (public.app_role() in ('staff', 'admin'))
with check (public.app_role() in ('staff', 'admin'));
create policy "additives_staff_write" on public.additives
for all to authenticated
using (public.app_role() in ('staff', 'admin'))
with check (public.app_role() in ('staff', 'admin'));
-- Einstellungen: nur Admin ändert
create policy "settings_admin_update" on public.settings
for update to authenticated
using (public.app_role() = 'admin')
with check (public.app_role() = 'admin');
-- =============================================================
-- Storage: Bucket "branding" für das Logo (öffentlich lesbar)
-- =============================================================
insert into storage.buckets (id, name, public)
values ('branding', 'branding', true);
create policy "branding_public_read" on storage.objects
for select to anon, authenticated
using (bucket_id = 'branding');
create policy "branding_admin_insert" on storage.objects
for insert to authenticated
with check (bucket_id = 'branding' and public.app_role() = 'admin');
create policy "branding_admin_update" on storage.objects
for update to authenticated
using (bucket_id = 'branding' and public.app_role() = 'admin');
create policy "branding_admin_delete" on storage.objects
for delete to authenticated
using (bucket_id = 'branding' and public.app_role() = 'admin');
-114
View File
@@ -1,114 +0,0 @@
-- =============================================================
-- Seed-Daten: Allergene, Zusatzstoffe, Testgerichte, Beispielwochen
-- =============================================================
-- ---------- Allergene (EU-Kennzeichnung, Buchstaben) ----------
insert into public.allergens (code, name, sort) values
('A', 'Glutenhaltiges Getreide', 1),
('B', 'Krebstiere', 2),
('C', 'Eier', 3),
('D', 'Fisch', 4),
('E', 'Erdnüsse', 5),
('F', 'Sojabohnen', 6),
('G', 'Milch (einschließlich Laktose)', 7),
('H', 'Schalenfrüchte (Nüsse)', 8),
('I', 'Sellerie', 9),
('J', 'Senf', 10),
('K', 'Sesamsamen', 11),
('L', 'Schwefeldioxid und Sulfite', 12),
('M', 'Lupinen', 13),
('N', 'Weichtiere', 14);
-- ---------- Zusatzstoffe (Ziffern) ----------
insert into public.additives (code, name, sort) values
('1', 'mit Farbstoff', 1),
('2', 'mit Konservierungsstoff', 2),
('3', 'mit Antioxidationsmittel', 3),
('4', 'mit Geschmacksverstärker', 4),
('5', 'geschwefelt', 5),
('6', 'geschwärzt', 6),
('7', 'gewachst', 7),
('8', 'mit Phosphat', 8),
('9', 'mit Süßungsmittel(n)', 9),
('10', 'enthält eine Phenylalaninquelle', 10),
('11', 'koffeinhaltig', 11);
-- ---------- Testgerichte ----------
insert into public.dishes (name, price) values
-- Brotzeit
('1 Stk. Rindskäsewurst mit Brötchen', 2.30),
('Hackfleischbaguette', 3.20),
('Knusperhähnchen Wrap', 3.70),
('Leberkäse im Brötchen', 2.30),
('Chili con Carne mit Brötchen', 3.90),
-- Mittagessen
('Schnitzel Baguette', 3.50),
('Penne mit Grünen Spargel und Garnelenspieße', 5.90),
('Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip', 5.90),
('Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle', 5.90),
-- Mittagessen vegetarisch
('Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten', 5.30),
('Spaghetti mit fruchtiger Tomatensauce und Parmesan', 5.30),
('Gebackener Feta mit griechischen Bauernsalat und Baguette', 5.30),
('Gemüse-Curry mit Basmatireis', 4.90),
-- Suppen
('Kartoffelcremesuppe', 1.50),
('Gulaschsuppe', 2.50),
('Tomatensuppe mit Croutons', 1.80),
('Kürbiscremesuppe', 1.80),
('Linseneintopf mit Würstchen', 2.20);
-- ---------- Beispielwoche 13.–17. Juli 2026 (wie Screenshot) ----------
with d as (select id, name from public.dishes)
insert into public.menu_entries (menu_date, section_key, dish_id)
select x.menu_date::date, x.section_key, d.id
from (values
-- Brotzeit
('2026-07-13', 'brotzeit', '1 Stk. Rindskäsewurst mit Brötchen'),
('2026-07-14', 'brotzeit', 'Hackfleischbaguette'),
('2026-07-15', 'brotzeit', 'Knusperhähnchen Wrap'),
('2026-07-16', 'brotzeit', 'Leberkäse im Brötchen'),
('2026-07-17', 'brotzeit', 'Chili con Carne mit Brötchen'),
-- Mittagessen
('2026-07-13', 'mittagessen', 'Schnitzel Baguette'),
('2026-07-14', 'mittagessen', 'Penne mit Grünen Spargel und Garnelenspieße'),
('2026-07-15', 'mittagessen', 'Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip'),
('2026-07-16', 'mittagessen', 'Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle'),
-- Mittagessen vegetarisch
('2026-07-14', 'mittagessen_veg', 'Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten'),
('2026-07-15', 'mittagessen_veg', 'Spaghetti mit fruchtiger Tomatensauce und Parmesan'),
('2026-07-16', 'mittagessen_veg', 'Gebackener Feta mit griechischen Bauernsalat und Baguette'),
-- Suppe
('2026-07-13', 'suppe', 'Kartoffelcremesuppe'),
('2026-07-14', 'suppe', 'Gulaschsuppe'),
('2026-07-15', 'suppe', 'Tomatensuppe mit Croutons'),
('2026-07-16', 'suppe', 'Kürbiscremesuppe'),
('2026-07-17', 'suppe', 'Linseneintopf mit Würstchen')
) as x (menu_date, section_key, name)
join d on d.name = x.name;
-- ---------- Beispielwoche 20.–24. Juli 2026 ("Nächste Woche") ----------
with d as (select id, name from public.dishes)
insert into public.menu_entries (menu_date, section_key, dish_id)
select x.menu_date::date, x.section_key, d.id
from (values
('2026-07-20', 'brotzeit', 'Leberkäse im Brötchen'),
('2026-07-21', 'brotzeit', 'Knusperhähnchen Wrap'),
('2026-07-22', 'brotzeit', '1 Stk. Rindskäsewurst mit Brötchen'),
('2026-07-23', 'brotzeit', 'Hackfleischbaguette'),
('2026-07-24', 'brotzeit', 'Chili con Carne mit Brötchen'),
('2026-07-20', 'mittagessen', 'Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip'),
('2026-07-21', 'mittagessen', 'Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle'),
('2026-07-22', 'mittagessen', 'Schnitzel Baguette'),
('2026-07-23', 'mittagessen', 'Penne mit Grünen Spargel und Garnelenspieße'),
('2026-07-20', 'mittagessen_veg', 'Gemüse-Curry mit Basmatireis'),
('2026-07-21', 'mittagessen_veg', 'Spaghetti mit fruchtiger Tomatensauce und Parmesan'),
('2026-07-22', 'mittagessen_veg', 'Gebackener Feta mit griechischen Bauernsalat und Baguette'),
('2026-07-23', 'mittagessen_veg', 'Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten'),
('2026-07-20', 'suppe', 'Gulaschsuppe'),
('2026-07-21', 'suppe', 'Kartoffelcremesuppe'),
('2026-07-22', 'suppe', 'Linseneintopf mit Würstchen'),
('2026-07-23', 'suppe', 'Tomatensuppe mit Croutons'),
('2026-07-24', 'suppe', 'Kürbiscremesuppe')
) as x (menu_date, section_key, name)
join d on d.name = x.name;
@@ -1,36 +0,0 @@
-- =============================================================
-- Selbstverwaltung des eigenen Kontos (Name, E-Mail-Abgleich)
-- =============================================================
-- Jeder Benutzer darf seinen eigenen Anzeigenamen ändern. Die Spalte
-- "role" bleibt geschützt: Rechte werden nicht per RLS eingeschränkt,
-- sondern per Spalten-Grant – selbst ein manuell gebauter Request mit
-- {"role": "admin"} im Payload scheitert an fehlenden Rechten auf die
-- Spalte. Rollenänderungen laufen weiterhin ausschließlich über die
-- Admin-API mit dem Service-Role-Key.
revoke update on public.profiles from authenticated;
grant update (full_name) on public.profiles to authenticated;
create policy "profiles_update_own_name"
on public.profiles for update
to authenticated
using (id = (select auth.uid()))
with check (id = (select auth.uid()));
-- Hält profiles.email synchron, sobald eine E-Mail-Änderung in
-- auth.users bestätigt wurde (supabase.auth.updateUser({ email })).
create or replace function public.handle_user_email_change()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
update public.profiles set email = new.email where id = new.id;
return new;
end;
$$;
create trigger on_auth_user_email_updated
after update of email on auth.users
for each row execute function public.handle_user_email_change();
@@ -1,32 +0,0 @@
-- =============================================================
-- Allergene/Zusatzstoffe pro Gericht, Passwort-Login abschaltbar,
-- austauschbares Favicon
-- =============================================================
-- ---------- Allergene/Zusatzstoffe je Gericht ----------
-- Referenziert Kürzel aus den globalen Listen (allergens/additives),
-- die weiterhin unterhalb der Speisekarte als Legende dienen.
alter table public.dishes
add column allergens text[] not null default '{}',
add column additives text[] not null default '{}';
-- ---------- Passwort-Login pro Benutzer abschaltbar ----------
alter table public.profiles
add column password_auth_enabled boolean not null default true;
-- Selbstverwaltung: Nutzer dürfen zusätzlich zum Namen (Migration 0003)
-- jetzt auch dieses eine Flag für ihr eigenes Konto setzen. Weiterhin
-- kein Zugriff auf "role" oder "email" über diesen Weg.
grant update (password_auth_enabled) on public.profiles to authenticated;
-- Klarerer Name für die inzwischen zwei selbstverwaltbaren Spalten.
drop policy "profiles_update_own_name" on public.profiles;
create policy "profiles_update_own"
on public.profiles for update
to authenticated
using (id = (select auth.uid()))
with check (id = (select auth.uid()));
-- ---------- Austauschbares Favicon ----------
alter table public.settings
add column favicon_path text;
@@ -1,68 +0,0 @@
-- =============================================================
-- Mehrsprachigkeit (Inhalte) + Rezeptvorschläge
-- =============================================================
-- ---------- Übersetzungsspalten ----------
-- Nullable: ohne Übersetzung fällt die Anzeige auf den deutschen
-- Text zurück. Werden manuell oder per DeepL-Anbindung befüllt.
alter table public.dishes add column name_en text, add column name_pl text;
alter table public.sections add column label_en text, add column label_pl text;
alter table public.allergens add column name_en text, add column name_pl text;
alter table public.additives add column name_en text, add column name_pl text;
-- ---------- Referenz-Übersetzungen (feste EU-Kennzeichnung) ----------
update public.sections set label_en = 'Breakfast', label_pl = 'Śniadanie' where key = 'brotzeit';
update public.sections set label_en = 'Lunch', label_pl = 'Obiad' where key = 'mittagessen';
update public.sections set label_en = 'Lunch (vegetarian)', label_pl = 'Obiad (wegetariański)' where key = 'mittagessen_veg';
update public.sections set label_en = 'Soup', label_pl = 'Zupa' where key = 'suppe';
update public.allergens set name_en = 'Cereals containing gluten', name_pl = 'Zboża zawierające gluten' where code = 'A';
update public.allergens set name_en = 'Crustaceans', name_pl = 'Skorupiaki' where code = 'B';
update public.allergens set name_en = 'Eggs', name_pl = 'Jaja' where code = 'C';
update public.allergens set name_en = 'Fish', name_pl = 'Ryby' where code = 'D';
update public.allergens set name_en = 'Peanuts', name_pl = 'Orzeszki ziemne' where code = 'E';
update public.allergens set name_en = 'Soybeans', name_pl = 'Soja' where code = 'F';
update public.allergens set name_en = 'Milk (including lactose)', name_pl = 'Mleko (w tym laktoza)' where code = 'G';
update public.allergens set name_en = 'Tree nuts', name_pl = 'Orzechy' where code = 'H';
update public.allergens set name_en = 'Celery', name_pl = 'Seler' where code = 'I';
update public.allergens set name_en = 'Mustard', name_pl = 'Gorczyca' where code = 'J';
update public.allergens set name_en = 'Sesame seeds', name_pl = 'Nasiona sezamu' where code = 'K';
update public.allergens set name_en = 'Sulphur dioxide and sulphites', name_pl = 'Dwutlenek siarki i siarczyny' where code = 'L';
update public.allergens set name_en = 'Lupin', name_pl = 'Łubin' where code = 'M';
update public.allergens set name_en = 'Molluscs', name_pl = 'Mięczaki' where code = 'N';
update public.additives set name_en = 'with colouring', name_pl = 'z barwnikiem' where code = '1';
update public.additives set name_en = 'with preservative', name_pl = 'z konserwantem' where code = '2';
update public.additives set name_en = 'with antioxidant', name_pl = 'z przeciwutleniaczem' where code = '3';
update public.additives set name_en = 'with flavour enhancer', name_pl = 'ze wzmacniaczem smaku' where code = '4';
update public.additives set name_en = 'sulphured', name_pl = 'siarkowany' where code = '5';
update public.additives set name_en = 'blackened', name_pl = 'barwiony na czarno' where code = '6';
update public.additives set name_en = 'waxed', name_pl = 'woskowany' where code = '7';
update public.additives set name_en = 'with phosphate', name_pl = 'z fosforanem' where code = '8';
update public.additives set name_en = 'with sweetener(s)', name_pl = 'ze słodzikiem/słodzikami' where code = '9';
update public.additives set name_en = 'contains a source of phenylalanine', name_pl = 'zawiera źródło fenyloalaniny' where code = '10';
update public.additives set name_en = 'caffeinated', name_pl = 'zawiera kofeinę' where code = '11';
-- ---------- Rezeptvorschläge ----------
create table public.dish_suggestions (
id uuid primary key default gen_random_uuid(),
name text not null,
description text,
created_at timestamptz not null default now()
);
alter table public.dish_suggestions enable row level security;
-- Jeder darf einen Vorschlag einreichen, aber niemand außer dem
-- Kantinen-Team darf die Liste einsehen (kein Lesezugriff für anon).
create policy "dish_suggestions_public_insert" on public.dish_suggestions
for insert to anon, authenticated
with check (true);
create policy "dish_suggestions_staff_select" on public.dish_suggestions
for select to authenticated
using (public.app_role() in ('staff', 'admin'));
create policy "dish_suggestions_staff_delete" on public.dish_suggestions
for delete to authenticated
using (public.app_role() in ('staff', 'admin'));