Compare commits

..
35 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
florian.wilde 8b98294998 Merge feature/v1.3: Mehrsprachigkeit (DE/EN/PL), Rezeptvorschläge, Bugfixes
- Mehrsprachige Oberfläche mit Sprachumschalter (Cookie-basiert)
- Gerichte/Bereiche/Allergene/Zusatzstoffe mit EN/PL-Namen, automatische
  Übersetzung über DeepL (optional, DEEPL_API_KEY) mit manueller
  Korrekturmöglichkeit
- Rezeptvorschläge: öffentliches Formular mit Dubletten-Check und
  Honeypot-Spamschutz, Sichtung/Übernahme im Kantinen-Bereich
- Fix: Passwort-wiederholen-Feld beim Bearbeiten erschien erst nach
  Tippen im ersten Feld
- Fix: Anmelden-Button überlappte auf Mobilgeräten mit breiten Logos

Version 1.3.0
2026-07-18 02:12:23 +02:00
florian.wilde a5b68d577d Version 1.3.0 2026-07-18 02:08:04 +02:00
florian.wilde babc55e781 Rezeptvorschläge: öffentliches Formular + Sichtung im Kantinen-Bereich
Nicht angemeldete Besucher können unter /vorschlag ein fehlendes
Gericht vorschlagen (Name + optionale Beschreibung); Dublettenprüfung
gegen bestehende Gerichte, einfacher Honeypot gegen Bot-Spam. Staff/
Admin sichten eingereichte Vorschläge unter /intern/vorschlaege und
übernehmen sie als Gericht oder lehnen sie ab.
2026-07-18 02:08:03 +02:00
florian.wilde a6165de10c Mehrsprachige Oberfläche + lokalisierte Gericht-/Bereichsnamen
Alle Seiten und Komponenten (öffentliche Speisekarte, Login, Kantinen-
und Admin-Bereich, eigenes Konto) verwenden jetzt die Wörterbücher aus
lib/i18n statt fest codierter deutscher Texte. Gerichte/Bereiche/
Allergene/Zusatzstoffe zeigen den Namen zur aktuell gewählten Sprache;
Gerichte- und Kennzeichnungs-Formulare bekommen Englisch-/Polnisch-
Felder mit Übersetzen-Button (siehe vorherigen Commit). Datumsformat-
ierung (formatDateLong) nimmt jetzt ein locale-Argument statt fix
'de-DE'.
2026-07-18 02:08:03 +02:00
florian.wilde 30a02ba7b7 DeepL-Anbindung für automatische Übersetzung
app/api/translate ruft DeepL für EN+PL auf und schreibt das Ergebnis
in die jeweilige _en/_pl-Spalte (Staff/Admin-only, Service-Role-Key).
Ohne DEEPL_API_KEY (neue Docker-Variable) antwortet die Route mit
configured:false, das Frontend zeigt dann den Hinweis zur manuellen
Eingabe statt eines Fehlers.
2026-07-18 02:08:02 +02:00
florian.wilde 1cc19de221 Migration: Übersetzungsspalten für Inhalte + Rezeptvorschläge-Tabelle
dishes/sections/allergens/additives bekommen _en/_pl-Spalten; die 14
EU-Allergene, 11 Zusatzstoff-Kennzeichnungen und 4 Bereiche sind direkt
mit Referenz-Übersetzungen befüllt. localized() liefert den Text zur
aktuellen Sprache mit Fallback auf Deutsch. Neue Tabelle
dish_suggestions (RLS: jeder darf einreichen, nur Staff/Admin lesen).
Lokale PostgreSQL-Kopie auf den neuen Stand gebracht.
2026-07-18 02:08:01 +02:00
florian.wilde 33909c660b i18n-Fundament: Cookie-basierte Sprachumschaltung (DE/EN/PL)
Wörterbücher unter lib/i18n statt next-intl (Next.js 16 ist neu genug,
dass eine externe Routing-Middleware zusätzliches Risiko bedeutet
hätte); Sprache liegt in einem Cookie, kein URL-Präfix nötig, dadurch
bleibt proxy.ts unverändert. LocaleProvider stellt Wörterbuch + Sprache
per Context für Client-Komponenten bereit (lib/i18n/shared.ts ist
bewusst getrennt von lib/i18n/index.ts, damit next/headers nicht in
den Client-Bundle gelangt).
2026-07-18 02:08:01 +02:00
florian.wilde d96faad4d5 Changelog: 1.3.0 vorbereiten (geplante Features, bereits behobene Bugs) 2026-07-18 01:29:57 +02:00
florian.wilde 0c4fad9649 Fix: Anmelden-Button überlappte auf Mobilgeräten mit breitem Logo
Absolute Positionierung im Header kollidierte mit Logos in breitem
Seitenverhältnis (z. B. Banner-Format), da das Logo bei h-20 nahezu
bildschirmbreit gerendert wird. Anmelden-Link steht jetzt auf eigener
Zeile über dem Logo statt absolut positioniert in der oberen Ecke.
2026-07-18 01:29:27 +02:00
florian.wilde 5c9df6e33b Fix: Passwort-wiederholen-Feld beim Bearbeiten erst nach Tippen sichtbar
Feld war beim Bearbeiten an form.password gebunden und erschien erst,
nachdem im ersten Feld schon getippt wurde – wirkte, als reiche ein
einziges Passwortfeld. Jetzt immer sichtbar, Pflicht bleibt weiterhin
nur, wenn tatsächlich ein neues Passwort gesetzt wird.
2026-07-18 01:29:27 +02:00
florian.wilde bb6093dbfe Changelog hinzufügen 2026-07-18 00:49:01 +02:00
florian.wilde 088df8a26b Version 1.2.0
Versionsnummer und README auf den aktuellen Funktionsstand gebracht.
2026-07-18 00:46:41 +02:00
florian.wilde 39181be3fb SITE_TITLE per Docker-Variable, Favicon im Admin-Bereich austauschbar
- SITE_TITLE (Default "Canteen Menu") steuert Browser-Tab-Titel und
  H1-Fallback ohne Logo
- Favicon-Upload analog zum Logo über den Design-Bereich (Storage-
  Bucket branding); generateMetadata setzt icons.icon jetzt immer
  explizit, da Next.js sonst app/favicon.ico zusätzlich einbindet –
  die Datei liegt daher als expliziter Fallback unter public/
2026-07-18 00:46:41 +02:00
florian.wilde 72459d8da5 Konto: Passwort-Anmeldung pro Nutzer deaktivierbar
Schalter im eigenen Konto, erzwingt vorher mindestens einen
registrierten Passkey (sonst Aussperrung). Durchsetzung beim Login:
nach erfolgreicher Passwort-Prüfung wird bei deaktiviertem Flag sofort
wieder abgemeldet; Passkey-Anmeldung bleibt davon unberührt.
2026-07-18 00:46:40 +02:00
florian.wilde 00745ae48b Benutzerverwaltung: Passwort-Wiederholung gegen Tippfehler
Gilt beim Anlegen (Pflicht) und beim optionalen Passwort-Reset im
Bearbeiten-Formular.
2026-07-18 00:46:39 +02:00
florian.wilde 675503e1f4 Allergene/Zusatzstoffe je Gericht auswählbar, klein angezeigt
Chip-Auswahl im Gericht-Formular (Klick zum An-/Abwählen) statt
Checkbox-Liste; Kürzel erscheinen hochgestellt neben dem Gerichtnamen
in Speisekarte, mobiler Ansicht und Wochenplan-Editor. Editor bekommt
außerdem einen Heute-Button.
2026-07-18 00:46:39 +02:00
florian.wilde 1db89a54bf Öffentliche Speisekarte: Heute-Button, mobile Tages-/Wochenansicht
- "Heute" springt direkt zur aktuellen Woche
- Unter dem sm-Breakpoint umschaltbar zwischen Tag (Standard) und
  Woche statt der Tabelle; Desktop bleibt vollständig serverseitig
  gerendert, nur der mobile Umschalter ist eine Client-Komponente
2026-07-18 00:46:39 +02:00
florian.wilde 29ca8e1227 Migration: Allergene/Zusatzstoffe je Gericht, abschaltbarer Passwort-Login, Favicon
- dishes.allergens/additives (text[]): Kennzeichnung wieder pro Gericht
  statt nur global, referenziert weiterhin die globalen Listen
- profiles.password_auth_enabled: Nutzer können die eigene
  Passwort-Anmeldung deaktivieren; Spalten-Grant + neue Policy
  "profiles_update_own" (ersetzt profiles_update_own_name)
- settings.favicon_path fürs austauschbare Favicon
- Lokale PostgreSQL-Kopie (Docker + nativ) auf den neuen Stand gebracht
2026-07-18 00:46:38 +02:00
florian.wilde 4e4abed0ca Version 1.1.0 mit Anzeige im Fußbereich
Versionsnummer aus package.json wird auf jeder Seite unten rechts
dezent eingeblendet.
2026-07-17 23:53:13 +02:00
104 changed files with 6004 additions and 2229 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ node_modules
.env* .env*
!.env.example !.env.example
ZUGANGSDATEN.md ZUGANGSDATEN.md
supabase/local data/branding
Dockerfile Dockerfile
docker-compose.yml docker-compose.yml
README.md README.md
+18 -11
View File
@@ -1,18 +1,25 @@
# Vorlage für .env.local (Entwicklung) bzw. die Umgebungsvariablen # Vorlage für .env.local (Entwicklung) bzw. die Umgebungsvariablen
# beim Hoster (Live-Betrieb). Echte Werte niemals committen! # beim Hoster (Live-Betrieb). Echte Werte niemals committen!
# Supabase-Projekt-URL (Dashboard -> Project Settings -> API) # Verbindung zur (selbst gehosteten) PostgreSQL-Datenbank.
NEXT_PUBLIC_SUPABASE_URL=https://<projekt-ref>.supabase.co DATABASE_URL=postgres://postgres:<passwort>@localhost:5432/canteen_menu
# Ö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>
# Öffentliche URL, unter der die App erreichbar ist (Schema + Domain). # Öffentliche URL, unter der die App erreichbar ist (Schema + Domain).
# Wird für den CSRF-Origin-Check hinter Reverse-Proxies genutzt und ist # Wird für den CSRF-Origin-Check hinter Reverse-Proxies genutzt und ist
# zugleich die Passkey-Domain: nach einer Änderung einmalig # zugleich die Passkey-Domain (RP-ID/Origin werden direkt daraus
# SUPABASE_ACCESS_TOKEN=<token> node scripts/configure-passkeys.mjs # abgeleitet, kein separater Konfigurationsschritt nötig).
# ausführen, damit Passkeys auf der neuen Domain funktionieren.
APP_URL=http://localhost:3000 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"
# inaktiv; Englisch/Polnisch lassen sich weiterhin manuell eintragen.
DEEPL_API_KEY=
+172
View File
@@ -0,0 +1,172 @@
# Changelog
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
- Mehrsprachigkeit für die komplette Anwendung (Deutsch, Englisch,
Polnisch): Sprachumschalter (Cookie-basiert, kein Neuladen der
Seite nötig), sämtliche Oberflächentexte übersetzt
- Gerichte, Bereiche, Allergene und Zusatzstoffe zusätzlich mit
englischem/polnischem Namen; öffentliche Speisekarte zeigt je nach
gewählter Sprache automatisch den passenden Namen (Fallback:
Deutsch, falls keine Übersetzung hinterlegt ist)
- Automatische Übersetzung per DeepL: Button „Automatisch übersetzen“
beim Anlegen/Bearbeiten von Gerichten, Allergenen und Zusatzstoffen;
Ergebnis bleibt danach manuell korrigierbar. Aktivierung über die
neue Docker-Variable `DEEPL_API_KEY` – ohne Key bleibt der Button
inaktiv, die Felder lassen sich weiterhin von Hand ausfüllen
- Rezeptvorschläge im öffentlichen Bereich: nicht angemeldete
Besucher können über „Rezept vorschlagen“ ein fehlendes Gericht
vorschlagen (Name + optionale Beschreibung), inkl. Dublettenprüfung
gegen bestehende Gerichte und einfachem Honeypot-Spamschutz
- Neue Seite „Vorschläge“ im Kantinen-Bereich: eingereichte Vorschläge
sichten, als Gericht übernehmen oder ablehnen
### Behoben
- Passwort-wiederholen-Feld beim Bearbeiten eines Benutzers erschien
erst, nachdem im ersten Passwortfeld schon getippt wurde – wirkte
wie ein einzelnes Pflichtfeld
- Anmelden-Button überlappte auf Mobilgeräten mit breiten Logos
(Banner-Format) das Logo, da er absolut in der oberen Ecke
positioniert war
## [1.2.0] – 2026-07-18
### Hinzugefügt
- „Heute"-Button in der Wochennavigation (öffentliche Speisekarte und
Wochenplan-Editor)
- Mobile Ansicht der öffentlichen Speisekarte: umschaltbar zwischen
Tag (Standard) und Woche, unabhängig von der Desktop-Tabelle
- Allergene und Zusatzstoffe wieder pro Gericht auswählbar (Chip-Auswahl
im Formular) und als kleine Kürzel neben dem Gerichtnamen sichtbar –
in Speisekarte, mobiler Ansicht und Wochenplan-Editor
- Passwort-Wiederholung beim Anlegen neuer Benutzer und beim optionalen
Passwort-Reset
- Passwort-Anmeldung pro Konto deaktivierbar (nur wenn mindestens ein
Passkey registriert ist), Durchsetzung im Login-Flow
- `SITE_TITLE`-Umgebungsvariable für Browser-Tab-Titel und Überschrift
ohne Logo
- Austauschbares Favicon über den Admin-Design-Bereich
### Geändert
- Gemeinsame Navigation für Kantinen- und Admin-Bereich statt zwei
getrennter Menüs
- Live-Suche bei den Gerichten
- Mehrfachauswahl zum Batch-Löschen von Gerichten
- App erreichbar auf `0.0.0.0` statt nur `localhost` (Docker und
nativer Betrieb)
- Projekt umbenannt von „Speisekarte“ zu „canteen-menu“ (Docker-,
npm- und Supabase-Namen; deutsche In-App-Texte unverändert)
- Öffentliche URL über `APP_URL` konfigurierbar (CSRF-Origin-Check
hinter Reverse-Proxy, Passkey-Domain)
### Behoben
- Next.js band `app/favicon.ico` zusätzlich zu einem dynamisch
gesetzten Favicon ein – Favicon wird jetzt immer explizit gesetzt
- Weiterleitungen (Logout, Zugriffsschutz) übernahmen nach der
0.0.0.0-Bindung fälschlich die Server-Adresse statt des
aufgerufenen Hosts
## [1.1.0] – 2026-07-17
### Hinzugefügt
- Versionsnummer im Fußbereich jeder Seite
## [1.0.0] – 2026-07-17
Erste vollständige Version.
### Hinzugefügt
- Öffentliche Speisekarte ohne Login: Wochenansicht Mo–Fr mit den
Bereichen Brotzeit, Mittagessen, Mittagessen vegetarisch und Suppe;
Wochen-Navigation; Logo zentriert; Allergene & Zusatzstoffe unterhalb
der Karte
- Anmeldung mit E-Mail/Passwort oder Passkey
- Kantinen-Bereich: Gerichte anlegen/bearbeiten/löschen, Wochenplan
pflegen, globale Allergen-/Zusatzstofflisten verwalten
- Admin-Bereich: Benutzerverwaltung, Farbgebung, Logo-Upload
- Eigenes Konto verwalten: Name, E-Mail, Passwort, Passkeys
- Row-Level-Security, HTTP-Security-Header, CSRF-Origin-Check
- Docker-Setup (App-Image, lokale PostgreSQL-Kopie, Dev-Profil ohne
lokales Node.js)
- Lokale PostgreSQL-Kopie mit Sync-Skript
+7 -7
View File
@@ -15,12 +15,6 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . 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 RUN npm run build
# ---------- Runtime ---------- # ---------- Runtime ----------
@@ -35,7 +29,13 @@ RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 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 USER nextjs
EXPOSE 3000 EXPOSE 3000
CMD ["node", "server.js"] CMD ["sh", "-c", "node db/migrate.mjs && node scripts/bootstrap-admin.mjs && exec node server.js"]
+88 -82
View File
@@ -4,19 +4,33 @@ Digitale Speisekarte für eine Firmenkantine (ca. 250 Mitarbeiter) mit
öffentlicher Wochenansicht, Kantinen-Bereich zur Pflege der Gerichte und öffentlicher Wochenansicht, Kantinen-Bereich zur Pflege der Gerichte und
Admin-Bereich für Benutzerverwaltung und Branding. Admin-Bereich für Benutzerverwaltung und Branding.
**Stack:** Next.js 16 (App Router, TypeScript, Tailwind CSS) · Supabase **Stack:** Next.js 16 (App Router, TypeScript, Tailwind CSS) · selbst
(PostgreSQL, Auth inkl. Passkeys, Storage, Row Level Security) 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`).
## Funktionen ## Funktionen
| Bereich | Funktionen | | 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; Logo zentriert über der Karte; Allergene & Zusatzstoffe unterhalb der Karte | | Ö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`) | Gerichte anlegen/bearbeiten/löschen; Gerichte Tagen/Bereichen zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen; eigene Passkeys verwalten | | 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 und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo hochladen/entfernen | | 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** Anmeldung mit E-Mail + Passwort oder alternativ per **Passkey**
(Registrierung im Bereich „Passkeys“ nach dem ersten Login). (Registrierung im eigenen Konto nach dem ersten Login). Die
Passwort-Anmeldung kann pro Konto deaktiviert werden, sobald mindestens
ein Passkey registriert ist (serverseitig erzwungen).
Die gesamte Oberfläche ist auf Deutsch, Englisch und Polnisch verfügbar
(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.
## Projektstruktur ## Projektstruktur
@@ -24,16 +38,33 @@ Anmeldung mit E-Mail + Passwort oder alternativ per **Passkey**
app/ Next.js-Routen app/ Next.js-Routen
page.tsx Öffentliche Speisekarte page.tsx Öffentliche Speisekarte
login/ Login (Passwort + Passkey) login/ Login (Passwort + Passkey)
vorschlag/ Öffentliches Rezeptvorschlag-Formular
intern/ Kantinen-Bereich (Rolle: staff oder admin) intern/ Kantinen-Bereich (Rolle: staff oder admin)
vorschlaege/ Rezeptvorschläge sichten/übernehmen
admin/ Verwaltung (Rolle: admin) 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/ Client-Komponenten (WeekEditor, DishManager, …)
lib/supabase/ Supabase-Clients (Browser / Server / Admin) components/i18n/ Sprachkontext, Sprachumschalter
lib/i18n/ Wörterbücher (de/en/pl), Locale-Helfer
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 lib/ Typen, Datums-/Formatierungshelfer
proxy.ts Session-Refresh + Schutz von /intern und /admin proxy.ts Session-Prüfung + Schutz von /intern, /admin, /konto
supabase/migrations/ Schema & Seed-Daten (Referenz/Doku) db/migrations/ Datenbankschema (reines SQL, nummeriert)
supabase/local/ Skripte + Dumps für die lokale PostgreSQL-Kopie db/migrate.mjs Wendet neue Migrationen an (idempotent, bei jedem Start)
scripts/ Hilfsskripte (DB-Migration, lokaler Sync) scripts/bootstrap-admin.mjs
Legt bei leerer Datenbank automatisch einen
Admin mit Zufallspasswort an (siehe ZUGANGSDATEN.md)
``` ```
## Entwicklung ## Entwicklung
@@ -49,24 +80,26 @@ docker compose --env-file .env.local --profile dev up dev
# http://localhost:3000 (Hot-Reload, Quellcode wird gemountet) # http://localhost:3000 (Hot-Reload, Quellcode wird gemountet)
``` ```
**Variante B – lokal installiertes Node.js:** **Variante B – lokal installiertes Node.js + PostgreSQL:**
```bash ```bash
npm install npm install
npm run dev # http://localhost:3000 (bindet an 0.0.0.0, auch im LAN erreichbar) 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 ## Umgebungsvariablen (Live-Betrieb)
(Werte: Supabase-Dashboard → Project Settings → API Keys):
| Variable | Bedeutung | Geheim? | | Variable | Bedeutung | Geheim? |
| --- | --- | --- | | --- | --- | --- |
| `NEXT_PUBLIC_SUPABASE_URL` | URL des Supabase-Projekts, z. B. `https://hvzikwjoyhoakedlywhj.supabase.co` | nein | | `DATABASE_URL` | Verbindung zur PostgreSQL-Datenbank, z. B. `postgres://postgres:<passwort>@db:5432/canteen_menu` | **ja** |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Öffentlicher anon-Key (RLS schützt die Daten) | nein | | `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 |
| `SUPABASE_SERVICE_ROLE_KEY` | Service-Role-Key für die Benutzerverwaltung | **ja – niemals im Browser/Repo!** | | `SITE_TITLE` | Titel im Browser-Tab und Überschrift ohne Logo, Default `Canteen Menu` | nein |
| `APP_URL` | Öffentliche URL der App, z. B. `https://menu.example.de` (Origin-Check hinter Reverse-Proxy; zugleich Passkey-Domain) | 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 Build & Start auf dem Server – **mit Docker** (empfohlen, Node.js und
PostgreSQL müssen nicht installiert sein): PostgreSQL müssen nicht installiert sein):
@@ -74,82 +107,55 @@ PostgreSQL müssen nicht installiert sein):
```bash ```bash
docker compose --env-file .env.local up -d --build docker compose --env-file .env.local up -d --build
# App: Port 3000 (hinter Reverse-Proxy mit HTTPS betreiben) # 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 ```bash
npm ci npm ci
npm run build npm run build
npm run start # Port 3000, bindet an 0.0.0.0 (hinter Reverse-Proxy betreiben) 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 (RP-ID = Hostname aus `APP_URL`).
Vor dem Livegang `APP_URL` auf die öffentliche URL setzen – ein
Passkeys sind an die Domain gebunden. Aktuell ist `localhost` konfiguriert. zusätzlicher Konfigurationsschritt ist nicht nötig. Bereits registrierte
Vor dem Livegang: `APP_URL` auf die öffentliche URL setzen und einmalig Passkeys einer vorherigen Domain verlieren dabei ihre Gültigkeit und
müssen neu registriert werden.
```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.
## Datenbank ## 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: `db/migrations/*.sql`, nummeriert, wird von `db/migrate.mjs`
- Schema und Seed-Daten: `supabase/migrations/0001_init.sql` und `0002_seed.sql` automatisch angewendet (idempotent, bei jedem Start des Servers bzw.
- Ausführen weiterer SQL-Dateien: Docker-Containers).
`SUPABASE_ACCESS_TOKEN=<token> node scripts/db-apply.mjs <datei.sql>` - 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, - Öffentlich lesbar (kein Login nötig): Speisekarte, Essenszeiten,
Zusatzstoffe, Einstellungen). Allergene, Zusatzstoffe, Logo/Favicon.
- `staff`/`admin` (Rolle im `profiles`-Eintrag) pflegen Gerichte, - `staff`/`admin` pflegen Gerichte, Essenszeiten, Wochenplan und
Wochenplan und Kennzeichnungen. Kennzeichnungen.
- Nur `admin` ändert Einstellungen (Farben/Logo) und verwaltet Benutzer; - Nur `admin` ändert Einstellungen (Farben/Logo) und verwaltet Benutzer.
das Anlegen/Löschen von Benutzern läuft serverseitig über den - Logo/Favicon liegen als Dateien in `BRANDING_DIR` (Docker-Volume
Service-Role-Key (`app/api/admin/users`). `branding_data`), öffentlich ausgeliefert über `/branding/[filename]`.
- 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.
## Zugangsdaten ## Zugangsdaten
Die Anmeldedaten (Admin, Demo-Mitarbeiter, Datenbank-Passwörter) stehen in Beim allerersten Start mit leerer Datenbank legt `scripts/bootstrap-admin.mjs`
`ZUGANGSDATEN.md` – diese Datei ist bewusst **nicht** im Repository automatisch einen Admin-Benutzer mit zufällig generiertem Passwort an und
(`.gitignore`). Nach dem ersten Login bitte die Startpasswörter ändern. 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 { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { getSession } from "@/lib/auth/session";
import AppHeader from "@/components/AppHeader"; import AppHeader from "@/components/AppHeader";
export default async function AdminLayout({ export default async function AdminLayout({
@@ -7,24 +7,15 @@ export default async function AdminLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const supabase = await createClient(); const session = await getSession();
const { if (!session) redirect("/login");
data: { user }, if (session.user.role !== "admin") redirect("/intern");
} = 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");
return ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<AppHeader <AppHeader
role="admin" 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"> <main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
{children} {children}
+4 -2
View File
@@ -1,5 +1,7 @@
import { getSession } from "@/lib/auth/session";
import UserManager from "@/components/UserManager"; import UserManager from "@/components/UserManager";
export default function AdminPage() { export default async function AdminPage() {
return <UserManager />; 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;
}
}
+86 -52
View File
@@ -1,33 +1,48 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server"; import { query, queryOne } from "@/lib/db";
import { createAdminClient } from "@/lib/supabase/admin"; import { hashPassword } from "@/lib/auth/password";
import { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin"; import { isSameOrigin } from "@/lib/origin";
import type { Profile } from "@/lib/types";
// Benutzerverwaltung (nur Admin). Läuft serverseitig mit dem // Benutzerverwaltung (nur Admin).
// Service-Role-Key, da das Anlegen/Löschen von Auth-Benutzern
// nicht über den anon key möglich ist.
async function requireAdmin(request: Request) { async function requireAdmin(request: Request) {
if (!isSameOrigin(request)) return null; if (!isSameOrigin(request)) throw new ForbiddenError();
const supabase = await createClient(); return requireRole(["admin"]);
const { }
data: { user },
} = await supabase.auth.getUser();
if (!user) return null;
const admin = createAdminClient(); function errorResponse(err: unknown) {
const { data: profile } = await admin if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
.from("profiles") return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
.select("role") }
.eq("id", user.id) return null;
.single(); }
if (profile?.role !== "admin") return null;
return { user, admin }; 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) { export async function POST(request: Request) {
const ctx = await requireAdmin(request); let session;
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); 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 body = await request.json().catch(() => null);
const { email, password, full_name, role } = body ?? {}; 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({ try {
email, const user = await queryOne<{ id: string }>(
password, `insert into users (email, password_hash, full_name, role)
email_confirm: true, values ($1, $2, $3, $4) returning id`,
user_metadata: { full_name: full_name ?? "", role }, [String(email).trim().toLowerCase(), hashPassword(password), full_name ?? "", role]
});
if (error) {
return NextResponse.json(
{
error: /already been registered/i.test(error.message)
? "Diese E-Mail-Adresse ist bereits registriert."
: error.message,
},
{ status: 400 }
); );
return NextResponse.json({ id: user?.id });
} catch (err) {
if (err instanceof Error && /unique/i.test(err.message)) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse ist bereits registriert." },
{ status: 400 }
);
}
throw err;
} }
return NextResponse.json({ id: data.user?.id });
} }
export async function PATCH(request: Request) { export async function PATCH(request: Request) {
const ctx = await requireAdmin(request); let session;
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); 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 body = await request.json().catch(() => null);
const { id, full_name, role, password } = body ?? {}; 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, // Der Admin darf sich nicht selbst die Admin-Rolle entziehen,
// sonst sperrt er sich aus der Verwaltung aus. // 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( return NextResponse.json(
{ error: "Du kannst dir nicht selbst die Admin-Rolle entziehen." }, { error: "Du kannst dir nicht selbst die Admin-Rolle entziehen." },
{ status: 400 } { status: 400 }
@@ -85,35 +105,49 @@ export async function PATCH(request: Request) {
{ status: 400 } { status: 400 }
); );
} }
const { error } = await ctx.admin.auth.admin.updateUserById(id, { password }); await query("update users set password_hash = $1 where id = $2", [
if (error) return NextResponse.json({ error: error.message }, { status: 400 }); hashPassword(password),
id,
]);
} }
const updates: Record<string, string> = {}; const fields: string[] = [];
if (typeof full_name === "string") updates.full_name = full_name; const values: unknown[] = [];
if (role) updates.role = role; if (typeof full_name === "string") {
if (Object.keys(updates).length > 0) { values.push(full_name);
const { error } = await ctx.admin.from("profiles").update(updates).eq("id", id); fields.push(`full_name = $${values.length}`);
if (error) return NextResponse.json({ error: error.message }, { status: 400 }); }
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 }); return NextResponse.json({ ok: true });
} }
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
const ctx = await requireAdmin(request); let session;
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); 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 body = await request.json().catch(() => null);
const { id } = body ?? {}; const { id } = body ?? {};
if (!id) return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 }); if (!id) return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
if (id === ctx.user.id) { if (id === session.user.id) {
return NextResponse.json( return NextResponse.json(
{ error: "Du kannst dich nicht selbst löschen." }, { error: "Du kannst dich nicht selbst löschen." },
{ status: 400 } { status: 400 }
); );
} }
const { error } = await ctx.admin.auth.admin.deleteUser(id); await query("delete from users where id = $1", [id]);
if (error) return NextResponse.json({ error: error.message }, { status: 400 });
return NextResponse.json({ ok: true }); 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 { isLocale, LOCALE_COOKIE } from "@/lib/i18n";
import { isSameOrigin } from "@/lib/origin";
// Setzt das Sprach-Cookie. Kein Login nötig (auch anonyme Besucher
// der öffentlichen Speisekarte sollen die Sprache wechseln können).
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
const body = await request.json().catch(() => null);
const locale = body?.locale;
if (!isLocale(locale)) {
return NextResponse.json({ error: "Ungültige Sprache." }, { status: 400 });
}
const response = NextResponse.json({ ok: true });
response.cookies.set(LOCALE_COOKIE, locale, {
path: "/",
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
});
return response;
}
+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;
}
}
+99
View File
@@ -0,0 +1,99 @@
import { NextResponse } from "next/server";
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
// schreibt das Ergebnis in die _en/_pl-Spalte der angegebenen Zeile.
// Ohne DEEPL_API_KEY läuft die Route kontrolliert ins Leere (configured:
// false), damit das UI die manuelle Eingabe anbieten kann.
const ALLOWED_TABLES: Record<string, { idColumn: string; textColumn: string }> = {
dishes: { idColumn: "id", textColumn: "name" },
sections: { idColumn: "key", textColumn: "label" },
allergens: { idColumn: "code", textColumn: "name" },
additives: { idColumn: "code", textColumn: "name" },
};
async function translateWithDeepL(text: string, targetLang: "EN" | "PL", apiKey: string) {
const host = apiKey.endsWith(":fx")
? "https://api-free.deepl.com"
: "https://api.deepl.com";
const res = await fetch(`${host}/v2/translate`, {
method: "POST",
headers: {
Authorization: `DeepL-Auth-Key ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
text: [text],
target_lang: targetLang,
source_lang: "DE",
}),
});
if (!res.ok) {
throw new Error(`DeepL antwortete mit ${res.status}: ${await res.text()}`);
}
const data = await res.json();
return data.translations?.[0]?.text as string | undefined;
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
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 ?? {};
const config = ALLOWED_TABLES[table];
if (!config || typeof text !== "string" || !text.trim()) {
return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
}
// Bei einem noch nicht gespeicherten (neuen) Eintrag gibt es keine
// Zeile zum Aktualisieren – nur übersetzen, das Speichern übernimmt
// anschließend das reguläre Formular.
const persist = typeof id === "string" && id.length > 0;
const apiKey = process.env.DEEPL_API_KEY;
if (!apiKey) {
return NextResponse.json({ configured: false });
}
try {
const [en, pl] = await Promise.all([
translateWithDeepL(text, "EN", apiKey),
translateWithDeepL(text, "PL", apiKey),
]);
if (!en || !pl) throw new Error("Keine Übersetzung erhalten.");
if (persist) {
// 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 });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Übersetzung fehlgeschlagen." },
{ status: 502 }
);
}
}
+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",
},
});
}
+29 -1
View File
@@ -1,7 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
/* Farb-Variablen: Defaults entsprechen r-hofmann.de; werden zur Laufzeit /* Farb-Variablen: Defaults entsprechen r-hofmann.de; werden zur Laufzeit
aus den Einstellungen (Supabase settings.colors) überschrieben. */ aus den Einstellungen (settings.colors) überschrieben. */
:root { :root {
--c-primary: #003388; --c-primary: #003388;
--c-primary-dark: #0d315c; --c-primary-dark: #0d315c;
@@ -108,3 +108,31 @@ body {
color: #4b5563; color: #4b5563;
margin-left: 0.15rem; margin-left: 0.15rem;
} }
/* ---------- Chip-Auswahl (Allergene/Zusatzstoffe je Gericht) ---------- */
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 9999px;
padding: 0.2rem 0.65rem;
font-size: 0.75rem;
line-height: 1.2;
border: 1px solid #d1d5db;
background: #f9fafb;
color: #374151;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.chip:hover { border-color: var(--c-primary); }
.chip-active {
background: var(--c-primary);
border-color: var(--c-primary);
color: #ffffff;
}
.dish-codes {
font-size: 0.65rem;
color: #6b7280;
vertical-align: super;
margin-left: 0.15rem;
}
+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"; import DishManager from "@/components/DishManager";
export default function GerichtePage() { export default function GerichtePage() {
return <DishManager />; return <DishManager translateEnabled={!!process.env.DEEPL_API_KEY} />;
} }
+3 -10
View File
@@ -1,18 +1,11 @@
import CodeListManager from "@/components/CodeListManager"; import CodeListManager from "@/components/CodeListManager";
export default function KennzeichnungPage() { export default function KennzeichnungPage() {
const translateEnabled = !!process.env.DEEPL_API_KEY;
return ( return (
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<CodeListManager <CodeListManager table="allergens" translateEnabled={translateEnabled} />
table="allergens" <CodeListManager table="additives" translateEnabled={translateEnabled} />
title="Allergene"
codeLabel="Kürzel (z. B. A)"
/>
<CodeListManager
table="additives"
title="Zusatzstoffe"
codeLabel="Kürzel (z. B. 1)"
/>
</div> </div>
); );
} }
+5 -15
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { getSession } from "@/lib/auth/session";
import AppHeader from "@/components/AppHeader"; import AppHeader from "@/components/AppHeader";
export default async function InternLayout({ export default async function InternLayout({
@@ -7,24 +7,14 @@ export default async function InternLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const supabase = await createClient(); const session = await getSession();
const { if (!session) redirect("/login");
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("/");
return ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<AppHeader <AppHeader
role={profile.role} role={session.user.role}
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"> <main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
{children} {children}
+5
View File
@@ -0,0 +1,5 @@
import SuggestionReview from "@/components/SuggestionReview";
export default function VorschlaegePage() {
return <SuggestionReview />;
}
+9 -18
View File
@@ -1,33 +1,24 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { getSession } from "@/lib/auth/session";
import AppHeader from "@/components/AppHeader"; import AppHeader from "@/components/AppHeader";
import AccountManager from "@/components/AccountManager"; import AccountManager from "@/components/AccountManager";
export default async function KontoPage() { export default async function KontoPage() {
const supabase = await createClient(); const session = await getSession();
const { if (!session) redirect("/login");
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("/");
return ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<AppHeader <AppHeader
role={profile.role} role={session.user.role}
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"> <main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
<AccountManager <AccountManager
role={profile.role} role={session.user.role}
initialFullName={profile.full_name ?? ""} initialFullName={session.user.full_name}
initialEmail={user.email ?? ""} initialEmail={session.user.email}
initialPasswordAuthEnabled={session.user.password_auth_enabled}
/> />
</main> </main>
</div> </div>
+28 -16
View File
@@ -1,10 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist } from "next/font/google"; import { Geist } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { createClient } from "@/lib/supabase/server"; import { getSettings } from "@/lib/db/settings";
import { COLOR_VARS, DEFAULT_COLORS } from "@/lib/types"; import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types";
import { getDictionary, getLocale } from "@/lib/i18n";
import { LocaleProvider } from "@/components/i18n/LocaleProvider";
import packageJson from "@/package.json";
// Farben/Logo sollen Änderungen sofort widerspiegeln. // Farben/Logo/Favicon sollen Änderungen sofort widerspiegeln.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const geistSans = Geist({ const geistSans = Geist({
@@ -12,35 +15,44 @@ const geistSans = Geist({
subsets: ["latin"], subsets: ["latin"],
}); });
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: "Canteen Menu", const settings = await getSettings();
description: "Speisekarte der Betriebskantine",
}; return {
title: SITE_TITLE,
description: "Speisekarte der Betriebskantine",
// Immer explizit setzen: app/favicon.ico würde sonst zusätzlich zu
// einem eigenen Favicon eingebunden (Next.js kombiniert Datei-
// Konvention und Metadata statt sie zu ersetzen).
icons: { icon: faviconUrl(settings?.favicon_path) ?? "/favicon.ico" },
};
}
export default async function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const supabase = await createClient(); const [settings, locale] = await Promise.all([getSettings(), getLocale()]);
const { data: settings } = await supabase const dict = getDictionary(locale);
.from("settings")
.select("colors")
.eq("id", 1)
.maybeSingle();
const colors: Record<string, string> = { const colors: Record<string, string> = {
...DEFAULT_COLORS, ...DEFAULT_COLORS,
...((settings?.colors as Record<string, string>) ?? {}), ...(settings?.colors ?? {}),
}; };
const style = Object.fromEntries( const style = Object.fromEntries(
Object.entries(COLOR_VARS).map(([key, cssVar]) => [cssVar, colors[key]]) Object.entries(COLOR_VARS).map(([key, cssVar]) => [cssVar, colors[key]])
) as React.CSSProperties; ) as React.CSSProperties;
return ( return (
<html lang="de" className={`${geistSans.variable} h-full antialiased`}> <html lang={locale} className={`${geistSans.variable} h-full antialiased`}>
<body style={style} className="min-h-full flex flex-col"> <body style={style} className="min-h-full flex flex-col">
{children} <LocaleProvider locale={locale} dict={dict}>
{children}
</LocaleProvider>
<footer className="mt-auto px-4 py-2 text-right text-xs text-gray-400 select-none">
Version {packageJson.version}
</footer>
</body> </body>
</html> </html>
); );
+52 -42
View File
@@ -3,67 +3,74 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; 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 germanAuthError(message: string): string { function authErrorMessage(dict: Dict, code: string): string {
if (/invalid login credentials/i.test(message)) if (code === "invalid_credentials") return dict.login.errorInvalidCredentials;
return "E-Mail-Adresse oder Passwort ist falsch."; if (code === "password_disabled") return dict.login.errorPasswordDisabled;
if (/email not confirmed/i.test(message)) if (/cancelled|aborted|NotAllowedError/i.test(code))
return "Die E-Mail-Adresse wurde noch nicht bestätigt."; return dict.login.errorPasskeyCancelled;
if (/cancelled|aborted|NotAllowedError/i.test(message)) return formatMessage(dict.login.errorGeneric, { message: code });
return "Passkey-Anmeldung wurde abgebrochen."; }
return `Anmeldung fehlgeschlagen: ${message}`;
function redirectPath(role: string): string {
return role === "admin" ? "/admin" : "/intern";
} }
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const supabase = createClient(); const dict = useDict();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); 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) { async function signInWithPassword(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
setBusy(true); setBusy(true);
const { data, error } = await supabase.auth.signInWithPassword({ const res = await fetch("/api/auth/login", {
email, method: "POST",
password, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
}); });
if (error || !data.user) { const data = await res.json().catch(() => ({}));
setError(germanAuthError(error?.message ?? "Unbekannter Fehler")); if (!res.ok) {
setError(authErrorMessage(dict, data.error ?? "unknown"));
setBusy(false); setBusy(false);
return; return;
} }
await redirectByRole(data.user.id); router.replace(redirectPath(data.role));
router.refresh();
} }
async function signInWithPasskey() { async function signInWithPasskey() {
setError(null); setError(null);
setBusy(true); setBusy(true);
try { try {
const { data, error } = await supabase.auth.signInWithPasskey(); const optionsRes = await fetch("/api/auth/passkeys/login-options", { method: "POST" });
if (error || !data?.user) { const options = await optionsRes.json();
setError( const response = await startAuthentication({ optionsJSON: options });
germanAuthError(error?.message ?? "Passkey-Anmeldung fehlgeschlagen")
); 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); setBusy(false);
return; return;
} }
await redirectByRole(data.user.id); router.replace(redirectPath(data.role));
router.refresh();
} catch (err) { } catch (err) {
setError(germanAuthError(err instanceof Error ? err.message : String(err))); setError(authErrorMessage(dict, err instanceof Error ? err.message : String(err)));
setBusy(false); setBusy(false);
} }
} }
@@ -71,13 +78,16 @@ export default function LoginPage() {
return ( return (
<div className="flex-1 flex items-center justify-center p-4"> <div className="flex-1 flex items-center justify-center p-4">
<div className="card w-full max-w-sm"> <div className="card w-full max-w-sm">
<h1 className="text-xl font-bold text-[var(--c-primary)] mb-4"> <div className="flex justify-between items-center mb-4">
Anmeldung <h1 className="text-xl font-bold text-[var(--c-primary)]">
</h1> {dict.login.title}
</h1>
<LanguageSwitcher variant="light" />
</div>
<form onSubmit={signInWithPassword} className="space-y-3"> <form onSubmit={signInWithPassword} className="space-y-3">
<div> <div>
<label className="label" htmlFor="email"> <label className="label" htmlFor="email">
E-Mail-Adresse {dict.common.email}
</label> </label>
<input <input
id="email" id="email"
@@ -91,7 +101,7 @@ export default function LoginPage() {
</div> </div>
<div> <div>
<label className="label" htmlFor="password"> <label className="label" htmlFor="password">
Passwort {dict.common.password}
</label> </label>
<input <input
id="password" id="password"
@@ -105,13 +115,13 @@ export default function LoginPage() {
</div> </div>
{error && <p className="text-sm text-[var(--c-del)]">{error}</p>} {error && <p className="text-sm text-[var(--c-del)]">{error}</p>}
<button type="submit" className="btn btn-primary w-full" disabled={busy}> <button type="submit" className="btn btn-primary w-full" disabled={busy}>
Anmelden {dict.login.submit}
</button> </button>
</form> </form>
<div className="my-4 flex items-center gap-3 text-xs text-gray-400"> <div className="my-4 flex items-center gap-3 text-xs text-gray-400">
<span className="h-px flex-1 bg-gray-200" /> <span className="h-px flex-1 bg-gray-200" />
oder {dict.login.or}
<span className="h-px flex-1 bg-gray-200" /> <span className="h-px flex-1 bg-gray-200" />
</div> </div>
@@ -121,12 +131,12 @@ export default function LoginPage() {
className="btn btn-neutral w-full" className="btn btn-neutral w-full"
disabled={busy} disabled={busy}
> >
Mit Passkey anmelden {dict.login.passkeyButton}
</button> </button>
<p className="mt-4 text-center text-sm"> <p className="mt-4 text-center text-sm">
<Link href="/" className="text-gray-500 hover:text-[var(--c-primary)]"> <Link href="/" className="text-gray-500 hover:text-[var(--c-primary)]">
← Zur Speisekarte {dict.login.backToMenu}
</Link> </Link>
</p> </p>
</div> </div>
+2 -3
View File
@@ -1,13 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server"; import { destroySession } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin"; import { isSameOrigin } from "@/lib/origin";
export async function POST(request: Request) { export async function POST(request: Request) {
if (!isSameOrigin(request)) { if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
} }
const supabase = await createClient(); await destroySession();
await supabase.auth.signOut();
// Relative Weiterleitung: bei einer absoluten URL aus request.url landet // Relative Weiterleitung: bei einer absoluten URL aus request.url landet
// sonst die Server-Bindung (0.0.0.0) statt des aufgerufenen Hosts im // sonst die Server-Bindung (0.0.0.0) statt des aufgerufenen Hosts im
// Location-Header. // Location-Header.
+119 -89
View File
@@ -1,15 +1,21 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import { query } from "@/lib/db";
import { createClient } from "@/lib/supabase/server"; import { loadMenuEntries } from "@/lib/db/menu-entries";
import { getSettings } from "@/lib/db/settings";
import { import {
addDays, addDays,
dishCodes,
entriesFor,
formatDateLong, formatDateLong,
formatPrice, formatPrice,
mondayOf, mondayOf,
WEEKDAYS, todayISO,
} from "@/lib/menu"; } from "@/lib/menu";
import { logoUrl } from "@/lib/types"; import { getDictionary, getLocale } from "@/lib/i18n";
import type { CodeItem, MenuEntry, Section } from "@/lib/types"; import { localized, logoUrl, SITE_TITLE } from "@/lib/types";
import type { CodeItem, Section } from "@/lib/types";
import PublicMobileMenu from "@/components/PublicMobileMenu";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
export default async function Home({ export default async function Home({
searchParams, searchParams,
@@ -19,125 +25,149 @@ export default async function Home({
const { woche } = await searchParams; const { woche } = await searchParams;
const monday = mondayOf(woche); const monday = mondayOf(woche);
const friday = addDays(monday, 4); const friday = addDays(monday, 4);
const days = WEEKDAYS.map((weekday, i) => ({ const currentWeek = mondayOf(todayISO());
const locale = await getLocale();
const dict = getDictionary(locale);
const days = dict.weekdays.long.map((weekday, i) => ({
weekday, weekday,
iso: addDays(monday, i), iso: addDays(monday, i),
})); }));
const supabase = await createClient(); const [sections, entries, allergens, additives, settings] = await Promise.all([
const [sectionsRes, entriesRes, allergensRes, additivesRes, settingsRes] = query<Section>("select * from sections order by sort"),
await Promise.all([ loadMenuEntries(monday, friday),
supabase.from("sections").select("*").order("sort"), query<CodeItem>("select * from allergens order by sort"),
supabase query<CodeItem>("select * from additives order by sort"),
.from("menu_entries") getSettings(),
.select("id, menu_date, section_key, position, dish:dishes(id, name, price)") ]);
.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 = (sectionsRes.data ?? []) as Section[]; const logo = logoUrl(settings?.logo_path);
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 cell = (iso: string, sectionKey: string) =>
entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey);
return ( return (
<div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full"> <div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full">
{/* Kopfbereich: Logo zentriert, Login-Link rechts */} {/* Kopfbereich: Sprache + Login-Link eigene Zeile, Logo zentriert
<header className="relative mb-6"> darunter. Kein absolutes Positionieren mehr – ein breites Logo
(z. B. ein Banner-Format) konnte sonst mit dem Login-Link
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)]"
>
{dict.common.login}
</Link>
</div>
<div className="flex justify-center"> <div className="flex justify-center">
{logo ? ( {logo ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={logo} alt="Logo" className="h-20 object-contain" /> <img
src={logo}
alt="Logo"
className="h-20 max-w-full object-contain"
/>
) : ( ) : (
<h1 className="text-4xl font-extrabold tracking-tight text-[var(--c-primary)]"> <h1 className="text-4xl font-extrabold tracking-tight text-[var(--c-primary)]">
Speisekarte {SITE_TITLE}
</h1> </h1>
)} )}
</div> </div>
<Link
href="/login"
className="absolute right-0 top-0 text-sm text-gray-500 hover:text-[var(--c-primary)]"
>
Anmelden
</Link>
</header> </header>
{/* Wochen-Navigation */} {/* Mobil: Tag (Standard) oder Woche umschaltbar */}
<div className="flex gap-3 mb-4"> <PublicMobileMenu monday={monday} sections={sections} entries={entries} />
<Link href={`/?woche=${addDays(monday, -7)}`} className="btn btn-add">
← Vorherige Woche {/* Ab "sm": klassische Wochentabelle */}
</Link> <div className="hidden sm:block">
<Link href={`/?woche=${addDays(monday, 7)}`} className="btn btn-add"> <div className="flex flex-wrap items-center gap-3 mb-4">
Nächste Woche → <Link href={`/?woche=${addDays(monday, -7)}`} className="btn btn-add">
</Link> {dict.publicMenu.prevWeek}
</Link>
<Link href={`/?woche=${currentWeek}`} className="btn btn-primary">
{dict.publicMenu.today}
</Link>
<Link href={`/?woche=${addDays(monday, 7)}`} className="btn btn-add">
{dict.publicMenu.nextWeek}
</Link>
<Link href="/vorschlag" className="btn btn-neutral ml-auto">
{dict.publicMenu.suggestDish}
</Link>
</div>
<div className="overflow-x-auto">
<table className="menu-table">
<thead>
<tr>
<th className="section-head w-56" aria-hidden />
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
<td key={d.iso} className="cell">
{entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">
{localized(e.dish, locale)}
</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "}
{formatPrice(e.dish.price)}
</div>
);
})}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
{/* Speisekarte */} {/* Allergene & Zusatzstoffe: Legende zu den Kürzeln bei den Gerichten */}
<table className="menu-table">
<thead>
<tr>
<th className="section-head w-56" aria-hidden />
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
<tr key={s.key}>
<th className="section-head">
<div>{s.label}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
<td key={d.iso} className="cell">
{cell(d.iso, s.key).map((e) => (
<div key={e.id} className="dish">
<span className="font-bold">{e.dish.name}</span>
{" – "}
{formatPrice(e.dish.price)}
</div>
))}
</td>
))}
</tr>
))}
</tbody>
</table>
{/* Allergene & Zusatzstoffe (gelten für die komplette Speisekarte) */}
<section className="mt-8 grid gap-6 sm:grid-cols-2 text-sm text-gray-700"> <section className="mt-8 grid gap-6 sm:grid-cols-2 text-sm text-gray-700">
<div> <div>
<h2 className="font-bold text-[var(--c-primary)] mb-2">Allergene</h2> <h2 className="font-bold text-[var(--c-primary)] mb-2">
{dict.publicMenu.allergens}
</h2>
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{allergens.map((a) => ( {allergens.map((a) => (
<li key={a.code}> <li key={a.code}>
<span className="font-semibold">{a.code}</span> – {a.name} <span className="font-semibold">{a.code}</span> –{" "}
{localized(a, locale)}
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
<div> <div>
<h2 className="font-bold text-[var(--c-primary)] mb-2"> <h2 className="font-bold text-[var(--c-primary)] mb-2">
Zusatzstoffe {dict.publicMenu.additives}
</h2> </h2>
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{additives.map((a) => ( {additives.map((a) => (
<li key={a.code}> <li key={a.code}>
<span className="font-semibold">{a.code}</span> – {a.name} <span className="font-semibold">{a.code}</span> –{" "}
{localized(a, locale)}
</li> </li>
))} ))}
</ul> </ul>
+5
View File
@@ -0,0 +1,5 @@
import SuggestionForm from "@/components/SuggestionForm";
export default function VorschlagPage() {
return <SuggestionForm />;
}
+157 -59
View File
@@ -1,36 +1,36 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client"; import { formatMessage } from "@/lib/i18n/shared";
import PasskeyManager from "@/components/PasskeyManager"; import PasskeyManager from "@/components/PasskeyManager";
import { useDict } from "@/components/i18n/LocaleProvider";
const ROLE_LABELS: Record<string, string> = { function errorMessage(
admin: "Administrator", dict: ReturnType<typeof useDict>,
staff: "Kantinenmitarbeiter", code: string,
}; fallback: string
): string {
function germanAuthError(message: string): string { if (code === "wrong_password") return dict.account.wrongCurrentPassword;
if (/invalid login credentials/i.test(message)) if (code === "email_used") return dict.account.emailAlreadyUsed;
return "Das aktuelle Passwort ist falsch."; if (code === "needs_passkey") return dict.account.passwordAuthNeedsPasskey;
if (/should be at least/i.test(message)) return fallback;
return "Das neue Passwort ist zu kurz (mindestens 8 Zeichen).";
if (/email.*already|already.*registered/i.test(message))
return "Diese E-Mail-Adresse wird bereits verwendet.";
return message;
} }
export default function AccountManager({ export default function AccountManager({
role, role,
initialFullName, initialFullName,
initialEmail, initialEmail,
initialPasswordAuthEnabled,
}: { }: {
role: string; role: string;
initialFullName: string; initialFullName: string;
initialEmail: string; initialEmail: string;
initialPasswordAuthEnabled: boolean;
}) { }) {
const supabase = useMemo(() => createClient(), []);
const router = useRouter(); const router = useRouter();
const dict = useDict();
const roleLabel = role === "admin" ? dict.common.roleAdmin : dict.common.roleStaff;
// Persönliche Daten // Persönliche Daten
const [fullName, setFullName] = useState(initialFullName); const [fullName, setFullName] = useState(initialFullName);
@@ -40,6 +40,7 @@ export default function AccountManager({
// E-Mail-Adresse // E-Mail-Adresse
const [email, setEmail] = useState(initialEmail); const [email, setEmail] = useState(initialEmail);
const [emailPassword, setEmailPassword] = useState("");
const [emailError, setEmailError] = useState<string | null>(null); const [emailError, setEmailError] = useState<string | null>(null);
const [emailInfo, setEmailInfo] = useState<string | null>(null); const [emailInfo, setEmailInfo] = useState<string | null>(null);
const [emailBusy, setEmailBusy] = useState(false); const [emailBusy, setEmailBusy] = useState(false);
@@ -52,23 +53,45 @@ export default function AccountManager({
const [pwInfo, setPwInfo] = useState<string | null>(null); const [pwInfo, setPwInfo] = useState<string | null>(null);
const [pwBusy, setPwBusy] = useState(false); const [pwBusy, setPwBusy] = useState(false);
// Passwort-Anmeldung an/aus
const [passwordAuthEnabled, setPasswordAuthEnabled] = useState(
initialPasswordAuthEnabled
);
const [passkeyCount, setPasskeyCount] = useState<number | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [authInfo, setAuthInfo] = useState<string | null>(null);
const [authBusy, setAuthBusy] = useState(false);
useEffect(() => {
fetch("/api/auth/passkeys")
.then((res) => (res.ok ? res.json() : []))
.then((data) => setPasskeyCount(data.length));
}, []);
async function saveName(e: React.FormEvent) { async function saveName(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setNameError(null); setNameError(null);
setNameInfo(null); setNameInfo(null);
const name = fullName.trim(); const name = fullName.trim();
if (!name) { if (!name) {
setNameError("Bitte einen Namen angeben."); setNameError(dict.account.nameRequired);
return; return;
} }
setNameBusy(true); setNameBusy(true);
const { error } = await supabase const res = await fetch("/api/account", {
.from("profiles") method: "PATCH",
.update({ full_name: name }) headers: { "Content-Type": "application/json" },
.eq("id", (await supabase.auth.getUser()).data.user?.id ?? ""); body: JSON.stringify({ full_name: name }),
if (error) setNameError(`Speichern fehlgeschlagen: ${error.message}`); });
else { if (!res.ok) {
setNameInfo("Name gespeichert."); 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(); router.refresh();
} }
setNameBusy(false); setNameBusy(false);
@@ -81,13 +104,22 @@ export default function AccountManager({
const next = email.trim(); const next = email.trim();
if (!next || next === initialEmail) return; if (!next || next === initialEmail) return;
setEmailBusy(true); setEmailBusy(true);
const { error } = await supabase.auth.updateUser({ email: next }); const res = await fetch("/api/account/email", {
if (error) { method: "PATCH",
setEmailError(`Ändern fehlgeschlagen: ${germanAuthError(error.message)}`); headers: { "Content-Type": "application/json" },
} else { body: JSON.stringify({ email: next, password: emailPassword }),
setEmailInfo( });
"Bestätigungs-E-Mails wurden verschickt. Die neue Adresse wird erst nach Bestätigung des Links aktiv." if (!res.ok) {
const data = await res.json().catch(() => ({}));
setEmailError(
formatMessage(dict.account.changeError, {
message: errorMessage(dict, data.error, data.error ?? String(res.status)),
})
); );
} else {
setEmailInfo(dict.account.emailChanged);
setEmailPassword("");
router.refresh();
} }
setEmailBusy(false); setEmailBusy(false);
} }
@@ -97,31 +129,29 @@ export default function AccountManager({
setPwError(null); setPwError(null);
setPwInfo(null); setPwInfo(null);
if (newPassword.length < 8) { if (newPassword.length < 8) {
setPwError("Das neue Passwort muss mindestens 8 Zeichen lang sein."); setPwError(dict.account.passwordTooShort);
return; return;
} }
if (newPassword !== newPasswordRepeat) { if (newPassword !== newPasswordRepeat) {
setPwError("Die Passwörter stimmen nicht überein."); setPwError(dict.account.passwordMismatch);
return; return;
} }
setPwBusy(true); setPwBusy(true);
// Zur Verifizierung: aktuelles Passwort gegenprüfen, bevor geändert wird. const res = await fetch("/api/account/password", {
const { error: verifyError } = await supabase.auth.signInWithPassword({ method: "PATCH",
email: initialEmail, headers: { "Content-Type": "application/json" },
password: currentPassword, body: JSON.stringify({ currentPassword, newPassword }),
}); });
if (verifyError) { if (!res.ok) {
setPwError(germanAuthError(verifyError.message)); const data = await res.json().catch(() => ({}));
setPwBusy(false); setPwError(
return; formatMessage(dict.account.changeError, {
} message: errorMessage(dict, data.error, data.error ?? String(res.status)),
})
const { error } = await supabase.auth.updateUser({ password: newPassword }); );
if (error) {
setPwError(`Ändern fehlgeschlagen: ${germanAuthError(error.message)}`);
} else { } else {
setPwInfo("Passwort geändert."); setPwInfo(dict.account.passwordChanged);
setCurrentPassword(""); setCurrentPassword("");
setNewPassword(""); setNewPassword("");
setNewPasswordRepeat(""); setNewPasswordRepeat("");
@@ -129,21 +159,55 @@ export default function AccountManager({
setPwBusy(false); setPwBusy(false);
} }
async function togglePasswordAuth() {
setAuthError(null);
setAuthInfo(null);
const next = !passwordAuthEnabled;
if (!next && (passkeyCount ?? 0) === 0) {
setAuthError(dict.account.passwordAuthNeedsPasskey);
return;
}
if (!next && !window.confirm(dict.account.confirmDisablePasswordAuth)) return;
setAuthBusy(true);
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(
next ? dict.account.passwordAuthEnabled : dict.account.passwordAuthDisabled
);
}
setAuthBusy(false);
}
return ( return (
<div className="max-w-3xl space-y-6"> <div className="max-w-3xl space-y-6">
<div> <div>
<h1 className="text-xl font-bold text-[var(--c-primary)]">Mein Konto</h1> <h1 className="text-xl font-bold text-[var(--c-primary)]">
{dict.account.title}
</h1>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{initialEmail} – {ROLE_LABELS[role] ?? role} {initialEmail} – {roleLabel}
</p> </p>
</div> </div>
<section className="card"> <section className="card">
<h2 className="font-semibold mb-3">Persönliche Daten</h2> <h2 className="font-semibold mb-3">{dict.account.personalTitle}</h2>
<form onSubmit={saveName} className="space-y-3"> <form onSubmit={saveName} className="space-y-3">
<div className="max-w-80"> <div className="max-w-80">
<label className="label" htmlFor="account-name"> <label className="label" htmlFor="account-name">
Name {dict.common.name}
</label> </label>
<input <input
id="account-name" id="account-name"
@@ -160,17 +224,17 @@ export default function AccountManager({
)} )}
{nameInfo && <p className="text-sm text-green-700">{nameInfo}</p>} {nameInfo && <p className="text-sm text-green-700">{nameInfo}</p>}
<button type="submit" className="btn btn-add" disabled={nameBusy}> <button type="submit" className="btn btn-add" disabled={nameBusy}>
Name speichern {dict.account.saveName}
</button> </button>
</form> </form>
</section> </section>
<section className="card"> <section className="card">
<h2 className="font-semibold mb-3">E-Mail-Adresse</h2> <h2 className="font-semibold mb-3">{dict.account.emailTitle}</h2>
<form onSubmit={saveEmail} className="space-y-3"> <form onSubmit={saveEmail} className="space-y-3">
<div className="max-w-80"> <div className="max-w-80">
<label className="label" htmlFor="account-email"> <label className="label" htmlFor="account-email">
E-Mail-Adresse {dict.common.email}
</label> </label>
<input <input
id="account-email" id="account-email"
@@ -181,6 +245,20 @@ export default function AccountManager({
required required
/> />
</div> </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 && ( {emailError && (
<p className="text-sm text-[var(--c-del)]" role="alert"> <p className="text-sm text-[var(--c-del)]" role="alert">
{emailError} {emailError}
@@ -192,17 +270,17 @@ export default function AccountManager({
className="btn btn-add" className="btn btn-add"
disabled={emailBusy || email.trim() === initialEmail} disabled={emailBusy || email.trim() === initialEmail}
> >
E-Mail-Adresse ändern {dict.account.changeEmail}
</button> </button>
</form> </form>
</section> </section>
<section className="card"> <section className="card">
<h2 className="font-semibold mb-3">Passwort ändern</h2> <h2 className="font-semibold mb-3">{dict.account.passwordTitle}</h2>
<form onSubmit={savePassword} className="space-y-3"> <form onSubmit={savePassword} className="space-y-3">
<div className="max-w-80"> <div className="max-w-80">
<label className="label" htmlFor="account-current-password"> <label className="label" htmlFor="account-current-password">
Aktuelles Passwort (zur Verifizierung) {dict.account.currentPassword}
</label> </label>
<input <input
id="account-current-password" id="account-current-password"
@@ -216,7 +294,7 @@ export default function AccountManager({
</div> </div>
<div className="max-w-80"> <div className="max-w-80">
<label className="label" htmlFor="account-new-password"> <label className="label" htmlFor="account-new-password">
Neues Passwort {dict.account.newPassword}
</label> </label>
<input <input
id="account-new-password" id="account-new-password"
@@ -231,7 +309,7 @@ export default function AccountManager({
</div> </div>
<div className="max-w-80"> <div className="max-w-80">
<label className="label" htmlFor="account-new-password-repeat"> <label className="label" htmlFor="account-new-password-repeat">
Neues Passwort wiederholen {dict.account.newPasswordRepeat}
</label> </label>
<input <input
id="account-new-password-repeat" id="account-new-password-repeat"
@@ -251,11 +329,31 @@ export default function AccountManager({
)} )}
{pwInfo && <p className="text-sm text-green-700">{pwInfo}</p>} {pwInfo && <p className="text-sm text-green-700">{pwInfo}</p>}
<button type="submit" className="btn btn-add" disabled={pwBusy}> <button type="submit" className="btn btn-add" disabled={pwBusy}>
Passwort ändern {dict.account.changePassword}
</button> </button>
</form> </form>
</section> </section>
<section className="card">
<h2 className="font-semibold mb-1">{dict.account.passwordAuthTitle}</h2>
<p className="text-sm text-gray-600 mb-3">{dict.account.passwordAuthHint}</p>
{authError && (
<p className="mb-2 text-sm text-[var(--c-del)]" role="alert">
{authError}
</p>
)}
{authInfo && <p className="mb-2 text-sm text-green-700">{authInfo}</p>}
<label className="flex items-center gap-2 cursor-pointer text-sm w-fit">
<input
type="checkbox"
checked={passwordAuthEnabled}
onChange={togglePasswordAuth}
disabled={authBusy || passkeyCount === null}
/>
{dict.account.passwordAuthCheckbox}
</label>
</section>
<PasskeyManager /> <PasskeyManager />
</div> </div>
); );
+26 -21
View File
@@ -8,17 +8,8 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import type { Role } from "@/lib/types"; import type { Role } from "@/lib/types";
import { useDict } from "@/components/i18n/LocaleProvider";
const MENU_LINKS = [ import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
{ href: "/intern", label: "Wochenplan" },
{ href: "/intern/gerichte", label: "Gerichte" },
{ href: "/intern/kennzeichnung", label: "Allergene & Zusatzstoffe" },
];
const ADMIN_LINKS = [
{ href: "/admin", label: "Benutzer" },
{ href: "/admin/design", label: "Design" },
];
export default function AppHeader({ export default function AppHeader({
role, role,
@@ -28,6 +19,19 @@ export default function AppHeader({
displayName: string; displayName: string;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const dict = useDict();
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 },
];
const adminLinks = [
{ href: "/admin", label: dict.header.users },
{ href: "/admin/design", label: dict.header.design },
];
const linkClass = (href: string) => const linkClass = (href: string) =>
`rounded px-2 py-0.5 transition-colors hover:bg-white/10 ${ `rounded px-2 py-0.5 transition-colors hover:bg-white/10 ${
@@ -37,16 +41,16 @@ export default function AppHeader({
return ( return (
<header className="bg-[var(--c-primary-dark)] text-white"> <header className="bg-[var(--c-primary-dark)] text-white">
<div className="max-w-[1600px] mx-auto w-full px-4 py-3 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm"> <div className="max-w-[1600px] mx-auto w-full px-4 py-3 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<span className="font-bold text-base">Kantine</span> <span className="font-bold text-base">{dict.header.canteen}</span>
<nav <nav
className="flex flex-wrap items-center gap-x-1 gap-y-1" className="flex flex-wrap items-center gap-x-1 gap-y-1"
aria-label="Speiseplan" aria-label={dict.header.menuGroup}
> >
<span className="mr-1 text-xs uppercase tracking-wider text-white/50"> <span className="mr-1 text-xs uppercase tracking-wider text-white/50">
Speiseplan {dict.header.menuGroup}
</span> </span>
{MENU_LINKS.map((l) => ( {menuLinks.map((l) => (
<Link key={l.href} href={l.href} className={linkClass(l.href)}> <Link key={l.href} href={l.href} className={linkClass(l.href)}>
{l.label} {l.label}
</Link> </Link>
@@ -56,12 +60,12 @@ export default function AppHeader({
{role === "admin" && ( {role === "admin" && (
<nav <nav
className="flex flex-wrap items-center gap-x-1 gap-y-1" className="flex flex-wrap items-center gap-x-1 gap-y-1"
aria-label="Verwaltung" aria-label={dict.header.adminGroup}
> >
<span className="mr-1 text-xs uppercase tracking-wider text-white/50"> <span className="mr-1 text-xs uppercase tracking-wider text-white/50">
Verwaltung {dict.header.adminGroup}
</span> </span>
{ADMIN_LINKS.map((l) => ( {adminLinks.map((l) => (
<Link key={l.href} href={l.href} className={linkClass(l.href)}> <Link key={l.href} href={l.href} className={linkClass(l.href)}>
{l.label} {l.label}
</Link> </Link>
@@ -73,13 +77,14 @@ export default function AppHeader({
href="/" href="/"
className="rounded px-2 py-0.5 text-white/70 hover:bg-white/10" className="rounded px-2 py-0.5 text-white/70 hover:bg-white/10"
> >
Speisekarte ansehen {dict.header.viewMenu}
</Link> </Link>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<LanguageSwitcher />
<Link <Link
href="/konto" href="/konto"
title="Mein Konto" title={dict.header.myAccount}
className={`rounded px-2 py-0.5 hover:bg-white/10 ${ className={`rounded px-2 py-0.5 hover:bg-white/10 ${
pathname === "/konto" pathname === "/konto"
? "bg-white/20 font-semibold" ? "bg-white/20 font-semibold"
@@ -90,7 +95,7 @@ export default function AppHeader({
</Link> </Link>
<form action="/logout" method="post"> <form action="/logout" method="post">
<button type="submit" className="btn btn-del"> <button type="submit" className="btn btn-del">
Abmelden {dict.common.logout}
</button> </button>
</form> </form>
</div> </div>
+48
View File
@@ -0,0 +1,48 @@
"use client";
import type { CodeItem } from "@/lib/types";
import { localized } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
export default function ChipPicker({
items,
selected,
onToggle,
label,
}: {
items: CodeItem[];
selected: string[];
onToggle: (code: string) => void;
label: string;
}) {
const dict = useDict();
const locale = useLocale();
return (
<div>
<label className="label">{label}</label>
{items.length === 0 ? (
<p className="text-sm text-gray-400">{dict.codeList.empty}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{items.map((item) => {
const active = selected.includes(item.code);
const name = localized(item, locale);
return (
<button
key={item.code}
type="button"
onClick={() => onToggle(item.code)}
className={`chip ${active ? "chip-active" : ""}`}
aria-pressed={active}
title={name}
>
<span className="font-semibold">{item.code}</span> {name}
</button>
);
})}
</div>
)}
</div>
);
}
+136 -42
View File
@@ -3,33 +3,46 @@
// Pflege der globalen Allergen-/Zusatzstofflisten, die unterhalb // Pflege der globalen Allergen-/Zusatzstofflisten, die unterhalb
// der öffentlichen Speisekarte angezeigt werden. // der öffentlichen Speisekarte angezeigt werden.
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client"; import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
import type { CodeItem } from "@/lib/types"; import type { CodeItem } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
export default function CodeListManager({ export default function CodeListManager({
table, table,
title, translateEnabled,
codeLabel,
}: { }: {
table: "allergens" | "additives"; table: "allergens" | "additives";
title: string; translateEnabled: boolean;
codeLabel: string;
}) { }) {
const supabase = useMemo(() => createClient(), []); const dict = useDict();
const locale = useLocale();
const title =
table === "allergens" ? dict.codeList.allergensTitle : dict.codeList.additivesTitle;
const codeLabel =
table === "allergens"
? dict.codeList.allergenCodeLabel
: dict.codeList.additiveCodeLabel;
const [items, setItems] = useState<CodeItem[]>([]); const [items, setItems] = useState<CodeItem[]>([]);
const [editing, setEditing] = useState<{ const [editing, setEditing] = useState<{
original: string | null; // code vor der Bearbeitung, null = neu original: string | null; // code vor der Bearbeitung, null = neu
code: string; code: string;
name: string; name: string;
nameEn: string;
namePl: string;
} | null>(null); } | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle"
);
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, error } = await supabase.from(table).select("*").order("sort"); const res = await fetch(`/api/${table}`);
if (error) setError(`Laden fehlgeschlagen: ${error.message}`); if (!res.ok) setError(formatMessage(dict.codeList.loadError, { message: String(res.status) }));
else setItems(data as CodeItem[]); else setItems(await res.json());
}, [supabase, table]); }, [table, dict]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -42,24 +55,30 @@ export default function CodeListManager({
const code = editing.code.trim(); const code = editing.code.trim();
const name = editing.name.trim(); const name = editing.name.trim();
if (!code || !name) return; if (!code || !name) return;
const payload = {
code,
name,
name_en: editing.nameEn.trim() || null,
name_pl: editing.namePl.trim() || null,
};
let dbError; const res = editing.original
if (editing.original) { ? await fetch(`/api/${table}/${editing.original}`, {
({ error: dbError } = await supabase method: "PATCH",
.from(table) headers: { "Content-Type": "application/json" },
.update({ code, name }) body: JSON.stringify(payload),
.eq("code", editing.original)); })
} else { : await fetch(`/api/${table}`, {
const maxSort = items.reduce((m, i) => Math.max(m, i.sort), 0); method: "POST",
({ error: dbError } = await supabase headers: { "Content-Type": "application/json" },
.from(table) body: JSON.stringify(payload),
.insert({ code, name, sort: maxSort + 1 })); });
} if (!res.ok) {
if (dbError) { const data = await res.json().catch(() => ({}));
setError( setError(
/duplicate key/i.test(dbError.message) data.error === "duplicate_code"
? `Das Kürzel „${code}“ existiert bereits.` ? formatMessage(dict.codeList.duplicateCode, { code })
: `Speichern fehlgeschlagen: ${dbError.message}` : formatMessage(dict.codeList.saveError, { message: data.error ?? String(res.status) })
); );
return; return;
} }
@@ -68,13 +87,44 @@ export default function CodeListManager({
} }
async function remove(item: CodeItem) { async function remove(item: CodeItem) {
if (!window.confirm(`„${item.code} – ${item.name}“ wirklich löschen?`)) if (
!window.confirm(
formatMessage(dict.codeList.confirmDelete, { code: item.code, name: item.name })
)
)
return; return;
const { error } = await supabase.from(table).delete().eq("code", item.code); const res = await fetch(`/api/${table}/${item.code}`, { method: "DELETE" });
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`); if (!res.ok)
setError(formatMessage(dict.codeList.deleteError, { message: String(res.status) }));
await load(); await load();
} }
async function autoTranslate() {
if (!editing || !editing.name.trim()) return;
setTranslateState("busy");
setError(null);
try {
const res = await fetch("/api/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
table,
id: editing.original,
text: editing.name.trim(),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
setEditing((prev) =>
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev
);
setTranslateState("done");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setTranslateState("idle");
}
}
return ( return (
<section className="card"> <section className="card">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
@@ -84,9 +134,12 @@ export default function CodeListManager({
<button <button
type="button" type="button"
className="btn btn-add" className="btn btn-add"
onClick={() => setEditing({ original: null, code: "", name: "" })} onClick={() => {
setEditing({ original: null, code: "", name: "", nameEn: "", namePl: "" });
setTranslateState("idle");
}}
> >
Hinzufügen {dict.common.add}
</button> </button>
</div> </div>
@@ -108,7 +161,7 @@ export default function CodeListManager({
/> />
</div> </div>
<div> <div>
<label className="label">Bezeichnung</label> <label className="label">{dict.codeList.nameLabel}</label>
<input <input
className="input" className="input"
value={editing.name} value={editing.name}
@@ -116,16 +169,53 @@ export default function CodeListManager({
required required
/> />
</div> </div>
<div className="grid gap-2 sm:grid-cols-2">
<div>
<label className="label">{dict.dishManager.nameEnLabel}</label>
<input
className="input"
value={editing.nameEn}
onChange={(e) => setEditing({ ...editing, nameEn: e.target.value })}
/>
</div>
<div>
<label className="label">{dict.dishManager.namePlLabel}</label>
<input
className="input"
value={editing.namePl}
onChange={(e) => setEditing({ ...editing, namePl: e.target.value })}
/>
</div>
</div>
{translateEnabled && (
<div>
<button
type="button"
className="btn btn-neutral"
onClick={autoTranslate}
disabled={translateState === "busy" || !editing.name.trim()}
>
{translateState === "busy"
? dict.dishManager.translating
: dict.dishManager.autoTranslate}
</button>
{translateState === "done" && (
<span className="ml-2 text-sm text-green-700">
{dict.dishManager.translated}
</span>
)}
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<button type="submit" className="btn btn-add"> <button type="submit" className="btn btn-add">
Speichern {dict.common.save}
</button> </button>
<button <button
type="button" type="button"
className="btn btn-neutral" className="btn btn-neutral"
onClick={() => setEditing(null)} onClick={() => setEditing(null)}
> >
Abbrechen {dict.common.cancel}
</button> </button>
</div> </div>
</form> </form>
@@ -135,32 +225,36 @@ export default function CodeListManager({
{items.map((item) => ( {items.map((item) => (
<li key={item.code} className="flex items-center gap-2 py-1.5 text-sm"> <li key={item.code} className="flex items-center gap-2 py-1.5 text-sm">
<span className="mr-auto"> <span className="mr-auto">
<span className="font-semibold">{item.code}</span> – {item.name} <span className="font-semibold">{item.code}</span> –{" "}
{localized(item, locale)}
</span> </span>
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
onClick={() => onClick={() => {
setEditing({ setEditing({
original: item.code, original: item.code,
code: item.code, code: item.code,
name: item.name, name: item.name,
}) nameEn: item.name_en ?? "",
} namePl: item.name_pl ?? "",
});
setTranslateState("idle");
}}
> >
Bearbeiten {dict.common.edit}
</button> </button>
<button <button
type="button" type="button"
className="btn btn-del" className="btn btn-del"
onClick={() => remove(item)} onClick={() => remove(item)}
> >
Löschen {dict.common.delete}
</button> </button>
</li> </li>
))} ))}
{items.length === 0 && ( {items.length === 0 && (
<li className="py-2 text-gray-500 text-sm">Keine Einträge.</li> <li className="py-2 text-gray-500 text-sm">{dict.codeList.empty}</li>
)} )}
</ul> </ul>
</section> </section>
+124 -70
View File
@@ -1,32 +1,44 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client"; import { formatMessage } from "@/lib/i18n/shared";
import { COLOR_LABELS, DEFAULT_COLORS, logoUrl } from "@/lib/types"; import { DEFAULT_COLORS, faviconUrl, logoUrl } from "@/lib/types";
import { useDict } from "@/components/i18n/LocaleProvider";
type AssetKind = "logo" | "favicon";
export default function DesignEditor() { export default function DesignEditor() {
const supabase = useMemo(() => createClient(), []);
const router = useRouter(); const router = useRouter();
const dict = useDict();
const colorLabels: Record<string, string> = {
primary: dict.designEditor.colorPrimary,
primaryDark: dict.designEditor.colorPrimaryDark,
accent: dict.designEditor.colorAccent,
headerText: dict.designEditor.colorHeaderText,
rowBg: dict.designEditor.colorRowBg,
rowBgAlt: dict.designEditor.colorRowBgAlt,
addBtn: dict.designEditor.colorAddBtn,
deleteBtn: dict.designEditor.colorDeleteBtn,
};
const [colors, setColors] = useState<Record<string, string>>(DEFAULT_COLORS); const [colors, setColors] = useState<Record<string, string>>(DEFAULT_COLORS);
const [logoPath, setLogoPath] = useState<string | null>(null); const [logoPath, setLogoPath] = useState<string | null>(null);
const [faviconPath, setFaviconPath] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, error } = await supabase const res = await fetch("/api/settings");
.from("settings") if (!res.ok) {
.select("colors, logo_path") setError(formatMessage(dict.designEditor.loadError, { message: String(res.status) }));
.eq("id", 1)
.maybeSingle();
if (error) {
setError(`Laden fehlgeschlagen: ${error.message}`);
return; return;
} }
const data = await res.json();
setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) }); setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) });
setLogoPath(data?.logo_path ?? null); setLogoPath(data?.logo_path ?? null);
}, [supabase]); setFaviconPath(data?.favicon_path ?? null);
}, [dict]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -36,13 +48,15 @@ export default function DesignEditor() {
setBusy(true); setBusy(true);
setError(null); setError(null);
setInfo(null); setInfo(null);
const { error } = await supabase const res = await fetch("/api/settings", {
.from("settings") method: "PATCH",
.update({ colors }) headers: { "Content-Type": "application/json" },
.eq("id", 1); body: JSON.stringify({ colors }),
if (error) setError(`Speichern fehlgeschlagen: ${error.message}`); });
if (!res.ok)
setError(formatMessage(dict.designEditor.saveError, { message: String(res.status) }));
else { else {
setInfo("Farben gespeichert."); setInfo(dict.designEditor.colorsSaved);
router.refresh(); router.refresh();
} }
setBusy(false); setBusy(false);
@@ -52,73 +66,78 @@ export default function DesignEditor() {
setColors(DEFAULT_COLORS); setColors(DEFAULT_COLORS);
} }
async function uploadLogo(e: React.ChangeEvent<HTMLInputElement>) { async function uploadAsset(kind: AssetKind, e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
e.target.value = ""; e.target.value = "";
if (!file) return; if (!file) return;
if (!/^image\//.test(file.type)) { if (!/^image\//.test(file.type)) {
setError("Bitte eine Bilddatei auswählen (PNG, JPG, SVG, WebP)."); setError(dict.designEditor.invalidImage);
return; return;
} }
setBusy(true); setBusy(true);
setError(null); setError(null);
setInfo(null); setInfo(null);
const ext = file.name.split(".").pop()?.toLowerCase() ?? "png"; const formData = new FormData();
const path = `logo-${Date.now()}.${ext}`; formData.append("kind", kind);
const { error: uploadError } = await supabase.storage formData.append("file", file);
.from("branding") const res = await fetch("/api/admin/branding", { method: "POST", body: formData });
.upload(path, file, { contentType: file.type }); if (!res.ok) {
if (uploadError) { const data = await res.json().catch(() => ({}));
setError(`Upload fehlgeschlagen: ${uploadError.message}`); setError(
formatMessage(dict.designEditor.uploadError, {
message: data.error ?? String(res.status),
})
);
setBusy(false); setBusy(false);
return; return;
} }
const { path } = await res.json();
const oldPath = logoPath; if (kind === "logo") setLogoPath(path);
const { error: settingsError } = await supabase else setFaviconPath(path);
.from("settings") setInfo(kind === "logo" ? dict.designEditor.logoUpdated : dict.designEditor.faviconUpdated);
.update({ logo_path: path })
.eq("id", 1);
if (settingsError) {
setError(`Speichern fehlgeschlagen: ${settingsError.message}`);
setBusy(false);
return;
}
if (oldPath) {
await supabase.storage.from("branding").remove([oldPath]);
}
setLogoPath(path);
setInfo("Logo aktualisiert.");
router.refresh(); router.refresh();
setBusy(false); setBusy(false);
} }
async function removeLogo() { async function removeAsset(kind: AssetKind) {
if (!logoPath) return; const path = kind === "logo" ? logoPath : faviconPath;
if (!window.confirm("Logo wirklich entfernen?")) return; if (!path) return;
if (
!window.confirm(
kind === "logo"
? dict.designEditor.confirmRemoveLogo
: dict.designEditor.confirmRemoveFavicon
)
)
return;
setBusy(true); setBusy(true);
setError(null); setError(null);
const { error } = await supabase const res = await fetch("/api/admin/branding", {
.from("settings") method: "DELETE",
.update({ logo_path: null }) headers: { "Content-Type": "application/json" },
.eq("id", 1); body: JSON.stringify({ kind }),
if (error) { });
setError(`Speichern fehlgeschlagen: ${error.message}`); if (!res.ok) {
setError(formatMessage(dict.designEditor.saveError, { message: String(res.status) }));
} else { } else {
await supabase.storage.from("branding").remove([logoPath]); if (kind === "logo") setLogoPath(null);
setLogoPath(null); else setFaviconPath(null);
setInfo("Logo entfernt."); setInfo(kind === "logo" ? dict.designEditor.logoRemoved : dict.designEditor.faviconRemoved);
router.refresh(); router.refresh();
} }
setBusy(false); setBusy(false);
} }
const currentLogo = logoUrl(logoPath); const currentLogo = logoUrl(logoPath);
const currentFavicon = faviconUrl(faviconPath);
return ( return (
<div className="max-w-3xl space-y-6"> <div className="max-w-3xl space-y-6">
<h1 className="text-xl font-bold text-[var(--c-primary)]">Design</h1> <h1 className="text-xl font-bold text-[var(--c-primary)]">
{dict.designEditor.title}
</h1>
{error && ( {error && (
<p className="text-sm text-[var(--c-del)]" role="alert"> <p className="text-sm text-[var(--c-del)]" role="alert">
@@ -128,28 +147,26 @@ export default function DesignEditor() {
{info && <p className="text-sm text-green-700">{info}</p>} {info && <p className="text-sm text-green-700">{info}</p>}
<section className="card"> <section className="card">
<h2 className="font-semibold mb-3">Logo</h2> <h2 className="font-semibold mb-3">{dict.designEditor.logoTitle}</h2>
<p className="text-sm text-gray-600 mb-3"> <p className="text-sm text-gray-600 mb-3">{dict.designEditor.logoHint}</p>
Das Logo wird zentriert über der Speisekarte angezeigt.
</p>
{currentLogo ? ( {currentLogo ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={currentLogo} src={currentLogo}
alt="Aktuelles Logo" alt="Logo"
className="h-20 object-contain mb-3 border border-gray-200 rounded p-2" className="h-20 object-contain mb-3 border border-gray-200 rounded p-2"
/> />
) : ( ) : (
<p className="text-sm text-gray-400 mb-3">Kein Logo hinterlegt.</p> <p className="text-sm text-gray-400 mb-3">{dict.designEditor.logoNone}</p>
)} )}
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<label className="btn btn-primary cursor-pointer"> <label className="btn btn-primary cursor-pointer">
Logo hochladen {dict.designEditor.logoUpload}
<input <input
type="file" type="file"
accept="image/*" accept="image/png,image/jpeg,image/webp,image/gif,image/x-icon"
className="hidden" className="hidden"
onChange={uploadLogo} onChange={(e) => uploadAsset("logo", e)}
disabled={busy} disabled={busy}
/> />
</label> </label>
@@ -157,19 +174,56 @@ export default function DesignEditor() {
<button <button
type="button" type="button"
className="btn btn-del" className="btn btn-del"
onClick={removeLogo} onClick={() => removeAsset("logo")}
disabled={busy} disabled={busy}
> >
Logo entfernen {dict.designEditor.logoRemove}
</button> </button>
)} )}
</div> </div>
</section> </section>
<section className="card"> <section className="card">
<h2 className="font-semibold mb-3">Farbgebung</h2> <h2 className="font-semibold mb-3">{dict.designEditor.faviconTitle}</h2>
<p className="text-sm text-gray-600 mb-3">{dict.designEditor.faviconHint}</p>
{currentFavicon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={currentFavicon}
alt="Favicon"
className="h-10 w-10 object-contain mb-3 border border-gray-200 rounded p-1"
/>
) : (
<p className="text-sm text-gray-400 mb-3">{dict.designEditor.faviconNone}</p>
)}
<div className="flex gap-2 items-center">
<label className="btn btn-primary cursor-pointer">
{dict.designEditor.faviconUpload}
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/x-icon"
className="hidden"
onChange={(e) => uploadAsset("favicon", e)}
disabled={busy}
/>
</label>
{currentFavicon && (
<button
type="button"
className="btn btn-del"
onClick={() => removeAsset("favicon")}
disabled={busy}
>
{dict.designEditor.faviconRemove}
</button>
)}
</div>
</section>
<section className="card">
<h2 className="font-semibold mb-3">{dict.designEditor.colorsTitle}</h2>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{Object.entries(COLOR_LABELS).map(([key, label]) => ( {Object.entries(colorLabels).map(([key, label]) => (
<label key={key} className="flex items-center gap-3 text-sm"> <label key={key} className="flex items-center gap-3 text-sm">
<input <input
type="color" type="color"
@@ -190,7 +244,7 @@ export default function DesignEditor() {
onClick={saveColors} onClick={saveColors}
disabled={busy} disabled={busy}
> >
Farben speichern {dict.designEditor.colorsSave}
</button> </button>
<button <button
type="button" type="button"
@@ -198,7 +252,7 @@ export default function DesignEditor() {
onClick={resetColors} onClick={resetColors}
disabled={busy} disabled={busy}
> >
Auf Standard zurücksetzen {dict.designEditor.colorsReset}
</button> </button>
</div> </div>
</section> </section>
+253 -83
View File
@@ -1,27 +1,52 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client"; import { dishCodes, formatPrice } from "@/lib/menu";
import { formatPrice } from "@/lib/menu"; import { formatMessage } from "@/lib/i18n/shared";
import type { Dish } from "@/lib/types"; import { localized } from "@/lib/types";
import type { CodeItem, Dish } from "@/lib/types";
import ChipPicker from "@/components/ChipPicker";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
interface DishForm { interface DishForm {
id: string | null; // null = neues Gericht id: string | null; // null = neues Gericht
name: string; name: string;
nameEn: string;
namePl: string;
price: string; price: string;
allergens: string[];
additives: string[];
} }
const EMPTY_FORM: DishForm = { id: null, name: "", price: "" }; const EMPTY_FORM: DishForm = {
id: null,
name: "",
nameEn: "",
namePl: "",
price: "",
allergens: [],
additives: [],
};
export default function DishManager() { export default function DishManager({
const supabase = useMemo(() => createClient(), []); translateEnabled,
}: {
translateEnabled: boolean;
}) {
const dict = useDict();
const locale = useLocale();
const [dishes, setDishes] = useState<Dish[]>([]); const [dishes, setDishes] = useState<Dish[]>([]);
const [allergens, setAllergens] = useState<CodeItem[]>([]);
const [additives, setAdditives] = useState<CodeItem[]>([]);
const [form, setForm] = useState<DishForm | null>(null); const [form, setForm] = useState<DishForm | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false); const [bulkBusy, setBulkBusy] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [translateState, setTranslateState] = useState<"idle" | "busy" | "done">(
"idle"
);
// Live-Suche: filtert die Liste bereits während des Tippens // Live-Suche: filtert die Liste bereits während des Tippens
const visible = useMemo(() => { const visible = useMemo(() => {
@@ -30,14 +55,20 @@ export default function DishManager() {
}, [dishes, search]); }, [dishes, search]);
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, error } = await supabase const [dishesRes, allergensRes, additivesRes] = await Promise.all([
.from("dishes") fetch("/api/dishes"),
.select("*") fetch("/api/allergens"),
.order("name"); fetch("/api/additives"),
if (error) setError(`Laden fehlgeschlagen: ${error.message}`); ]);
else setDishes(data as Dish[]); 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); setLoading(false);
}, [supabase]); }, [dict]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -49,15 +80,30 @@ export default function DishManager() {
setError(null); setError(null);
const price = Number.parseFloat(form.price.replace(",", ".")); const price = Number.parseFloat(form.price.replace(",", "."));
if (Number.isNaN(price) || price < 0) { if (Number.isNaN(price) || price < 0) {
setError("Bitte einen gültigen Preis angeben, z. B. 3,50"); setError(dict.dishManager.invalidPrice);
return; return;
} }
const payload = { name: form.name.trim(), price }; const payload = {
const { error } = form.id name: form.name.trim(),
? await supabase.from("dishes").update(payload).eq("id", form.id) name_en: form.nameEn.trim() || null,
: await supabase.from("dishes").insert(payload); name_pl: form.namePl.trim() || null,
if (error) { price,
setError(`Speichern fehlgeschlagen: ${error.message}`); allergens: form.allergens,
additives: form.additives,
};
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; return;
} }
setForm(null); setForm(null);
@@ -67,12 +113,13 @@ export default function DishManager() {
async function remove(dish: Dish) { async function remove(dish: Dish) {
if ( if (
!window.confirm( !window.confirm(
`„${dish.name}“ wirklich löschen? Das Gericht wird auch aus allen Wochenplänen entfernt.` formatMessage(dict.dishManager.confirmDelete, { name: dish.name })
) )
) )
return; return;
const { error } = await supabase.from("dishes").delete().eq("id", dish.id); const res = await fetch(`/api/dishes/${dish.id}`, { method: "DELETE" });
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`); if (!res.ok)
setError(formatMessage(dict.dishManager.deleteError, { message: String(res.status) }));
setSelected((prev) => { setSelected((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(dish.id); next.delete(dish.id);
@@ -106,22 +153,72 @@ export default function DishManager() {
if (selected.size === 0) return; if (selected.size === 0) return;
if ( if (
!window.confirm( !window.confirm(
`${selected.size} Gericht(e) wirklich löschen? Sie werden auch aus allen Wochenplänen entfernt.` formatMessage(dict.dishManager.confirmDeleteSelected, {
count: selected.size,
})
) )
) )
return; return;
setBulkBusy(true); setBulkBusy(true);
setError(null); setError(null);
const { error } = await supabase const res = await fetch("/api/dishes/bulk-delete", {
.from("dishes") method: "POST",
.delete() headers: { "Content-Type": "application/json" },
.in("id", Array.from(selected)); body: JSON.stringify({ ids: Array.from(selected) }),
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`); });
if (!res.ok)
setError(formatMessage(dict.dishManager.deleteError, { message: String(res.status) }));
setSelected(new Set()); setSelected(new Set());
setBulkBusy(false); setBulkBusy(false);
await load(); await load();
} }
function toggleAllergen(code: string) {
if (!form) return;
setForm({
...form,
allergens: form.allergens.includes(code)
? form.allergens.filter((c) => c !== code)
: [...form.allergens, code],
});
}
function toggleAdditive(code: string) {
if (!form) return;
setForm({
...form,
additives: form.additives.includes(code)
? form.additives.filter((c) => c !== code)
: [...form.additives, code],
});
}
async function autoTranslate() {
if (!form || !form.name.trim()) return;
setTranslateState("busy");
setError(null);
try {
const res = await fetch("/api/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
table: "dishes",
id: form.id,
text: form.name.trim(),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
setForm((prev) =>
prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev
);
setTranslateState("done");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setTranslateState("idle");
}
}
const allSelected = const allSelected =
visible.length > 0 && visible.every((d) => selected.has(d.id)); visible.length > 0 && visible.every((d) => selected.has(d.id));
@@ -129,14 +226,17 @@ export default function DishManager() {
<div className="max-w-3xl"> <div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto"> <h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
Gerichte {dict.dishManager.title}
</h1> </h1>
<button <button
type="button" type="button"
className="btn btn-add" className="btn btn-add"
onClick={() => setForm(EMPTY_FORM)} onClick={() => {
setForm(EMPTY_FORM);
setTranslateState("idle");
}}
> >
Neues Gericht {dict.dishManager.newDish}
</button> </button>
</div> </div>
@@ -147,13 +247,13 @@ export default function DishManager() {
)} )}
{form && ( {form && (
<form onSubmit={save} className="card mb-4 space-y-3"> <form onSubmit={save} className="card mb-4 space-y-4">
<h2 className="font-semibold"> <h2 className="font-semibold">
{form.id ? "Gericht bearbeiten" : "Neues Gericht"} {form.id ? dict.dishManager.editDish : dict.dishManager.newDish}
</h2> </h2>
<div> <div>
<label className="label" htmlFor="dish-name"> <label className="label" htmlFor="dish-name">
Bezeichnung {dict.dishManager.nameLabel}
</label> </label>
<input <input
id="dish-name" id="dish-name"
@@ -165,45 +265,102 @@ export default function DishManager() {
</div> </div>
<div className="max-w-40"> <div className="max-w-40">
<label className="label" htmlFor="dish-price"> <label className="label" htmlFor="dish-price">
Preis in € {dict.dishManager.priceLabel}
</label> </label>
<input <input
id="dish-price" id="dish-price"
className="input" className="input"
inputMode="decimal" inputMode="decimal"
placeholder="3,50" placeholder={dict.dishManager.pricePlaceholder}
value={form.price} value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })} onChange={(e) => setForm({ ...form, price: e.target.value })}
required required
/> />
</div> </div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label" htmlFor="dish-name-en">
{dict.dishManager.nameEnLabel}
</label>
<input
id="dish-name-en"
className="input"
value={form.nameEn}
onChange={(e) => setForm({ ...form, nameEn: e.target.value })}
/>
</div>
<div>
<label className="label" htmlFor="dish-name-pl">
{dict.dishManager.namePlLabel}
</label>
<input
id="dish-name-pl"
className="input"
value={form.namePl}
onChange={(e) => setForm({ ...form, namePl: e.target.value })}
/>
</div>
</div>
{translateEnabled && (
<div>
<button
type="button"
className="btn btn-neutral"
onClick={autoTranslate}
disabled={translateState === "busy" || !form.name.trim()}
>
{translateState === "busy"
? dict.dishManager.translating
: dict.dishManager.autoTranslate}
</button>
{translateState === "done" && (
<span className="ml-2 text-sm text-green-700">
{dict.dishManager.translated}
</span>
)}
</div>
)}
<ChipPicker
label={dict.codeList.allergensTitle}
items={allergens}
selected={form.allergens}
onToggle={toggleAllergen}
/>
<ChipPicker
label={dict.codeList.additivesTitle}
items={additives}
selected={form.additives}
onToggle={toggleAdditive}
/>
<div className="flex gap-2"> <div className="flex gap-2">
<button type="submit" className="btn btn-add"> <button type="submit" className="btn btn-add">
Speichern {dict.common.save}
</button> </button>
<button <button
type="button" type="button"
className="btn btn-neutral" className="btn btn-neutral"
onClick={() => setForm(null)} onClick={() => setForm(null)}
> >
Abbrechen {dict.common.cancel}
</button> </button>
</div> </div>
</form> </form>
)} )}
{loading ? ( {loading ? (
<p className="text-gray-500">Lade Gerichte…</p> <p className="text-gray-500">{dict.common.loading}</p>
) : ( ) : (
<> <>
{dishes.length > 0 && ( {dishes.length > 0 && (
<input <input
type="search" type="search"
className="input mb-3" className="input mb-3"
placeholder="Gericht suchen…" placeholder={dict.dishManager.searchPlaceholder}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
aria-label="Gerichte durchsuchen" aria-label={dict.dishManager.searchPlaceholder}
/> />
)} )}
{dishes.length > 0 && ( {dishes.length > 0 && (
@@ -214,10 +371,10 @@ export default function DishManager() {
checked={allSelected} checked={allSelected}
onChange={toggleAll} onChange={toggleAll}
/> />
Alle auswählen {dict.dishManager.selectAll}
</label> </label>
<span className="text-gray-500"> <span className="text-gray-500">
{selected.size} ausgewählt {formatMessage(dict.dishManager.selectedCount, { count: selected.size })}
</span> </span>
<button <button
type="button" type="button"
@@ -225,53 +382,66 @@ export default function DishManager() {
onClick={removeSelected} onClick={removeSelected}
disabled={selected.size === 0 || bulkBusy} disabled={selected.size === 0 || bulkBusy}
> >
Ausgewählte löschen{selected.size > 0 ? ` (${selected.size})` : ""} {selected.size > 0
? formatMessage(dict.dishManager.deleteSelectedCount, {
count: selected.size,
})
: dict.dishManager.deleteSelected}
</button> </button>
</div> </div>
)} )}
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white"> <ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{visible.map((dish) => ( {visible.map((dish) => {
<li key={dish.id} className="flex items-center gap-3 px-4 py-2"> const codes = dishCodes(dish);
<input return (
type="checkbox" <li key={dish.id} className="flex items-center gap-3 px-4 py-2">
checked={selected.has(dish.id)} <input
onChange={() => toggleOne(dish.id)} type="checkbox"
aria-label={`${dish.name} auswählen`} checked={selected.has(dish.id)}
/> onChange={() => toggleOne(dish.id)}
<span className="mr-auto"> aria-label={dish.name}
<span className="font-semibold">{dish.name}</span>{" "} />
<span className="text-gray-500">– {formatPrice(dish.price)}</span> <span className="mr-auto">
</span> <span className="font-semibold">{localized(dish, locale)}</span>
<button {codes && <sup className="dish-codes">{codes}</sup>}{" "}
type="button" <span className="text-gray-500">
className="btn btn-primary" – {formatPrice(dish.price)}
onClick={() => </span>
setForm({ </span>
id: dish.id, <button
name: dish.name, type="button"
price: String(dish.price).replace(".", ","), className="btn btn-primary"
}) onClick={() => {
} setForm({
> id: dish.id,
Bearbeiten name: dish.name,
</button> nameEn: dish.name_en ?? "",
<button namePl: dish.name_pl ?? "",
type="button" price: String(dish.price).replace(".", ","),
className="btn btn-del" allergens: dish.allergens ?? [],
onClick={() => remove(dish)} additives: dish.additives ?? [],
> });
Löschen setTranslateState("idle");
</button> }}
</li> >
))} {dict.common.edit}
</button>
<button
type="button"
className="btn btn-del"
onClick={() => remove(dish)}
>
{dict.common.delete}
</button>
</li>
);
})}
{dishes.length === 0 && ( {dishes.length === 0 && (
<li className="px-4 py-3 text-gray-500"> <li className="px-4 py-3 text-gray-500">{dict.dishManager.empty}</li>
Noch keine Gerichte angelegt.
</li>
)} )}
{dishes.length > 0 && visible.length === 0 && ( {dishes.length > 0 && visible.length === 0 && (
<li className="px-4 py-3 text-gray-500"> <li className="px-4 py-3 text-gray-500">
Keine Treffer für „{search}“. {formatMessage(dict.dishManager.noResults, { query: search })}
</li> </li>
)} )}
</ul> </ul>
+46 -37
View File
@@ -1,27 +1,32 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client"; import { startRegistration } from "@simplewebauthn/browser";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
interface PasskeyItem { interface PasskeyItem {
id: string; id: string;
friendly_name?: string; name: string | null;
created_at: string; created_at: string;
last_used_at?: string; last_used_at: string | null;
} }
export default function PasskeyManager() { export default function PasskeyManager() {
const supabase = useMemo(() => createClient(), []); const dict = useDict();
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]); const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, error } = await supabase.auth.passkey.list(); const res = await fetch("/api/auth/passkeys");
if (error) setError(`Passkeys konnten nicht geladen werden: ${error.message}`); if (!res.ok) {
else setPasskeys((data ?? []) as PasskeyItem[]); setError(formatMessage(dict.passkeys.loadError, { message: String(res.status) }));
}, [supabase]); return;
}
setPasskeys(await res.json());
}, [dict]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -32,39 +37,46 @@ export default function PasskeyManager() {
setInfo(null); setInfo(null);
setBusy(true); setBusy(true);
try { try {
const { error } = await supabase.auth.registerPasskey(); const optionsRes = await fetch("/api/auth/passkeys/register-options", { method: "POST" });
if (error) { if (!optionsRes.ok) throw new Error(String(optionsRes.status));
setError(`Registrierung fehlgeschlagen: ${error.message}`); const options = await optionsRes.json();
} else { const response = await startRegistration({ optionsJSON: options });
setInfo("Passkey wurde erfolgreich registriert.");
await load(); 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) { } catch (err) {
setError( setError(
/NotAllowedError|abort/i.test(String(err)) /NotAllowedError|abort/i.test(String(err))
? "Die Passkey-Registrierung wurde abgebrochen." ? dict.passkeys.registerCancelled
: `Registrierung fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}` : formatMessage(dict.passkeys.registerError, {
message: err instanceof Error ? err.message : String(err),
})
); );
} }
setBusy(false); setBusy(false);
} }
async function remove(pk: PasskeyItem) { async function remove(pk: PasskeyItem) {
if (!window.confirm("Diesen Passkey wirklich löschen?")) return; if (!window.confirm(dict.passkeys.confirmDelete)) return;
const { error } = await supabase.auth.passkey.delete({ passkeyId: pk.id }); const res = await fetch(`/api/auth/passkeys/${pk.id}`, { method: "DELETE" });
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`); if (!res.ok)
setError(formatMessage(dict.passkeys.deleteError, { message: String(res.status) }));
await load(); await load();
} }
return ( return (
<section className="card"> <section className="card">
<h2 className="font-semibold mb-1">Passkeys</h2> <h2 className="font-semibold mb-1">{dict.passkeys.title}</h2>
<p className="text-sm text-gray-600 mb-3"> <p className="text-sm text-gray-600 mb-3">{dict.passkeys.hint}</p>
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.
</p>
{error && ( {error && (
<p className="mb-2 text-sm text-[var(--c-del)]" role="alert"> <p className="mb-2 text-sm text-[var(--c-del)]" role="alert">
@@ -79,19 +91,18 @@ export default function PasskeyManager() {
onClick={register} onClick={register}
disabled={busy} disabled={busy}
> >
Neuen Passkey registrieren {dict.passkeys.register}
</button> </button>
<ul className="divide-y divide-gray-100"> <ul className="divide-y divide-gray-100">
{passkeys.map((pk) => ( {passkeys.map((pk) => (
<li key={pk.id} className="flex items-center gap-3 py-1.5 text-sm"> <li key={pk.id} className="flex items-center gap-3 py-1.5 text-sm">
<span className="mr-auto"> <span className="mr-auto">
<span className="font-semibold"> <span className="font-semibold">{pk.name || "Passkey"}</span>{" "}
{pk.friendly_name || "Passkey"}
</span>{" "}
<span className="text-gray-500"> <span className="text-gray-500">
– angelegt am{" "} {formatMessage(dict.passkeys.createdOn, {
{new Date(pk.created_at).toLocaleDateString("de-DE")} date: new Date(pk.created_at).toLocaleDateString(dict.dateLocale),
})}
</span> </span>
</span> </span>
<button <button
@@ -99,14 +110,12 @@ export default function PasskeyManager() {
className="btn btn-del" className="btn btn-del"
onClick={() => remove(pk)} onClick={() => remove(pk)}
> >
Löschen {dict.common.delete}
</button> </button>
</li> </li>
))} ))}
{passkeys.length === 0 && ( {passkeys.length === 0 && (
<li className="py-2 text-gray-500 text-sm"> <li className="py-2 text-gray-500 text-sm">{dict.passkeys.empty}</li>
Noch keine Passkeys registriert.
</li>
)} )}
</ul> </ul>
</section> </section>
+182
View File
@@ -0,0 +1,182 @@
"use client";
// Mobile Ansicht der öffentlichen Speisekarte: Tag (Standard) oder Woche,
// umschaltbar ohne Seiten-Reload. Ab dem "sm"-Breakpoint übernimmt die
// normale Wochentabelle (siehe app/page.tsx), diese Komponente blendet
// sich dort per CSS aus.
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import {
addDays,
dishCodes,
entriesFor,
formatDateLong,
formatPrice,
mondayOf,
todayISO,
weekdayIndex,
} from "@/lib/menu";
import { localized } from "@/lib/types";
import type { MenuEntry, Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
export default function PublicMobileMenu({
monday,
sections,
entries,
}: {
monday: string;
sections: Section[];
entries: MenuEntry[];
}) {
const router = useRouter();
const dict = useDict();
const locale = useLocale();
const days = dict.weekdays.long.map((weekday, i) => ({
weekday,
short: dict.weekdays.short[i],
iso: addDays(monday, i),
}));
const isCurrentWeek = monday === mondayOf(todayISO());
const todayIdx = isCurrentWeek ? weekdayIndex(todayISO()) : -1;
const [tab, setTab] = useState<"tag" | "woche">("tag");
const [selectedIdx, setSelectedIdx] = useState(todayIdx >= 0 ? todayIdx : 0);
function goToday() {
const currentMonday = mondayOf(todayISO());
if (currentMonday === monday) {
const idx = weekdayIndex(todayISO());
setSelectedIdx(idx >= 0 ? idx : 0);
setTab("tag");
} else {
router.push(`/?woche=${currentMonday}`);
}
}
function renderDay(day: { weekday: string; iso: string }) {
return (
<div key={day.iso} className="mb-4 last:mb-0">
<h3 className="font-bold text-[var(--c-primary)] mb-2">
{day.weekday} · {formatDateLong(day.iso, dict.dateLocale)}
</h3>
<div className="space-y-3">
{sections.map((s) => {
const items = entriesFor(entries, day.iso, s.key);
return (
<div
key={s.key}
className="rounded-md border border-gray-200 overflow-hidden"
>
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
<span className="font-semibold">{localized(s, locale)}</span>{" "}
<span className="opacity-80">· {s.time_range}</span>
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm">
{items.length === 0 ? (
<p className="text-gray-500">{dict.publicMenu.noEntry}</p>
) : (
items.map((e) => {
const codes = dishCodes(e.dish);
return (
<p key={e.id} className="mb-1 last:mb-0">
<span className="font-semibold">
{localized(e.dish, locale)}
</span>
{codes && <sup className="dish-codes">{codes}</sup>}{" "}
– {formatPrice(e.dish.price)}
</p>
);
})
)}
</div>
</div>
);
})}
</div>
</div>
);
}
return (
<div className="sm:hidden mb-6">
<div className="flex justify-end mb-3">
<LanguageSwitcher variant="light" />
</div>
<div className="flex gap-2 mb-3">
<Link
href={`/?woche=${addDays(monday, -7)}`}
className="btn btn-add flex-1 justify-center"
>
{dict.publicMenu.prevWeekShort}
</Link>
<button
type="button"
onClick={goToday}
className="btn btn-primary flex-1"
>
{dict.publicMenu.today}
</button>
<Link
href={`/?woche=${addDays(monday, 7)}`}
className="btn btn-add flex-1 justify-center"
>
{dict.publicMenu.nextWeekShort}
</Link>
</div>
<div className="flex gap-2 mb-3" role="tablist" aria-label="Ansicht">
<button
type="button"
role="tab"
aria-selected={tab === "tag"}
onClick={() => setTab("tag")}
className={`btn flex-1 ${tab === "tag" ? "btn-primary" : "btn-neutral"}`}
>
{dict.publicMenu.dayTab}
</button>
<button
type="button"
role="tab"
aria-selected={tab === "woche"}
onClick={() => setTab("woche")}
className={`btn flex-1 ${tab === "woche" ? "btn-primary" : "btn-neutral"}`}
>
{dict.publicMenu.weekTab}
</button>
</div>
{tab === "tag" ? (
<>
<div className="flex gap-1 mb-3">
{days.map((d, i) => (
<button
key={d.iso}
type="button"
onClick={() => setSelectedIdx(i)}
className={`flex-1 rounded-md py-1.5 text-sm font-semibold border transition-colors ${
i === selectedIdx
? "bg-[var(--c-primary)] text-white border-[var(--c-primary)]"
: "bg-white text-gray-600 border-gray-200"
} ${i === todayIdx ? "ring-2 ring-[var(--c-accent)] ring-offset-1" : ""}`}
>
{d.short}
</button>
))}
</div>
{renderDay(days[selectedIdx])}
</>
) : (
<div>{days.map((d) => renderDay(d))}</div>
)}
<Link href="/vorschlag" className="btn btn-neutral w-full justify-center mt-2">
{dict.publicMenu.suggestDish}
</Link>
</div>
);
}
+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>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { formatMessage } from "@/lib/i18n/shared";
import { useDict } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
export default function SuggestionForm() {
const dict = useDict();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
// Honeypot: für echte Nutzer unsichtbares Feld. Füllt ein Bot es aus,
// tun wir so, als sei alles gut gegangen, ohne wirklich zu speichern.
const [website, setWebsite] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const trimmedName = name.trim();
if (!trimmedName) {
setError(dict.suggestions.nameRequired);
return;
}
if (website.trim()) {
// Bot-Falle ausgelöst: keine echte Anfrage, aber Erfolg vortäuschen.
setSuccess(true);
return;
}
setBusy(true);
const res = await fetch("/api/dish-suggestions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmedName, description: description.trim() || null }),
});
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;
}
setSuccess(true);
setBusy(false);
}
return (
<div className="flex-1 flex items-center justify-center p-4">
<div className="card w-full max-w-md">
<div className="flex justify-between items-center mb-1">
<h1 className="text-xl font-bold text-[var(--c-primary)]">
{dict.suggestions.formTitle}
</h1>
<LanguageSwitcher variant="light" />
</div>
<p className="text-sm text-gray-600 mb-4">{dict.suggestions.formHint}</p>
{success ? (
<p className="text-sm text-green-700">{dict.suggestions.success}</p>
) : (
<form onSubmit={submit} className="space-y-3">
<div>
<label className="label" htmlFor="suggestion-name">
{dict.suggestions.nameLabel}
</label>
<input
id="suggestion-name"
className="input"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div>
<label className="label" htmlFor="suggestion-description">
{dict.suggestions.descriptionLabel}
</label>
<textarea
id="suggestion-description"
className="input"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{/* Honeypot – für Menschen unsichtbar, keine echten Nutzerdaten */}
<div className="absolute -left-[9999px]" aria-hidden="true">
<label htmlFor="website">Website</label>
<input
id="website"
name="website"
type="text"
tabIndex={-1}
autoComplete="off"
value={website}
onChange={(e) => setWebsite(e.target.value)}
/>
</div>
{error && (
<p className="text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
<button type="submit" className="btn btn-primary w-full" disabled={busy}>
{busy ? dict.suggestions.submitting : dict.suggestions.submit}
</button>
</form>
)}
<p className="mt-4 text-center text-sm">
<Link href="/" className="text-gray-500 hover:text-[var(--c-primary)]">
{dict.suggestions.backToMenu}
</Link>
</p>
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use 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 dict = useDict();
const [items, setItems] = useState<DishSuggestion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const load = useCallback(async () => {
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);
}, [dict]);
useEffect(() => {
load();
}, [load]);
async function accept(item: DishSuggestion) {
setBusyId(item.id);
setError(null);
setInfo(null);
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;
}
setInfo(dict.suggestions.accepted);
setBusyId(null);
await load();
}
async function reject(item: DishSuggestion) {
if (!window.confirm(formatMessage(dict.suggestions.confirmReject, { name: item.name })))
return;
setBusyId(item.id);
setError(null);
setInfo(null);
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);
}
setBusyId(null);
await load();
}
return (
<div className="max-w-3xl">
<h1 className="text-xl font-bold text-[var(--c-primary)] mb-4">
{dict.suggestions.reviewTitle}
</h1>
{error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
{error}
</p>
)}
{info && <p className="mb-3 text-sm text-green-700">{info}</p>}
{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">
{items.map((item) => (
<li key={item.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
<span className="mr-auto">
<span className="font-semibold">{item.name}</span>
{item.description && (
<span className="block text-sm text-gray-600 mt-0.5">
{item.description}
</span>
)}
<span className="block text-xs text-gray-400 mt-0.5">
{formatMessage(dict.suggestions.submittedOn, {
date: new Date(item.created_at).toLocaleDateString(dict.dateLocale),
})}
</span>
</span>
<button
type="button"
className="btn btn-add"
onClick={() => accept(item)}
disabled={busyId === item.id}
>
{dict.suggestions.accept}
</button>
<button
type="button"
className="btn btn-del"
onClick={() => reject(item)}
disabled={busyId === item.id}
>
{dict.suggestions.reject}
</button>
</li>
))}
{items.length === 0 && (
<li className="px-4 py-3 text-gray-500">{dict.suggestions.reviewEmpty}</li>
)}
</ul>
)}
</div>
);
}
+55 -36
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { createClient } from "@/lib/supabase/client"; import { formatMessage } from "@/lib/i18n/shared";
import type { Profile, Role } from "@/lib/types"; import type { Profile, Role } from "@/lib/types";
import { useDict } from "@/components/i18n/LocaleProvider";
interface UserForm { interface UserForm {
id: string | null; // null = neuer Benutzer id: string | null; // null = neuer Benutzer
@@ -10,6 +11,7 @@ interface UserForm {
full_name: string; full_name: string;
role: Role; role: Role;
password: string; // bei Bearbeitung optional (leer = unverändert) password: string; // bei Bearbeitung optional (leer = unverändert)
passwordRepeat: string;
} }
const EMPTY_FORM: UserForm = { const EMPTY_FORM: UserForm = {
@@ -18,31 +20,26 @@ const EMPTY_FORM: UserForm = {
full_name: "", full_name: "",
role: "staff", role: "staff",
password: "", password: "",
passwordRepeat: "",
}; };
const ROLE_LABELS: Record<Role, string> = { export default function UserManager({ meId }: { meId: string }) {
admin: "Administrator", const dict = useDict();
staff: "Kantinenmitarbeiter", const roleLabels: Record<Role, string> = {
}; admin: dict.common.roleAdmin,
staff: dict.common.roleStaff,
export default function UserManager() { };
const supabase = useMemo(() => createClient(), []);
const [profiles, setProfiles] = useState<Profile[]>([]); const [profiles, setProfiles] = useState<Profile[]>([]);
const [me, setMe] = useState<string | null>(null);
const [form, setForm] = useState<UserForm | null>(null); const [form, setForm] = useState<UserForm | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
const [{ data: profileData, error: profileError }, { data: userData }] = const res = await fetch("/api/admin/users");
await Promise.all([ if (!res.ok)
supabase.from("profiles").select("*").order("created_at"), setError(formatMessage(dict.userManager.loadError, { message: String(res.status) }));
supabase.auth.getUser(), else setProfiles(await res.json());
]); }, [dict]);
if (profileError) setError(`Laden fehlgeschlagen: ${profileError.message}`);
else setProfiles(profileData as Profile[]);
setMe(userData.user?.id ?? null);
}, [supabase]);
useEffect(() => { useEffect(() => {
load(); load();
@@ -74,6 +71,10 @@ export default function UserManager() {
async function save(e: React.FormEvent) { async function save(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!form) return; if (!form) return;
if (form.password && form.password !== form.passwordRepeat) {
setError(dict.userManager.passwordMismatch);
return;
}
const ok = form.id const ok = form.id
? await callApi("PATCH", { ? await callApi("PATCH", {
id: form.id, id: form.id,
@@ -96,7 +97,7 @@ export default function UserManager() {
async function remove(profile: Profile) { async function remove(profile: Profile) {
if ( if (
!window.confirm( !window.confirm(
`Benutzer „${profile.email}“ wirklich löschen? Der Zugang wird dauerhaft entfernt.` formatMessage(dict.userManager.confirmDelete, { email: profile.email })
) )
) )
return; return;
@@ -107,14 +108,14 @@ export default function UserManager() {
<div className="max-w-3xl"> <div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto"> <h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
Benutzer {dict.userManager.title}
</h1> </h1>
<button <button
type="button" type="button"
className="btn btn-add" className="btn btn-add"
onClick={() => setForm(EMPTY_FORM)} onClick={() => setForm(EMPTY_FORM)}
> >
Neuer Benutzer {dict.userManager.newUser}
</button> </button>
</div> </div>
@@ -127,12 +128,12 @@ export default function UserManager() {
{form && ( {form && (
<form onSubmit={save} className="card mb-4 space-y-3"> <form onSubmit={save} className="card mb-4 space-y-3">
<h2 className="font-semibold"> <h2 className="font-semibold">
{form.id ? "Benutzer bearbeiten" : "Neuer Benutzer"} {form.id ? dict.userManager.editUser : dict.userManager.newUser}
</h2> </h2>
{!form.id && ( {!form.id && (
<div> <div>
<label className="label" htmlFor="user-email"> <label className="label" htmlFor="user-email">
E-Mail-Adresse {dict.common.email}
</label> </label>
<input <input
id="user-email" id="user-email"
@@ -146,7 +147,7 @@ export default function UserManager() {
)} )}
<div> <div>
<label className="label" htmlFor="user-name"> <label className="label" htmlFor="user-name">
Name {dict.common.name}
</label> </label>
<input <input
id="user-name" id="user-name"
@@ -157,7 +158,7 @@ export default function UserManager() {
</div> </div>
<div className="max-w-60"> <div className="max-w-60">
<label className="label" htmlFor="user-role"> <label className="label" htmlFor="user-role">
Rolle {dict.common.role}
</label> </label>
<select <select
id="user-role" id="user-role"
@@ -167,13 +168,13 @@ export default function UserManager() {
setForm({ ...form, role: e.target.value as Role }) setForm({ ...form, role: e.target.value as Role })
} }
> >
<option value="staff">Kantinenmitarbeiter</option> <option value="staff">{dict.common.roleStaff}</option>
<option value="admin">Administrator</option> <option value="admin">{dict.common.roleAdmin}</option>
</select> </select>
</div> </div>
<div className="max-w-60"> <div className="max-w-60">
<label className="label" htmlFor="user-password"> <label className="label" htmlFor="user-password">
{form.id ? "Neues Passwort (optional)" : "Passwort"} {form.id ? dict.userManager.newPasswordLabel : dict.common.password}
</label> </label>
<input <input
id="user-password" id="user-password"
@@ -186,16 +187,33 @@ export default function UserManager() {
autoComplete="new-password" autoComplete="new-password"
/> />
</div> </div>
<div className="max-w-60">
<label className="label" htmlFor="user-password-repeat">
{dict.userManager.repeatPasswordLabel}
</label>
<input
id="user-password-repeat"
type="password"
className="input"
value={form.passwordRepeat}
onChange={(e) =>
setForm({ ...form, passwordRepeat: e.target.value })
}
minLength={8}
required={!form.id || !!form.password}
autoComplete="new-password"
/>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<button type="submit" className="btn btn-add" disabled={busy}> <button type="submit" className="btn btn-add" disabled={busy}>
Speichern {dict.common.save}
</button> </button>
<button <button
type="button" type="button"
className="btn btn-neutral" className="btn btn-neutral"
onClick={() => setForm(null)} onClick={() => setForm(null)}
> >
Abbrechen {dict.common.cancel}
</button> </button>
</div> </div>
</form> </form>
@@ -207,9 +225,9 @@ export default function UserManager() {
<span className="mr-auto"> <span className="mr-auto">
<span className="font-semibold">{p.full_name || p.email}</span>{" "} <span className="font-semibold">{p.full_name || p.email}</span>{" "}
<span className="text-gray-500"> <span className="text-gray-500">
– {p.email} – {ROLE_LABELS[p.role]} – {p.email} – {roleLabels[p.role]}
</span> </span>
{p.id === me && <span className="text-gray-400"> (du)</span>} {p.id === meId && <span className="text-gray-400"> {dict.common.you}</span>}
</span> </span>
<button <button
type="button" type="button"
@@ -221,18 +239,19 @@ export default function UserManager() {
full_name: p.full_name, full_name: p.full_name,
role: p.role, role: p.role,
password: "", password: "",
passwordRepeat: "",
}) })
} }
> >
Bearbeiten {dict.common.edit}
</button> </button>
{p.id !== me && ( {p.id !== meId && (
<button <button
type="button" type="button"
className="btn btn-del" className="btn btn-del"
onClick={() => remove(p)} onClick={() => remove(p)}
> >
Löschen {dict.common.delete}
</button> </button>
)} )}
</li> </li>
+154 -83
View File
@@ -1,15 +1,26 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { createClient } from "@/lib/supabase/client"; import {
import { addDays, formatDateLong, formatPrice, WEEKDAYS } from "@/lib/menu"; addDays,
dishCodes,
entriesFor,
formatDateLong,
formatPrice,
mondayOf,
todayISO,
} from "@/lib/menu";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
import type { Dish, MenuEntry, Section } from "@/lib/types"; import type { Dish, MenuEntry, Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
export default function WeekEditor({ monday }: { monday: string }) { export default function WeekEditor({ monday }: { monday: string }) {
const supabase = useMemo(() => createClient(), []); const dict = useDict();
const locale = useLocale();
const friday = addDays(monday, 4); const friday = addDays(monday, 4);
const days = WEEKDAYS.map((weekday, i) => ({ const days = dict.weekdays.long.map((weekday, i) => ({
weekday, weekday,
iso: addDays(monday, i), iso: addDays(monday, i),
})); }));
@@ -24,30 +35,36 @@ export default function WeekEditor({ monday }: { monday: string }) {
null null
); );
const [selectedDish, setSelectedDish] = useState(""); 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 () => { const load = useCallback(async () => {
setError(null); setError(null);
const [se, di, en] = await Promise.all([ const [se, di, en] = await Promise.all([
supabase.from("sections").select("*").order("sort"), fetch("/api/sections"),
supabase.from("dishes").select("*").order("name"), fetch("/api/dishes"),
supabase fetch(`/api/menu-entries?from=${monday}&to=${friday}`),
.from("menu_entries")
.select("id, menu_date, section_key, position, dish:dishes(id, name, price)")
.gte("menu_date", monday)
.lte("menu_date", friday)
.order("position")
.order("created_at"),
]); ]);
const problem = se.error ?? di.error ?? en.error; if (!se.ok || !di.ok || !en.ok) {
if (problem) { setError(formatMessage(dict.weekEditor.loadError, { message: "load failed" }));
setError(`Laden fehlgeschlagen: ${problem.message}`);
} else { } else {
setSections(se.data as Section[]); setSections(await se.json());
setDishes(di.data as Dish[]); setDishes(await di.json());
setEntries(en.data as unknown as MenuEntry[]); setEntries(await en.json());
} }
setLoading(false); setLoading(false);
}, [supabase, monday, friday]); }, [monday, friday, dict]);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
@@ -56,43 +73,58 @@ export default function WeekEditor({ monday }: { monday: string }) {
async function addEntry() { async function addEntry() {
if (!adding || !selectedDish) return; if (!adding || !selectedDish) return;
const { error } = await supabase.from("menu_entries").insert({ const res = await fetch("/api/menu-entries", {
menu_date: adding.iso, method: "POST",
section_key: adding.section, headers: { "Content-Type": "application/json" },
dish_id: selectedDish, 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( setError(
/duplicate key/i.test(error.message) data.error === "duplicate_entry"
? "Dieses Gericht ist an diesem Tag bereits zugewiesen." ? dict.weekEditor.duplicateEntry
: `Speichern fehlgeschlagen: ${error.message}` : formatMessage(dict.weekEditor.saveError, { message: data.error ?? String(res.status) })
); );
} }
setAdding(null); cancelAdding();
setSelectedDish("");
await load(); await load();
} }
async function removeEntry(id: string) { async function removeEntry(id: string) {
const { error } = await supabase.from("menu_entries").delete().eq("id", id); const res = await fetch(`/api/menu-entries/${id}`, { method: "DELETE" });
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`); if (!res.ok)
setError(formatMessage(dict.weekEditor.deleteError, { message: String(res.status) }));
await load(); await load();
} }
const cell = (iso: string, sectionKey: string) => const [hintBefore, hintAfter] = dict.weekEditor.newDishesHint.split("{link}");
entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey); 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 ( return (
<div> <div>
<div className="flex flex-wrap items-center gap-3 mb-4"> <div className="flex flex-wrap items-center gap-3 mb-4">
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto"> <h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
Wochenplan {dict.weekEditor.title}
</h1> </h1>
<Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add"> <Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add">
← Vorherige Woche {dict.publicMenu.prevWeek}
</Link>
<Link
href={`/intern?woche=${mondayOf(todayISO())}`}
className="btn btn-primary"
>
{dict.publicMenu.today}
</Link> </Link>
<Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add"> <Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add">
Nächste Woche → {dict.publicMenu.nextWeek}
</Link> </Link>
</div> </div>
@@ -102,7 +134,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
</p> </p>
)} )}
{loading ? ( {loading ? (
<p className="text-gray-500">Lade Wochenplan…</p> <p className="text-gray-500">{dict.weekEditor.loading}</p>
) : ( ) : (
<table className="menu-table"> <table className="menu-table">
<thead> <thead>
@@ -112,7 +144,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
<th key={d.iso} className="day-head"> <th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span> <span className="font-bold">{d.weekday}</span>
<br /> <br />
{formatDateLong(d.iso)} {formatDateLong(d.iso, dict.dateLocale)}
</th> </th>
))} ))}
</tr> </tr>
@@ -121,7 +153,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
{sections.map((s) => ( {sections.map((s) => (
<tr key={s.key}> <tr key={s.key}>
<th className="section-head"> <th className="section-head">
<div>{s.label}</div> <div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div> <div className="time">{s.time_range}</div>
</th> </th>
{days.map((d) => { {days.map((d) => {
@@ -129,36 +161,81 @@ export default function WeekEditor({ monday }: { monday: string }) {
adding?.iso === d.iso && adding?.section === s.key; adding?.iso === d.iso && adding?.section === s.key;
return ( return (
<td key={d.iso} className="cell"> <td key={d.iso} className="cell">
{cell(d.iso, s.key).map((e) => ( {entriesFor(entries, d.iso, s.key).map((e) => {
<div key={e.id} className="dish"> const codes = dishCodes(e.dish);
<span className="font-bold">{e.dish.name}</span> return (
{" – "} <div key={e.id} className="dish">
{formatPrice(e.dish.price)} <span className="font-bold">
<div className="mt-1"> {localized(e.dish, locale)}
<button </span>
type="button" {codes && <sup className="dish-codes">{codes}</sup>}
className="btn btn-del" {" – "}
onClick={() => removeEntry(e.id)} {formatPrice(e.dish.price)}
> <div className="mt-1">
Löschen <button
</button> type="button"
className="btn btn-del"
onClick={() => removeEntry(e.id)}
>
{dict.common.delete}
</button>
</div>
</div> </div>
</div> );
))} })}
{isAdding ? ( {isAdding ? (
<div className="mt-2 space-y-1 text-left"> <div className="mt-2 space-y-1 text-left">
<select {selectedDishObj ? (
className="input" <div className="flex items-center gap-1 text-sm">
value={selectedDish} <span className="mr-auto">
onChange={(e) => setSelectedDish(e.target.value)} {localized(selectedDishObj, locale)} (
> {formatPrice(selectedDishObj.price)})
<option value="">– Gericht wählen –</option> </span>
{dishes.map((dish) => ( <button
<option key={dish.id} value={dish.id}> type="button"
{dish.name} ({formatPrice(dish.price)}) className="text-gray-400 hover:text-[var(--c-primary)]"
</option> onClick={() => {
))} setSelectedDish("");
</select> setDishSearch("");
}}
aria-label={dict.weekEditor.changeDish}
title={dict.weekEditor.changeDish}
>
×
</button>
</div>
) : (
<>
<input
type="search"
className="input"
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)}
>
{localized(dish, locale)} ({formatPrice(dish.price)})
</button>
</li>
))}
{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"> <div className="flex gap-1 justify-center">
<button <button
type="button" type="button"
@@ -166,17 +243,14 @@ export default function WeekEditor({ monday }: { monday: string }) {
onClick={addEntry} onClick={addEntry}
disabled={!selectedDish} disabled={!selectedDish}
> >
Hinzufügen {dict.common.add}
</button> </button>
<button <button
type="button" type="button"
className="btn btn-neutral" className="btn btn-neutral"
onClick={() => { onClick={cancelAdding}
setAdding(null);
setSelectedDish("");
}}
> >
Abbrechen {dict.common.cancel}
</button> </button>
</div> </div>
</div> </div>
@@ -184,12 +258,9 @@ export default function WeekEditor({ monday }: { monday: string }) {
<button <button
type="button" type="button"
className="btn btn-add mt-1" className="btn btn-add mt-1"
onClick={() => { onClick={() => startAdding(d.iso, s.key)}
setAdding({ iso: d.iso, section: s.key });
setSelectedDish("");
}}
> >
Mahlzeit hinzufügen {dict.weekEditor.addMeal}
</button> </button>
)} )}
</td> </td>
@@ -201,11 +272,11 @@ export default function WeekEditor({ monday }: { monday: string }) {
</table> </table>
)} )}
<p className="mt-4 text-sm text-gray-500"> <p className="mt-4 text-sm text-gray-500">
Neue Gerichte legst du unter{" "} {hintBefore}
<Link href="/intern/gerichte" className="underline"> <Link href="/intern/gerichte" className="underline">
Gerichte {dict.weekEditor.newDishesLinkLabel}
</Link>{" "} </Link>
an. {hintAfter}
</p> </p>
</div> </div>
); );
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { LOCALES, LOCALE_LABELS } from "@/lib/i18n/shared";
import type { Locale } from "@/lib/i18n/shared";
import { useLocale } from "@/components/i18n/LocaleProvider";
export default function LanguageSwitcher({
className = "",
variant = "dark",
}: {
className?: string;
variant?: "dark" | "light";
}) {
const locale = useLocale();
const router = useRouter();
const [busy, setBusy] = useState(false);
async function change(next: Locale) {
if (next === locale || busy) return;
setBusy(true);
await fetch("/api/locale", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locale: next }),
});
router.refresh();
setBusy(false);
}
const activeCls =
variant === "dark" ? "bg-white/25 text-white" : "bg-gray-200 text-gray-900";
const inactiveCls =
variant === "dark"
? "text-white/60 hover:text-white hover:bg-white/10"
: "text-gray-400 hover:text-gray-700 hover:bg-gray-100";
return (
<div className={`flex gap-1 text-xs ${className}`} aria-label="Sprache">
{LOCALES.map((l) => (
<button
key={l}
type="button"
onClick={() => change(l)}
disabled={busy}
aria-current={l === locale}
title={LOCALE_LABELS[l]}
className={`rounded px-1.5 py-0.5 uppercase font-semibold transition-colors ${
l === locale ? activeCls : inactiveCls
}`}
>
{l}
</button>
))}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
// Stellt Sprache + Wörterbuch für Client-Komponenten bereit. Wird einmal
// im Root-Layout mit dem serverseitig aus dem Cookie gelesenen Locale
// instanziiert; Client-Komponenten lesen darüber via useDict()/useLocale().
import { createContext, useContext, useMemo } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import type { Dict, Locale } from "@/lib/i18n/shared";
interface LocaleContextValue {
locale: Locale;
dict: Dict;
}
const LocaleContext = createContext<LocaleContextValue | null>(null);
export function LocaleProvider({
locale,
dict,
children,
}: LocaleContextValue & { children: React.ReactNode }) {
const value = useMemo(() => ({ locale, dict }), [locale, dict]);
return (
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
);
}
function useLocaleContext(): LocaleContextValue {
const ctx = useContext(LocaleContext);
if (!ctx) throw new Error("useDict/useLocale muss innerhalb von LocaleProvider verwendet werden.");
return ctx;
}
export function useDict(): Dict {
return useLocaleContext().dict;
}
export function useLocale(): Locale {
return useLocaleContext().locale;
}
/** t("Text mit {var}", { var: "Wert" }) für Platzhalter in Übersetzungen. */
export function useFormatMessage() {
return formatMessage;
}
+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;
+26 -19
View File
@@ -1,15 +1,12 @@
# ============================================================= # =============================================================
# Canteen Menu – Docker-Setup # Canteen Menu – Docker-Setup
# #
# Produktion (App + lokale PostgreSQL-Kopie): # Produktion (App + PostgreSQL):
# docker compose --env-file .env.local up -d --build # docker compose --env-file .env.local up -d --build
# #
# Entwicklung (Dev-Server mit Hot-Reload, kein lokales Node nötig): # Entwicklung (Dev-Server mit Hot-Reload, kein lokales Node nötig):
# docker compose --env-file .env.local --profile dev up dev # 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" – # "name" fixiert den Compose-Projektnamen explizit auf "canteen-menu" –
# ohne diese Angabe leitet Compose ihn aus dem Ordnernamen ab. # ohne diese Angabe leitet Compose ihn aus dem Ordnernamen ab.
# ============================================================= # =============================================================
@@ -21,26 +18,32 @@ services:
app: app:
build: build:
context: . context: .
args:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
image: canteen-menu-app image: canteen-menu-app
container_name: canteen-menu-app container_name: canteen-menu-app
environment: environment:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} DATABASE_URL: postgres://postgres:${LOCAL_PG_PASSWORD:-canteen_menu}@db:5432/canteen_menu
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
# Öffentliche URL der App (Domain hinter dem Reverse-Proxy). # Öffentliche URL der App (Domain hinter dem Reverse-Proxy).
# Wird für den CSRF-Origin-Check genutzt; dieselbe URL ist die # Wird für den CSRF-Origin-Check genutzt; dieselbe URL ist auch die
# Passkey-Domain (siehe scripts/configure-passkeys.mjs). # Passkey-Domain (RP-ID/Origin werden direkt daraus abgeleitet).
APP_URL: ${APP_URL:-http://localhost:3000} 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
# (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: ports:
- "0.0.0.0:3000:3000" - "0.0.0.0:3000:3000"
volumes:
- branding_data:/data/branding
- ./ZUGANGSDATEN.md:/app/ZUGANGSDATEN.md
depends_on:
- db
restart: unless-stopped restart: unless-stopped
# ---------- Lokale PostgreSQL-Kopie (Ablage/Auswertungen) ---------- # ---------- PostgreSQL (primäre Datenbank) ----------
# Beim ersten Start werden die Skripte aus supabase/local
# automatisch in alphabetischer Reihenfolge eingespielt.
db: db:
image: postgres:17-alpine image: postgres:17-alpine
container_name: canteen-menu-db container_name: canteen-menu-db
@@ -53,7 +56,6 @@ services:
- "5433:5432" - "5433:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
- ./supabase/local:/docker-entrypoint-initdb.d:ro
restart: unless-stopped restart: unless-stopped
# ---------- Dev-Server (nur mit --profile dev) ---------- # ---------- Dev-Server (nur mit --profile dev) ----------
@@ -64,16 +66,21 @@ services:
working_dir: /app working_dir: /app
command: sh -c "npm install && npm run dev" command: sh -c "npm install && npm run dev"
environment: environment:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} DATABASE_URL: postgres://postgres:${LOCAL_PG_PASSWORD:-canteen_menu}@db:5432/canteen_menu
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
APP_URL: ${APP_URL:-http://localhost:3000} APP_URL: ${APP_URL:-http://localhost:3000}
SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
DEEPL_API_KEY: ${DEEPL_API_KEY:-}
ports: ports:
- "0.0.0.0:3000:3000" - "0.0.0.0:3000:3000"
volumes: volumes:
- .:/app - .:/app
- dev_node_modules:/app/node_modules - dev_node_modules:/app/node_modules
- branding_data:/app/data/branding
depends_on:
- db
volumes: volumes:
pgdata: pgdata:
dev_node_modules: 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"
);
}
+764
View File
@@ -0,0 +1,764 @@
// Übersetzungswörterbücher für die komplette Oberfläche.
// "de" ist die Referenzstruktur; en/pl werden dagegen typgeprüft,
// damit kein Schlüssel in einer Sprache fehlt.
const de = {
common: {
save: "Speichern",
cancel: "Abbrechen",
delete: "Löschen",
edit: "Bearbeiten",
add: "Hinzufügen",
back: "Zurück",
login: "Anmelden",
logout: "Abmelden",
loading: "Lade…",
you: "(du)",
name: "Name",
email: "E-Mail-Adresse",
password: "Passwort",
role: "Rolle",
roleAdmin: "Administrator",
roleStaff: "Kantinenmitarbeiter",
},
weekdays: {
long: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"],
short: ["Mo", "Di", "Mi", "Do", "Fr"],
},
dateLocale: "de-DE",
header: {
canteen: "Kantine",
menuGroup: "Speiseplan",
weekPlan: "Wochenplan",
sections: "Essenszeiten",
dishes: "Gerichte",
labels: "Allergene & Zusatzstoffe",
adminGroup: "Verwaltung",
users: "Benutzer",
design: "Design",
suggestions: "Vorschläge",
viewMenu: "Speisekarte ansehen",
myAccount: "Mein Konto",
},
login: {
title: "Anmeldung",
submit: "Anmelden",
or: "oder",
passkeyButton: "Mit Passkey anmelden",
backToMenu: "← Zur Speisekarte",
errorInvalidCredentials: "E-Mail-Adresse oder Passwort ist falsch.",
errorPasskeyCancelled: "Passkey-Anmeldung wurde abgebrochen.",
errorGeneric: "Anmeldung fehlgeschlagen: {message}",
errorPasswordDisabled:
"Die Anmeldung mit Passwort ist für dieses Konto deaktiviert. Bitte mit Passkey anmelden.",
},
publicMenu: {
prevWeek: "← Vorherige Woche",
nextWeek: "Nächste Woche →",
prevWeekShort: "← Woche",
nextWeekShort: "Woche →",
today: "Heute",
dayTab: "Tag",
weekTab: "Woche",
allergens: "Allergene",
additives: "Zusatzstoffe",
suggestDish: "Rezept vorschlagen",
noEntry: "–",
},
weekEditor: {
title: "Wochenplan",
loading: "Lade Wochenplan…",
changeDish: "Anderes Gericht wählen",
addMeal: "Mahlzeit hinzufügen",
newDishesHint: "Neue Gerichte legst du unter {link} an.",
newDishesLinkLabel: "Gerichte",
loadError: "Laden fehlgeschlagen: {message}",
duplicateEntry: "Dieses Gericht ist an diesem Tag bereits zugewiesen.",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
},
dishManager: {
title: "Gerichte",
newDish: "Neues Gericht",
editDish: "Gericht bearbeiten",
nameLabel: "Bezeichnung",
nameEnLabel: "Name (Englisch)",
namePlLabel: "Name (Polnisch)",
autoTranslate: "Automatisch übersetzen",
translating: "Übersetze…",
translated: "Übersetzt.",
priceLabel: "Preis in €",
pricePlaceholder: "3,50",
searchPlaceholder: "Gericht suchen…",
selectAll: "Alle auswählen",
selectedCount: "{count} ausgewählt",
deleteSelected: "Ausgewählte löschen",
deleteSelectedCount: "Ausgewählte löschen ({count})",
empty: "Noch keine Gerichte angelegt.",
noResults: "Keine Treffer für „{query}“.",
confirmDelete:
"„{name}“ wirklich löschen? Das Gericht wird auch aus allen Wochenplänen entfernt.",
confirmDeleteSelected:
"{count} Gericht(e) wirklich löschen? Sie werden auch aus allen Wochenplänen entfernt.",
invalidPrice: "Bitte einen gültigen Preis angeben, z. B. 3,50",
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
},
codeList: {
allergensTitle: "Allergene",
additivesTitle: "Zusatzstoffe",
allergenCodeLabel: "Kürzel (z. B. A)",
additiveCodeLabel: "Kürzel (z. B. 1)",
nameLabel: "Bezeichnung",
empty: "Keine Einträge.",
confirmDelete: "„{code} – {name}“ wirklich löschen?",
duplicateCode: "Das Kürzel „{code}“ existiert bereits.",
loadError: "Laden fehlgeschlagen: {message}",
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",
editUser: "Benutzer bearbeiten",
newPasswordLabel: "Neues Passwort (optional)",
repeatPasswordLabel: "Passwort wiederholen",
passwordMismatch: "Die Passwörter stimmen nicht überein.",
confirmDelete:
"Benutzer „{email}“ wirklich löschen? Der Zugang wird dauerhaft entfernt.",
loadError: "Laden fehlgeschlagen: {message}",
},
designEditor: {
title: "Design",
logoTitle: "Logo",
logoHint: "Das Logo wird zentriert über der Speisekarte angezeigt.",
logoNone: "Kein Logo hinterlegt.",
logoUpload: "Logo hochladen",
logoRemove: "Logo entfernen",
logoUpdated: "Logo aktualisiert.",
logoRemoved: "Logo entfernt.",
faviconTitle: "Favicon",
faviconHint:
"Wird als Icon im Browser-Tab angezeigt. Ohne eigenes Favicon gilt das Standard-Icon der Anwendung.",
faviconNone: "Kein eigenes Favicon hinterlegt.",
faviconUpload: "Favicon hochladen",
faviconRemove: "Favicon entfernen",
faviconUpdated: "Favicon aktualisiert.",
faviconRemoved: "Favicon entfernt.",
invalidImage: "Bitte eine Bilddatei auswählen (PNG, JPG, SVG, WebP, ICO).",
colorsTitle: "Farbgebung",
colorsSave: "Farben speichern",
colorsReset: "Auf Standard zurücksetzen",
colorsSaved: "Farben gespeichert.",
colorPrimary: "Primärfarbe (Tabellenkopf)",
colorPrimaryDark: "Primärfarbe dunkel (Kopfleiste intern)",
colorAccent: "Akzentfarbe",
colorHeaderText: "Schriftfarbe im Tabellenkopf",
colorRowBg: "Zellen-Hintergrund",
colorRowBgAlt: "Zellen-Hintergrund (alternierend)",
colorAddBtn: "Button „Hinzufügen“",
colorDeleteBtn: "Button „Löschen“",
loadError: "Laden fehlgeschlagen: {message}",
saveError: "Speichern fehlgeschlagen: {message}",
uploadError: "Upload fehlgeschlagen: {message}",
confirmRemoveLogo: "Logo wirklich entfernen?",
confirmRemoveFavicon: "Favicon wirklich entfernen?",
},
passkeys: {
title: "Passkeys",
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}",
registered: "Passkey wurde erfolgreich registriert.",
registerError: "Registrierung fehlgeschlagen: {message}",
registerCancelled: "Die Passkey-Registrierung wurde abgebrochen.",
loadError: "Passkeys konnten nicht geladen werden: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
confirmDelete: "Diesen Passkey wirklich löschen?",
},
account: {
title: "Mein Konto",
personalTitle: "Persönliche Daten",
saveName: "Name speichern",
nameSaved: "Name gespeichert.",
nameRequired: "Bitte einen Namen angeben.",
emailTitle: "E-Mail-Adresse",
changeEmail: "E-Mail-Adresse ändern",
emailChanged: "E-Mail-Adresse geändert.",
passwordTitle: "Passwort ändern",
currentPassword: "Aktuelles Passwort (zur Verifizierung)",
newPassword: "Neues Passwort",
newPasswordRepeat: "Neues Passwort wiederholen",
changePassword: "Passwort ändern",
passwordChanged: "Passwort geändert.",
passwordMismatch: "Die Passwörter stimmen nicht überein.",
passwordTooShort: "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
passwordAuthTitle: "Anmeldung mit Passwort",
passwordAuthHint:
"Du kannst die Anmeldung mit E-Mail und Passwort für dein Konto deaktivieren und dich künftig ausschließlich per Passkey anmelden. Dafür muss mindestens ein Passkey registriert sein.",
passwordAuthCheckbox: "Anmeldung mit E-Mail und Passwort erlaubt",
passwordAuthEnabled: "Passwort-Anmeldung aktiviert.",
passwordAuthDisabled: "Passwort-Anmeldung deaktiviert.",
passwordAuthNeedsPasskey:
"Ohne registrierten Passkey kannst du die Passwort-Anmeldung nicht deaktivieren – sonst wärst du ausgesperrt. Registriere zuerst einen Passkey weiter unten.",
confirmDisablePasswordAuth:
"Anmeldung mit E-Mail und Passwort wirklich deaktivieren? Du kannst dich danach nur noch per Passkey anmelden.",
wrongCurrentPassword: "Das aktuelle Passwort ist falsch.",
emailAlreadyUsed: "Diese E-Mail-Adresse wird bereits verwendet.",
saveError: "Speichern fehlgeschlagen: {message}",
changeError: "Ändern fehlgeschlagen: {message}",
},
suggestions: {
formTitle: "Rezept vorschlagen",
formHint:
"Fehlt dir ein Gericht auf der Speisekarte? Schlage es hier vor – das Kantinen-Team sichtet deinen Vorschlag.",
nameLabel: "Gerichtname",
descriptionLabel: "Beschreibung / Zutaten (optional)",
submit: "Vorschlag senden",
submitting: "Wird gesendet…",
success: "Danke! Dein Vorschlag wurde übermittelt.",
duplicate: "Dieses Gericht gibt es bereits auf der Speisekarte.",
genericError: "Senden fehlgeschlagen: {message}",
nameRequired: "Bitte einen Namen angeben.",
backToMenu: "← Zur Speisekarte",
reviewTitle: "Rezeptvorschläge",
reviewEmpty: "Noch keine Vorschläge.",
accept: "Als Gericht übernehmen",
reject: "Ablehnen",
confirmReject: "Vorschlag „{name}“ wirklich ablehnen und löschen?",
accepted: "Als Gericht übernommen.",
rejected: "Vorschlag abgelehnt.",
loadError: "Laden fehlgeschlagen: {message}",
actionError: "Aktion fehlgeschlagen: {message}",
submittedOn: "eingereicht am {date}",
},
};
type Dict = typeof de;
const en: Dict = {
common: {
save: "Save",
cancel: "Cancel",
delete: "Delete",
edit: "Edit",
add: "Add",
back: "Back",
login: "Log in",
logout: "Log out",
loading: "Loading…",
you: "(you)",
name: "Name",
email: "Email address",
password: "Password",
role: "Role",
roleAdmin: "Administrator",
roleStaff: "Canteen staff",
},
weekdays: {
long: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
short: ["Mon", "Tue", "Wed", "Thu", "Fri"],
},
dateLocale: "en-GB",
header: {
canteen: "Canteen",
menuGroup: "Menu",
weekPlan: "Weekly plan",
sections: "Meal times",
dishes: "Dishes",
labels: "Allergens & Additives",
adminGroup: "Administration",
users: "Users",
design: "Design",
suggestions: "Suggestions",
viewMenu: "View menu",
myAccount: "My account",
},
login: {
title: "Sign in",
submit: "Log in",
or: "or",
passkeyButton: "Sign in with passkey",
backToMenu: "← Back to menu",
errorInvalidCredentials: "Email address or password is incorrect.",
errorPasskeyCancelled: "Passkey sign-in was cancelled.",
errorGeneric: "Sign-in failed: {message}",
errorPasswordDisabled:
"Password sign-in is disabled for this account. Please sign in with a passkey.",
},
publicMenu: {
prevWeek: "← Previous week",
nextWeek: "Next week →",
prevWeekShort: "← Week",
nextWeekShort: "Week →",
today: "Today",
dayTab: "Day",
weekTab: "Week",
allergens: "Allergens",
additives: "Additives",
suggestDish: "Suggest a dish",
noEntry: "–",
},
weekEditor: {
title: "Weekly plan",
loading: "Loading weekly plan…",
changeDish: "Choose a different dish",
addMeal: "Add meal",
newDishesHint: "Add new dishes under {link}.",
newDishesLinkLabel: "Dishes",
loadError: "Loading failed: {message}",
duplicateEntry: "This dish is already assigned on this day.",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
},
dishManager: {
title: "Dishes",
newDish: "New dish",
editDish: "Edit dish",
nameLabel: "Name",
nameEnLabel: "Name (English)",
namePlLabel: "Name (Polish)",
autoTranslate: "Auto-translate",
translating: "Translating…",
translated: "Translated.",
priceLabel: "Price in €",
pricePlaceholder: "3.50",
searchPlaceholder: "Search dishes…",
selectAll: "Select all",
selectedCount: "{count} selected",
deleteSelected: "Delete selected",
deleteSelectedCount: "Delete selected ({count})",
empty: "No dishes yet.",
noResults: "No results for “{query}”.",
confirmDelete:
"Really delete “{name}”? The dish will also be removed from all weekly plans.",
confirmDeleteSelected:
"Really delete {count} dish(es)? They will also be removed from all weekly plans.",
invalidPrice: "Please enter a valid price, e.g. 3.50",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
deleteError: "Deleting failed: {message}",
},
codeList: {
allergensTitle: "Allergens",
additivesTitle: "Additives",
allergenCodeLabel: "Code (e.g. A)",
additiveCodeLabel: "Code (e.g. 1)",
nameLabel: "Name",
empty: "No entries.",
confirmDelete: "Really delete “{code} – {name}”?",
duplicateCode: "The code “{code}” already exists.",
loadError: "Loading failed: {message}",
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",
editUser: "Edit user",
newPasswordLabel: "New password (optional)",
repeatPasswordLabel: "Repeat password",
passwordMismatch: "The passwords do not match.",
confirmDelete:
"Really delete user “{email}”? Access will be permanently removed.",
loadError: "Loading failed: {message}",
},
designEditor: {
title: "Design",
logoTitle: "Logo",
logoHint: "The logo is displayed centered above the menu.",
logoNone: "No logo set.",
logoUpload: "Upload logo",
logoRemove: "Remove logo",
logoUpdated: "Logo updated.",
logoRemoved: "Logo removed.",
faviconTitle: "Favicon",
faviconHint:
"Shown as the icon in the browser tab. Without a custom favicon, the application's default icon is used.",
faviconNone: "No custom favicon set.",
faviconUpload: "Upload favicon",
faviconRemove: "Remove favicon",
faviconUpdated: "Favicon updated.",
faviconRemoved: "Favicon removed.",
invalidImage: "Please choose an image file (PNG, JPG, SVG, WebP, ICO).",
colorsTitle: "Colors",
colorsSave: "Save colors",
colorsReset: "Reset to default",
colorsSaved: "Colors saved.",
colorPrimary: "Primary color (table header)",
colorPrimaryDark: "Primary color, dark (internal top bar)",
colorAccent: "Accent color",
colorHeaderText: "Table header text color",
colorRowBg: "Cell background",
colorRowBgAlt: "Cell background (alternating)",
colorAddBtn: "“Add” button",
colorDeleteBtn: "“Delete” button",
loadError: "Loading failed: {message}",
saveError: "Saving failed: {message}",
uploadError: "Upload failed: {message}",
confirmRemoveLogo: "Really remove the logo?",
confirmRemoveFavicon: "Really remove the favicon?",
},
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 – after a domain change they need to be registered again.",
register: "Register new passkey",
empty: "No passkeys registered yet.",
createdOn: "– created on {date}",
registered: "Passkey registered successfully.",
registerError: "Registration failed: {message}",
registerCancelled: "Passkey registration was cancelled.",
loadError: "Could not load passkeys: {message}",
deleteError: "Deleting failed: {message}",
confirmDelete: "Really delete this passkey?",
},
account: {
title: "My account",
personalTitle: "Personal details",
saveName: "Save name",
nameSaved: "Name saved.",
nameRequired: "Please enter a name.",
emailTitle: "Email address",
changeEmail: "Change email address",
emailChanged: "Email address changed.",
passwordTitle: "Change password",
currentPassword: "Current password (for verification)",
newPassword: "New password",
newPasswordRepeat: "Repeat new password",
changePassword: "Change password",
passwordChanged: "Password changed.",
passwordMismatch: "The passwords do not match.",
passwordTooShort: "The new password must be at least 8 characters long.",
passwordAuthTitle: "Password sign-in",
passwordAuthHint:
"You can disable email and password sign-in for your account and sign in exclusively with a passkey in the future. At least one passkey must be registered for this.",
passwordAuthCheckbox: "Email and password sign-in allowed",
passwordAuthEnabled: "Password sign-in enabled.",
passwordAuthDisabled: "Password sign-in disabled.",
passwordAuthNeedsPasskey:
"You can't disable password sign-in without a registered passkey – you would lock yourself out. Register a passkey below first.",
confirmDisablePasswordAuth:
"Really disable email and password sign-in? Afterwards you can only sign in with a passkey.",
wrongCurrentPassword: "The current password is incorrect.",
emailAlreadyUsed: "This email address is already in use.",
saveError: "Saving failed: {message}",
changeError: "Change failed: {message}",
},
suggestions: {
formTitle: "Suggest a dish",
formHint:
"Missing a dish on the menu? Suggest it here – the canteen team will review your suggestion.",
nameLabel: "Dish name",
descriptionLabel: "Description / ingredients (optional)",
submit: "Submit suggestion",
submitting: "Submitting…",
success: "Thanks! Your suggestion has been submitted.",
duplicate: "This dish already exists on the menu.",
genericError: "Submission failed: {message}",
nameRequired: "Please enter a name.",
backToMenu: "← Back to menu",
reviewTitle: "Dish suggestions",
reviewEmpty: "No suggestions yet.",
accept: "Add as dish",
reject: "Reject",
confirmReject: "Really reject and delete the suggestion “{name}”?",
accepted: "Added as dish.",
rejected: "Suggestion rejected.",
loadError: "Loading failed: {message}",
actionError: "Action failed: {message}",
submittedOn: "submitted on {date}",
},
};
const pl: Dict = {
common: {
save: "Zapisz",
cancel: "Anuluj",
delete: "Usuń",
edit: "Edytuj",
add: "Dodaj",
back: "Wstecz",
login: "Zaloguj się",
logout: "Wyloguj się",
loading: "Wczytywanie…",
you: "(ty)",
name: "Nazwa",
email: "Adres e-mail",
password: "Hasło",
role: "Rola",
roleAdmin: "Administrator",
roleStaff: "Personel stołówki",
},
weekdays: {
long: ["Poniedziałek", "Wtorek", "Środa", "Czwartek", "Piątek"],
short: ["Pon", "Wto", "Śro", "Czw", "Pią"],
},
dateLocale: "pl-PL",
header: {
canteen: "Stołówka",
menuGroup: "Jadłospis",
weekPlan: "Plan tygodnia",
sections: "Pory posiłków",
dishes: "Dania",
labels: "Alergeny i dodatki",
adminGroup: "Administracja",
users: "Użytkownicy",
design: "Wygląd",
suggestions: "Propozycje",
viewMenu: "Zobacz jadłospis",
myAccount: "Moje konto",
},
login: {
title: "Logowanie",
submit: "Zaloguj się",
or: "lub",
passkeyButton: "Zaloguj się kluczem dostępu",
backToMenu: "← Powrót do jadłospisu",
errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.",
errorPasskeyCancelled: "Logowanie kluczem dostępu zostało anulowane.",
errorGeneric: "Logowanie nie powiodło się: {message}",
errorPasswordDisabled:
"Logowanie hasłem jest wyłączone dla tego konta. Zaloguj się kluczem dostępu.",
},
publicMenu: {
prevWeek: "← Poprzedni tydzień",
nextWeek: "Następny tydzień →",
prevWeekShort: "← Tydzień",
nextWeekShort: "Tydzień →",
today: "Dziś",
dayTab: "Dzień",
weekTab: "Tydzień",
allergens: "Alergeny",
additives: "Dodatki",
suggestDish: "Zaproponuj danie",
noEntry: "–",
},
weekEditor: {
title: "Plan tygodnia",
loading: "Wczytywanie planu tygodnia…",
changeDish: "Wybierz inne danie",
addMeal: "Dodaj posiłek",
newDishesHint: "Nowe dania możesz dodać w zakładce {link}.",
newDishesLinkLabel: "Dania",
loadError: "Wczytywanie nie powiodło się: {message}",
duplicateEntry: "To danie jest już przypisane na ten dzień.",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
},
dishManager: {
title: "Dania",
newDish: "Nowe danie",
editDish: "Edytuj danie",
nameLabel: "Nazwa",
nameEnLabel: "Nazwa (angielski)",
namePlLabel: "Nazwa (polski)",
autoTranslate: "Przetłumacz automatycznie",
translating: "Tłumaczenie…",
translated: "Przetłumaczono.",
priceLabel: "Cena w €",
pricePlaceholder: "3,50",
searchPlaceholder: "Szukaj dania…",
selectAll: "Zaznacz wszystko",
selectedCount: "Zaznaczono: {count}",
deleteSelected: "Usuń zaznaczone",
deleteSelectedCount: "Usuń zaznaczone ({count})",
empty: "Nie dodano jeszcze żadnych dań.",
noResults: "Brak wyników dla „{query}”.",
confirmDelete:
"Czy na pewno usunąć „{name}”? Danie zostanie również usunięte ze wszystkich planów tygodniowych.",
confirmDeleteSelected:
"Czy na pewno usunąć {count} danie/dania? Zostaną również usunięte ze wszystkich planów tygodniowych.",
invalidPrice: "Podaj prawidłową cenę, np. 3,50",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
},
codeList: {
allergensTitle: "Alergeny",
additivesTitle: "Dodatki",
allergenCodeLabel: "Kod (np. A)",
additiveCodeLabel: "Kod (np. 1)",
nameLabel: "Nazwa",
empty: "Brak wpisów.",
confirmDelete: "Czy na pewno usunąć „{code} – {name}”?",
duplicateCode: "Kod „{code}” już istnieje.",
loadError: "Wczytywanie nie powiodło się: {message}",
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",
editUser: "Edytuj użytkownika",
newPasswordLabel: "Nowe hasło (opcjonalnie)",
repeatPasswordLabel: "Powtórz hasło",
passwordMismatch: "Hasła nie są zgodne.",
confirmDelete:
"Czy na pewno usunąć użytkownika „{email}”? Dostęp zostanie trwale usunięty.",
loadError: "Wczytywanie nie powiodło się: {message}",
},
designEditor: {
title: "Wygląd",
logoTitle: "Logo",
logoHint: "Logo jest wyświetlane wyśrodkowane nad jadłospisem.",
logoNone: "Nie ustawiono logo.",
logoUpload: "Prześlij logo",
logoRemove: "Usuń logo",
logoUpdated: "Logo zaktualizowane.",
logoRemoved: "Logo usunięte.",
faviconTitle: "Favicon",
faviconHint:
"Wyświetlane jako ikona na karcie przeglądarki. Bez własnego favicona używana jest domyślna ikona aplikacji.",
faviconNone: "Nie ustawiono własnego favicona.",
faviconUpload: "Prześlij favicon",
faviconRemove: "Usuń favicon",
faviconUpdated: "Favicon zaktualizowany.",
faviconRemoved: "Favicon usunięty.",
invalidImage: "Wybierz plik graficzny (PNG, JPG, SVG, WebP, ICO).",
colorsTitle: "Kolorystyka",
colorsSave: "Zapisz kolory",
colorsReset: "Przywróć domyślne",
colorsSaved: "Kolory zapisane.",
colorPrimary: "Kolor główny (nagłówek tabeli)",
colorPrimaryDark: "Kolor główny, ciemny (górny pasek wewnętrzny)",
colorAccent: "Kolor akcentu",
colorHeaderText: "Kolor tekstu w nagłówku tabeli",
colorRowBg: "Tło komórek",
colorRowBgAlt: "Tło komórek (naprzemienne)",
colorAddBtn: "Przycisk „Dodaj”",
colorDeleteBtn: "Przycisk „Usuń”",
loadError: "Wczytywanie nie powiodło się: {message}",
saveError: "Zapisywanie nie powiodło się: {message}",
uploadError: "Przesyłanie nie powiodło się: {message}",
confirmRemoveLogo: "Czy na pewno usunąć logo?",
confirmRemoveFavicon: "Czy na pewno usunąć favicon?",
},
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 – po zmianie domeny trzeba je zarejestrować ponownie.",
register: "Zarejestruj nowy klucz dostępu",
empty: "Nie zarejestrowano jeszcze żadnych kluczy dostępu.",
createdOn: "– utworzono {date}",
registered: "Klucz dostępu został pomyślnie zarejestrowany.",
registerError: "Rejestracja nie powiodła się: {message}",
registerCancelled: "Rejestracja klucza dostępu została anulowana.",
loadError: "Nie udało się wczytać kluczy dostępu: {message}",
deleteError: "Usuwanie nie powiodło się: {message}",
confirmDelete: "Czy na pewno usunąć ten klucz dostępu?",
},
account: {
title: "Moje konto",
personalTitle: "Dane osobowe",
saveName: "Zapisz nazwę",
nameSaved: "Nazwa zapisana.",
nameRequired: "Podaj nazwę.",
emailTitle: "Adres e-mail",
changeEmail: "Zmień adres e-mail",
emailChanged: "Adres e-mail zmieniony.",
passwordTitle: "Zmień hasło",
currentPassword: "Obecne hasło (do weryfikacji)",
newPassword: "Nowe hasło",
newPasswordRepeat: "Powtórz nowe hasło",
changePassword: "Zmień hasło",
passwordChanged: "Hasło zmienione.",
passwordMismatch: "Hasła nie są zgodne.",
passwordTooShort: "Nowe hasło musi mieć co najmniej 8 znaków.",
passwordAuthTitle: "Logowanie hasłem",
passwordAuthHint:
"Możesz wyłączyć logowanie e-mailem i hasłem dla swojego konta i logować się w przyszłości wyłącznie kluczem dostępu. Wymaga to zarejestrowania co najmniej jednego klucza dostępu.",
passwordAuthCheckbox: "Logowanie e-mailem i hasłem dozwolone",
passwordAuthEnabled: "Logowanie hasłem włączone.",
passwordAuthDisabled: "Logowanie hasłem wyłączone.",
passwordAuthNeedsPasskey:
"Bez zarejestrowanego klucza dostępu nie możesz wyłączyć logowania hasłem – stracisz dostęp do konta. Najpierw zarejestruj klucz dostępu poniżej.",
confirmDisablePasswordAuth:
"Czy na pewno wyłączyć logowanie e-mailem i hasłem? Potem będziesz mógł/mogła zalogować się tylko kluczem dostępu.",
wrongCurrentPassword: "Obecne hasło jest nieprawidłowe.",
emailAlreadyUsed: "Ten adres e-mail jest już używany.",
saveError: "Zapisywanie nie powiodło się: {message}",
changeError: "Zmiana nie powiodła się: {message}",
},
suggestions: {
formTitle: "Zaproponuj danie",
formHint:
"Brakuje dania w jadłospisie? Zaproponuj je tutaj – zespół stołówki sprawdzi Twoją propozycję.",
nameLabel: "Nazwa dania",
descriptionLabel: "Opis / składniki (opcjonalnie)",
submit: "Wyślij propozycję",
submitting: "Wysyłanie…",
success: "Dziękujemy! Twoja propozycja została przesłana.",
duplicate: "To danie już istnieje w jadłospisie.",
genericError: "Wysyłanie nie powiodło się: {message}",
nameRequired: "Podaj nazwę.",
backToMenu: "← Powrót do jadłospisu",
reviewTitle: "Propozycje dań",
reviewEmpty: "Brak propozycji.",
accept: "Dodaj jako danie",
reject: "Odrzuć",
confirmReject: "Czy na pewno odrzucić i usunąć propozycję „{name}”?",
accepted: "Dodano jako danie.",
rejected: "Propozycja odrzucona.",
loadError: "Wczytywanie nie powiodło się: {message}",
actionError: "Czynność nie powiodła się: {message}",
submittedOn: "przesłano {date}",
},
};
export type { Dict };
export const dictionaries = { de, en, pl };
+16
View File
@@ -0,0 +1,16 @@
// Nur für Server-Komponenten/Routen importieren (next/headers)!
// Client-Komponenten müssen stattdessen "@/lib/i18n/shared" verwenden,
// sonst landet next/headers im Client-Bundle.
import "server-only";
import { cookies } from "next/headers";
import { DEFAULT_LOCALE, isLocale, type Locale } from "./locales";
export * from "./shared";
/** Aktuelle Sprache aus dem Cookie lesen (Server-Komponenten). */
export async function getLocale(): Promise<Locale> {
const store = await cookies();
const value = store.get("locale")?.value;
return isLocale(value) ? value : DEFAULT_LOCALE;
}
+13
View File
@@ -0,0 +1,13 @@
export const LOCALES = ["de", "en", "pl"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "de";
export const LOCALE_LABELS: Record<Locale, string> = {
de: "Deutsch",
en: "English",
pl: "Polski",
};
export function isLocale(value: string | undefined | null): value is Locale {
return !!value && (LOCALES as readonly string[]).includes(value);
}
+26
View File
@@ -0,0 +1,26 @@
// Client-sicherer Teil von lib/i18n: keine Abhängigkeit auf next/headers,
// darf daher auch von "use client"-Komponenten importiert werden.
import { dictionaries, type Dict } from "./dictionaries";
import type { Locale } from "./locales";
export { LOCALES, LOCALE_LABELS, DEFAULT_LOCALE, isLocale } from "./locales";
export type { Locale } from "./locales";
export type { Dict } from "./dictionaries";
export const LOCALE_COOKIE = "locale";
export function getDictionary(locale: Locale): Dict {
return dictionaries[locale];
}
/** Platzhalter der Form "{name}" durch vars.name ersetzen. */
export function formatMessage(
template: string,
vars?: Record<string, string | number>
): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (match, key) =>
key in vars ? String(vars[key]) : match
);
}
+29 -4
View File
@@ -2,6 +2,8 @@
// Alle Berechnungen laufen über UTC, damit Server und Browser // Alle Berechnungen laufen über UTC, damit Server und Browser
// unabhängig von der Zeitzone dieselbe Woche ermitteln. // unabhängig von der Zeitzone dieselbe Woche ermitteln.
import type { MenuEntry } from "./types";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function toDate(iso: string): Date { function toDate(iso: string): Date {
@@ -31,11 +33,20 @@ export function addDays(iso: string, days: number): string {
return toISO(d); return toISO(d);
} }
export const WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"]; /** Heutiges Datum als YYYY-MM-DD (UTC). */
export function todayISO(): string {
return toISO(new Date());
}
/** z. B. "13. Juli 2026" */ /** Index (0=Montag…4=Freitag) von `iso` innerhalb seiner Woche, sonst -1 (Wochenende). */
export function formatDateLong(iso: string): string { export function weekdayIndex(iso: string): number {
return toDate(iso).toLocaleDateString("de-DE", { const day = (toDate(iso).getUTCDay() + 6) % 7;
return day <= 4 ? day : -1;
}
/** z. B. "13. Juli 2026" (dateLocale z. B. "de-DE", "en-GB", "pl-PL") */
export function formatDateLong(iso: string, dateLocale: string): string {
return toDate(iso).toLocaleDateString(dateLocale, {
day: "numeric", day: "numeric",
month: "long", month: "long",
year: "numeric", year: "numeric",
@@ -47,3 +58,17 @@ export function formatDateLong(iso: string): string {
export function formatPrice(price: number | string): string { export function formatPrice(price: number | string): string {
return Number(price).toFixed(2).replace(".", ",") + "€"; return Number(price).toFixed(2).replace(".", ",") + "€";
} }
/** Allergen-/Zusatzstoff-Kürzel eines Gerichts, z. B. "A,G,2" */
export function dishCodes(dish: { allergens: string[]; additives: string[] }): string {
return [...(dish.allergens ?? []), ...(dish.additives ?? [])].join(",");
}
/** Einträge eines Tages/einer Essenszeit aus der geladenen Wochenliste herausfiltern. */
export function entriesFor(
entries: MenuEntry[],
iso: string,
sectionKey: string
): MenuEntry[] {
return entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey);
}
+2 -2
View File
@@ -1,8 +1,8 @@
// CSRF-Schutz (Defense-in-Depth) für schreibende Route-Handler: // CSRF-Schutz (Defense-in-Depth) für schreibende Route-Handler:
// Browser senden bei Cross-Site-POSTs immer einen Origin-Header – // Browser senden bei Cross-Site-POSTs immer einen Origin-Header –
// stimmt er nicht mit dem Host überein, wird die Anfrage abgelehnt. // stimmt er nicht mit dem Host überein, wird die Anfrage abgelehnt.
// (Erste Verteidigungslinie sind bereits die SameSite=Lax-Session- // (Erste Verteidigungslinie ist bereits das SameSite=Lax-Session-Cookie,
// Cookies von @supabase/ssr sowie der JSON-Content-Type.) // siehe lib/auth/session.ts, sowie der JSON-Content-Type.)
export function isSameOrigin(request: Request): boolean { export function isSameOrigin(request: Request): boolean {
const origin = request.headers.get("origin"); const origin = request.headers.get("origin");
// Kein Origin-Header: kein Browser-Cross-Site-Kontext (z. B. curl); // 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.
}
},
},
}
);
}
+35 -12
View File
@@ -5,12 +5,15 @@ export interface Profile {
email: string; email: string;
full_name: string; full_name: string;
role: Role; role: Role;
password_auth_enabled: boolean;
created_at: string; created_at: string;
} }
export interface Section { export interface Section {
key: string; key: string;
label: string; label: string;
label_en: string | null;
label_pl: string | null;
time_range: string; time_range: string;
sort: number; sort: number;
} }
@@ -18,7 +21,11 @@ export interface Section {
export interface Dish { export interface Dish {
id: string; id: string;
name: string; name: string;
name_en: string | null;
name_pl: string | null;
price: number; price: number;
allergens: string[];
additives: string[];
} }
export interface MenuEntry { export interface MenuEntry {
@@ -32,9 +39,29 @@ export interface MenuEntry {
export interface CodeItem { export interface CodeItem {
code: string; code: string;
name: string; name: string;
name_en: string | null;
name_pl: string | null;
sort: number; sort: number;
} }
export interface DishSuggestion {
id: string;
name: string;
description: string | null;
created_at: string;
}
/** Übersetzten Namen für die aktuelle Sprache liefern, sonst Deutsch als Fallback. */
export function localized(
item: { name?: string; label?: string; name_en?: string | null; name_pl?: string | null; label_en?: string | null; label_pl?: string | null },
locale: string
): string {
const de = item.name ?? item.label ?? "";
if (locale === "en") return item.name_en || item.label_en || de;
if (locale === "pl") return item.name_pl || item.label_pl || de;
return de;
}
// Farb-Defaults (r-hofmann.de: Blau #003388, Navy #0d315c, Orange #ff6900) // Farb-Defaults (r-hofmann.de: Blau #003388, Navy #0d315c, Orange #ff6900)
export const DEFAULT_COLORS: Record<string, string> = { export const DEFAULT_COLORS: Record<string, string> = {
primary: "#003388", primary: "#003388",
@@ -58,18 +85,14 @@ export const COLOR_VARS: Record<string, string> = {
deleteBtn: "--c-del", deleteBtn: "--c-del",
}; };
export const COLOR_LABELS: Record<string, string> = {
primary: "Primärfarbe (Tabellenkopf)",
primaryDark: "Primärfarbe dunkel (Kopfleiste intern)",
accent: "Akzentfarbe",
headerText: "Schriftfarbe im Tabellenkopf",
rowBg: "Zellen-Hintergrund",
rowBgAlt: "Zellen-Hintergrund (alternierend)",
addBtn: "Button „Hinzufügen“",
deleteBtn: "Button „Löschen“",
};
export function logoUrl(path: string | null | undefined): string | null { export function logoUrl(path: string | null | undefined): string | null {
if (!path) return 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 `/branding/${path}`;
}
export const SITE_TITLE = process.env.SITE_TITLE?.trim() || "Canteen Menu";
+417 -114
View File
@@ -1,16 +1,17 @@
{ {
"name": "canteen-menu", "name": "canteen-menu",
"version": "0.1.0", "version": "1.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "canteen-menu", "name": "canteen-menu",
"version": "0.1.0", "version": "1.5.0",
"dependencies": { "dependencies": {
"@supabase/ssr": "^0.12.3", "@simplewebauthn/browser": "^13.3.0",
"@supabase/supabase-js": "^2.110.7", "@simplewebauthn/server": "^13.3.2",
"next": "16.2.10", "next": "16.2.10",
"pg": "^8.22.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"server-only": "^0.0.1" "server-only": "^0.0.1"
@@ -18,6 +19,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
@@ -456,6 +458,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1086,6 +1094,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -1309,6 +1323,174 @@
"node": ">=12.4.0" "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": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1316,100 +1498,29 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@supabase/auth-js": { "node_modules/@simplewebauthn/browser": {
"version": "2.110.7", "version": "13.3.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.7.tgz", "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-M5Bpl4hCv6kHcOO/xM06Dyfg1mYLHljMkp1plhzG9IRZPc3czvyMsSN1XpL5+GKisOKM3lSN59zhpcm6sMVXfA==", "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"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==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@supabase/postgrest-js": { "node_modules/@simplewebauthn/server": {
"version": "2.110.7", "version": "13.3.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.7.tgz", "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz",
"integrity": "sha512-ban6YV0djhVaqVYezlOARKLIuOBSvLLhyQVZjA2nxPrtswhxHCl1+gI4giFgI9ATQAaMNbUZb4JXiuL5lEA/5g==", "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "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": { "engines": {
"node": ">=22.0.0" "node": ">=20.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_modules/@swc/helpers": { "node_modules/@swc/helpers": {
@@ -1746,6 +1857,18 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/react": {
"version": "19.2.17", "version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -2648,6 +2771,20 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/ast-types-flow": {
"version": "0.0.8", "version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -2915,19 +3052,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4215,15 +4339,6 @@
"hermes-estree": "0.25.1" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5661,6 +5776,95 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5719,6 +5923,45 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5751,6 +5994,24 @@
"node": ">=6" "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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5800,6 +6061,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6215,6 +6482,15 @@
"node": ">=0.10.0" "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": { "node_modules/stable-hash": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6549,6 +6825,24 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -6898,6 +7192,15 @@
"node": ">=0.10.0" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+10 -4
View File
@@ -1,17 +1,22 @@
{ {
"name": "canteen-menu", "name": "canteen-menu",
"version": "0.1.0", "version": "1.5.0",
"private": true, "private": true,
"scripts": { "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", "dev": "next dev -H 0.0.0.0",
"build": "next build", "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", "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": { "dependencies": {
"@supabase/ssr": "^0.12.3", "@simplewebauthn/browser": "^13.3.0",
"@supabase/supabase-js": "^2.110.7", "@simplewebauthn/server": "^13.3.2",
"next": "16.2.10", "next": "16.2.10",
"pg": "^8.22.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"server-only": "^0.0.1" "server-only": "^0.0.1"
@@ -19,6 +24,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "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 { NextResponse, type NextRequest } from "next/server";
import { Pool } from "pg";
// Session-Refresh + Zugriffsschutz für die internen Bereiche. // 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) { export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request }); const token = request.cookies.get(SESSION_COOKIE)?.value;
const supabase = createServerClient( function redirectToLogin() {
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) {
// Absolute URL aus dem Host-Header des Browsers bauen: request.nextUrl // 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 // trägt bei "-H 0.0.0.0" die Server-Bindung statt des aufgerufenen
// Hosts, und relative Location-Header lehnt die Middleware ab. // 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); 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; return response;
} }

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+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()
);
-437
View File
@@ -1,437 +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: 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: 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
);
--
-- 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,
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
);
--
-- 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,
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: 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: 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_name; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY profiles_update_own_name 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;

Some files were not shown because too many files have changed in this diff Show More