Compare commits

..
18 Commits
Author SHA1 Message Date
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
florian.wilde bfd32d1aa7 Konfigurierbare öffentliche URL (APP_URL) inkl. Passkey-Anbindung
- Neue Umgebungsvariable APP_URL (docker-compose reicht sie durch,
  Default http://localhost:3000): gilt beim CSRF-Origin-Check als
  eigene Origin, falls ein Reverse-Proxy den Host-Header umschreibt
- scripts/configure-passkeys.mjs setzt die Passkey-Domain (WebAuthn
  rp_id/origins) des Supabase-Projekts auf APP_URL – ein Befehl statt
  manueller Dashboard-Konfiguration beim Domain-Wechsel
2026-07-17 23:53:13 +02:00
florian.wilde 32594c2dd8 Fix: Weiterleitungen übernehmen nicht mehr die Server-Bindung 0.0.0.0
Nach dem Abmelden landete man auf 0.0.0.0:3000, weil die absolute
Redirect-URL aus request.url gebaut wurde und dort seit der Bindung an
0.0.0.0 die Server-Adresse statt des aufgerufenen Hosts steht.
Logout leitet jetzt relativ weiter; der Proxy baut die URL aus dem
Host-Header des Browsers (inkl. x-forwarded-proto hinter Proxies).
2026-07-17 23:53:12 +02:00
florian.wilde 5c7112ecd2 Gerichte: Live-Suche
Filtert die Liste bereits während des Tippens (Name, ohne Groß-/
Kleinschreibung). "Alle auswählen" bezieht sich auf die gefilterte
Liste, Treffer-Hinweis bei leerem Ergebnis.
2026-07-17 23:53:12 +02:00
florian.wilde 2a62d07dc4 Navigation: gemeinsamer Kopfbereich statt getrennter Bereiche
Eine Leiste für alles: Gruppe "Speiseplan" (Wochenplan, Gerichte,
Allergene & Zusatzstoffe) und für Admins zusätzlich Gruppe
"Verwaltung" (Benutzer, Design). Aktiver Menüpunkt wird hervorgehoben,
die Konto-Seite nutzt denselben Header – kein Hin- und Herspringen
mehr zwischen Kantinen-Bereich und Verwaltung.
2026-07-17 23:53:11 +02:00
florian.wilde 0dbf89dfa2 Erreichbarkeit auf 0.0.0.0; Projekt zu "canteen-menu" umbenannt
- next dev/start binden jetzt explizit an 0.0.0.0 statt nur localhost
  (Docker war über die Port-Freigabe bereits erreichbar, jetzt auch
  beim nativen npm-Betrieb); docker-compose.yml macht das Binden an
  alle Interfaces zusätzlich explizit
- package.json-Name, Docker-Image/Container/Netzwerk-Namen, lokaler
  DB-Name (canteen_menu), Browser-Tab-Titel und README auf den
  englischen Projektnamen "canteen-menu" umgestellt; deutsche
  In-App-Texte für die Kantinen-Mitarbeiter bleiben unverändert
- Supabase-Projekt (Anzeigename) und native lokale Postgres-DB
  entsprechend umbenannt (Ref/URL/Keys unverändert)
2026-07-17 20:07:53 +02:00
florian.wilde 41a6508b89 Gerichte: Mehrfachauswahl zum Batch-Löschen
Checkbox je Zeile plus "Alle auswählen"; "Ausgewählte löschen (n)"
löscht alle markierten Gerichte in einem einzigen delete().in()-Request.
2026-07-17 20:07:53 +02:00
florian.wilde d59b20d4ba Lokale DB-Kopie: Schema-Dump auf Migration 0003 aktualisieren 2026-07-17 19:16:53 +02:00
florian.wilde 7d08e6fd81 Konto-Seite: eigenen Namen, E-Mail, Passwort und Passkeys selbst verwalten
- Klick auf den Benutzernamen im Header (Kantinen- und Admin-Bereich)
  öffnet /konto
- Name-Änderung per RLS-Policy mit spaltenscharfem Grant (nur
  full_name), Rolle bleibt ausschließlich über die Admin-API änderbar
- E-Mail-Änderung über supabase.auth.updateUser mit Bestätigungslink;
  Trigger synchronisiert profiles.email nach Bestätigung
- Passwortänderung verlangt zur Verifizierung das aktuelle Passwort
  (Re-Login-Check) plus Wiederholung des neuen Passworts
- Passkey-Verwaltung aus /intern/konto hierher verschoben
2026-07-17 19:15:09 +02:00
florian.wilde 902725acae Docker: Compose-Projektnamen fest auf "speisekarte" setzen
Ohne explizite Angabe leitet Compose den Projektnamen aus dem
Ordnernamen ab und wirft dabei das "ß" ersatzlos weg (Images/
Container hießen "speiekarte-*"). name+image+container_name fixieren
das auf "speisekarte-app"/"speisekarte-db"/"speisekarte-dev".
2026-07-17 19:15:09 +02:00
36 changed files with 1551 additions and 283 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"version": "0.0.1", "version": "0.0.1",
"configurations": [ "configurations": [
{ {
"name": "speisekarte-dev", "name": "canteen-menu-dev",
"runtimeExecutable": "C:\\Program Files\\nodejs\\npm.cmd", "runtimeExecutable": "C:\\Program Files\\nodejs\\npm.cmd",
"runtimeArgs": ["run", "dev"], "runtimeArgs": ["run", "dev"],
"port": 3000 "port": 3000
+10
View File
@@ -9,3 +9,13 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
# Service-Role-Key (GEHEIM! Nur serverseitig, für die Benutzerverwaltung) # Service-Role-Key (GEHEIM! Nur serverseitig, für die Benutzerverwaltung)
SUPABASE_SERVICE_ROLE_KEY=<service-role-key> SUPABASE_SERVICE_ROLE_KEY=<service-role-key>
# Öffentliche URL, unter der die App erreichbar ist (Schema + Domain).
# Wird für den CSRF-Origin-Check hinter Reverse-Proxies genutzt und ist
# zugleich die Passkey-Domain: nach einer Änderung einmalig
# SUPABASE_ACCESS_TOKEN=<token> node scripts/configure-passkeys.mjs
# ausführen, damit Passkeys auf der neuen Domain funktionieren.
APP_URL=http://localhost:3000
# Titel im Browser-Tab und Überschrift, solange kein Logo hochgeladen ist.
SITE_TITLE=Canteen Menu
+66
View File
@@ -0,0 +1,66 @@
# 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.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
+31 -13
View File
@@ -1,4 +1,4 @@
# Speisekarte – Betriebskantine # Canteen Menu – Speisekarte für die Betriebskantine
Digitale Speisekarte für eine Firmenkantine (ca. 250 Mitarbeiter) mit 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
@@ -7,16 +7,21 @@ 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) · Supabase
(PostgreSQL, Auth inkl. Passkeys, Storage, Row Level Security) (PostgreSQL, Auth inkl. Passkeys, Storage, Row Level Security)
Ä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 den Bereichen Brotzeit (09:00–10:00), Mittagessen, Mittagessen vegetarisch und Suppe (je 11:30–13:30); Navigation vorherige/nächste Woche/Heute; auf schmalen Bildschirmen umschaltbare Tages-/Wochenansicht (Tag ist Standard); Logo zentriert über der Karte; Allergen-/Zusatzstoff-Kürzel je Gericht, Legende unterhalb der Karte |
| 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`) | Gerichte anlegen/bearbeiten/löschen (inkl. Live-Suche und Mehrfachauswahl zum Batch-Löschen) mit Allergenen/Zusatzstoffen je Gericht; Gerichte Tagen/Bereichen zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen; 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.
## Projektstruktur ## Projektstruktur
@@ -53,7 +58,7 @@ docker compose --env-file .env.local --profile dev up dev
```bash ```bash
npm install npm install
npm run dev # http://localhost:3000 npm run dev # http://localhost:3000 (bindet an 0.0.0.0, auch im LAN erreichbar)
``` ```
## Umgebungsvariablen (Live-Betrieb) ## Umgebungsvariablen (Live-Betrieb)
@@ -66,6 +71,8 @@ Beim Webserver/Hoster müssen genau diese drei Variablen hinterlegt werden
| `NEXT_PUBLIC_SUPABASE_URL` | URL des Supabase-Projekts, z. B. `https://hvzikwjoyhoakedlywhj.supabase.co` | nein | | `NEXT_PUBLIC_SUPABASE_URL` | URL des Supabase-Projekts, z. B. `https://hvzikwjoyhoakedlywhj.supabase.co` | nein |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Öffentlicher anon-Key (RLS schützt die Daten) | nein | | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Öffentlicher anon-Key (RLS schützt die Daten) | nein |
| `SUPABASE_SERVICE_ROLE_KEY` | Service-Role-Key für die Benutzerverwaltung | **ja – niemals im Browser/Repo!** | | `SUPABASE_SERVICE_ROLE_KEY` | Service-Role-Key für die Benutzerverwaltung | **ja – niemals im Browser/Repo!** |
| `APP_URL` | Öffentliche URL der App, z. B. `https://menu.example.de` (Origin-Check hinter Reverse-Proxy; zugleich Passkey-Domain) | nein |
| `SITE_TITLE` | Titel im Browser-Tab und Überschrift ohne Logo, Default `Canteen Menu` | nein |
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):
@@ -81,18 +88,29 @@ Oder klassisch ohne Docker:
```bash ```bash
npm ci npm ci
npm run build npm run build
npm run start # Port 3000 (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 für die Produktions-Domain freischalten
Passkeys sind an die Domain gebunden. Aktuell ist `localhost` konfiguriert. Passkeys sind an die Domain gebunden. Aktuell ist `localhost` konfiguriert.
Vor dem Livegang in der Supabase-Konfiguration (Management API oder Vor dem Livegang: `APP_URL` auf die öffentliche URL setzen und einmalig
Dashboard → Authentication → Settings) anpassen:
- `webauthn_rp_id` = `speisekarte.example.de` (die echte Domain, ohne Protokoll) ```bash
- `webauthn_rp_origins` = `https://speisekarte.example.de` SUPABASE_ACCESS_TOKEN=<personal-access-token> node scripts/configure-passkeys.mjs
- `passkey_enabled` = `true` (bereits aktiv) ```
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
@@ -116,7 +134,7 @@ Sicherheitsmodell (Row Level Security):
### Lokale PostgreSQL-Kopie ### Lokale PostgreSQL-Kopie
Die Datenbank `speisekarte` (Kopie von Schema + Daten aus Supabase, inkl. Die Datenbank `canteen_menu` (Kopie von Schema + Daten aus Supabase, inkl.
Minimal-Stubs für `auth`/`storage`) gibt es in zwei Varianten: Minimal-Stubs für `auth`/`storage`) gibt es in zwei Varianten:
**Per Docker:** Der Dienst `db` aus `docker-compose.yml` spielt die **Per Docker:** Der Dienst `db` aus `docker-compose.yml` spielt die
+5 -30
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import AppHeader from "@/components/AppHeader";
export default async function AdminLayout({ export default async function AdminLayout({
children, children,
@@ -22,35 +22,10 @@ export default async function AdminLayout({
return ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<header className="bg-[var(--c-primary-dark)] text-white"> <AppHeader
<div className="max-w-[1600px] mx-auto w-full px-4 py-3 flex flex-wrap items-center gap-x-5 gap-y-2"> role="admin"
<span className="font-bold">Verwaltung</span> displayName={profile.full_name || user.email || ""}
<nav className="flex flex-wrap gap-x-4 gap-y-1 text-sm"> />
<Link href="/admin" className="hover:underline">
Benutzer
</Link>
<Link href="/admin/design" className="hover:underline">
Design
</Link>
<Link href="/intern" className="hover:underline">
Kantinen-Bereich
</Link>
<Link href="/" className="hover:underline">
Zur Speisekarte
</Link>
</nav>
<div className="ml-auto flex items-center gap-3 text-sm">
<span className="text-white/70">
{profile.full_name || user.email}
</span>
<form action="/logout" method="post">
<button type="submit" className="btn btn-del">
Abmelden
</button>
</form>
</div>
</div>
</header>
<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}
</main> </main>
+28
View File
@@ -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
@@ -1,5 +0,0 @@
import PasskeyManager from "@/components/PasskeyManager";
export default function KontoPage() {
return <PasskeyManager />;
}
+5 -38
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import AppHeader from "@/components/AppHeader";
export default async function InternLayout({ export default async function InternLayout({
children, children,
@@ -22,43 +22,10 @@ export default async function InternLayout({
return ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<header className="bg-[var(--c-primary-dark)] text-white"> <AppHeader
<div className="max-w-[1600px] mx-auto w-full px-4 py-3 flex flex-wrap items-center gap-x-5 gap-y-2"> role={profile.role}
<span className="font-bold">Kantinen-Bereich</span> displayName={profile.full_name || user.email || ""}
<nav className="flex flex-wrap gap-x-4 gap-y-1 text-sm"> />
<Link href="/intern" className="hover:underline">
Wochenplan
</Link>
<Link href="/intern/gerichte" className="hover:underline">
Gerichte
</Link>
<Link href="/intern/kennzeichnung" className="hover:underline">
Allergene &amp; Zusatzstoffe
</Link>
<Link href="/intern/konto" className="hover:underline">
Passkeys
</Link>
{profile.role === "admin" && (
<Link href="/admin" className="hover:underline">
Verwaltung
</Link>
)}
<Link href="/" className="hover:underline">
Zur Speisekarte
</Link>
</nav>
<div className="ml-auto flex items-center gap-3 text-sm">
<span className="text-white/70">
{profile.full_name || user.email}
</span>
<form action="/logout" method="post">
<button type="submit" className="btn btn-del">
Abmelden
</button>
</form>
</div>
</div>
</header>
<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}
</main> </main>
+36
View File
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import AppHeader from "@/components/AppHeader";
import AccountManager from "@/components/AccountManager";
export default async function KontoPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: profile } = await supabase
.from("profiles")
.select("role, full_name, password_auth_enabled")
.eq("id", user.id)
.single();
if (!profile || !["staff", "admin"].includes(profile.role)) redirect("/");
return (
<div className="flex-1 flex flex-col">
<AppHeader
role={profile.role}
displayName={profile.full_name || user.email || ""}
/>
<main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
<AccountManager
role={profile.role}
initialFullName={profile.full_name ?? ""}
initialEmail={user.email ?? ""}
initialPasswordAuthEnabled={profile.password_auth_enabled}
/>
</main>
</div>
);
}
+21 -4
View File
@@ -2,9 +2,10 @@ 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 { createClient } from "@/lib/supabase/server";
import { COLOR_VARS, DEFAULT_COLORS } from "@/lib/types"; import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types";
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,10 +13,23 @@ const geistSans = Geist({
subsets: ["latin"], subsets: ["latin"],
}); });
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: "Speisekarte", const supabase = await createClient();
const { data: settings } = await supabase
.from("settings")
.select("favicon_path")
.eq("id", 1)
.maybeSingle();
return {
title: SITE_TITLE,
description: "Speisekarte der Betriebskantine", 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,
@@ -41,6 +55,9 @@ export default async function RootLayout({
<html lang="de" className={`${geistSans.variable} h-full antialiased`}> <html lang="de" 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} {children}
<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>
); );
+16 -1
View File
@@ -46,7 +46,22 @@ export default function LoginPage() {
setBusy(false); setBusy(false);
return; return;
} }
await redirectByRole(data.user.id);
const { data: profile } = await supabase
.from("profiles")
.select("role, password_auth_enabled")
.eq("id", data.user.id)
.single();
if (profile?.password_auth_enabled === false) {
await supabase.auth.signOut();
setError(
"Die Anmeldung mit Passwort ist für dieses Konto deaktiviert. Bitte mit Passkey anmelden."
);
setBusy(false);
return;
}
router.replace(profile?.role === "admin" ? "/admin" : "/intern");
router.refresh();
} }
async function signInWithPasskey() { async function signInWithPasskey() {
+7 -1
View File
@@ -8,5 +8,11 @@ export async function POST(request: Request) {
} }
const supabase = await createClient(); const supabase = await createClient();
await supabase.auth.signOut(); await supabase.auth.signOut();
return NextResponse.redirect(new URL("/login", request.url), { status: 303 }); // Relative Weiterleitung: bei einer absoluten URL aus request.url landet
// sonst die Server-Bindung (0.0.0.0) statt des aufgerufenen Hosts im
// Location-Header.
return new NextResponse(null, {
status: 303,
headers: { Location: "/login" },
});
} }
+28 -12
View File
@@ -1,15 +1,18 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { import {
addDays, addDays,
dishCodes,
entriesFor,
formatDateLong, formatDateLong,
formatPrice, formatPrice,
mondayOf, mondayOf,
todayISO,
WEEKDAYS, WEEKDAYS,
} from "@/lib/menu"; } from "@/lib/menu";
import { logoUrl } from "@/lib/types"; import { logoUrl, SITE_TITLE } from "@/lib/types";
import type { CodeItem, MenuEntry, Section } from "@/lib/types"; import type { CodeItem, MenuEntry, Section } from "@/lib/types";
import PublicMobileMenu from "@/components/PublicMobileMenu";
export default async function Home({ export default async function Home({
searchParams, searchParams,
@@ -19,6 +22,7 @@ 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 currentWeek = mondayOf(todayISO());
const days = WEEKDAYS.map((weekday, i) => ({ const days = WEEKDAYS.map((weekday, i) => ({
weekday, weekday,
iso: addDays(monday, i), iso: addDays(monday, i),
@@ -30,7 +34,9 @@ export default async function Home({
supabase.from("sections").select("*").order("sort"), supabase.from("sections").select("*").order("sort"),
supabase supabase
.from("menu_entries") .from("menu_entries")
.select("id, menu_date, section_key, position, dish:dishes(id, name, price)") .select(
"id, menu_date, section_key, position, dish:dishes(id, name, price, allergens, additives)"
)
.gte("menu_date", monday) .gte("menu_date", monday)
.lte("menu_date", friday) .lte("menu_date", friday)
.order("position") .order("position")
@@ -46,9 +52,6 @@ export default async function Home({
const additives = (additivesRes.data ?? []) as CodeItem[]; const additives = (additivesRes.data ?? []) as CodeItem[];
const logo = logoUrl(settingsRes.data?.logo_path); 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: Logo zentriert, Login-Link rechts */}
@@ -59,7 +62,7 @@ export default async function Home({
<img src={logo} alt="Logo" className="h-20 object-contain" /> <img src={logo} alt="Logo" className="h-20 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>
@@ -71,17 +74,24 @@ export default async function Home({
</Link> </Link>
</header> </header>
{/* Wochen-Navigation */} {/* Mobil: Tag (Standard) oder Woche umschaltbar */}
<PublicMobileMenu monday={monday} sections={sections} entries={entries} />
{/* Ab "sm": klassische Wochentabelle */}
<div className="hidden sm:block">
<div className="flex gap-3 mb-4"> <div className="flex gap-3 mb-4">
<Link href={`/?woche=${addDays(monday, -7)}`} className="btn btn-add"> <Link href={`/?woche=${addDays(monday, -7)}`} className="btn btn-add">
← Vorherige Woche ← Vorherige Woche
</Link> </Link>
<Link href={`/?woche=${currentWeek}`} className="btn btn-primary">
Heute
</Link>
<Link href={`/?woche=${addDays(monday, 7)}`} className="btn btn-add"> <Link href={`/?woche=${addDays(monday, 7)}`} className="btn btn-add">
Nächste Woche → Nächste Woche →
</Link> </Link>
</div> </div>
{/* Speisekarte */} <div className="overflow-x-auto">
<table className="menu-table"> <table className="menu-table">
<thead> <thead>
<tr> <tr>
@@ -104,21 +114,27 @@ export default async function Home({
</th> </th>
{days.map((d) => ( {days.map((d) => (
<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) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish"> <div key={e.id} className="dish">
<span className="font-bold">{e.dish.name}</span> <span className="font-bold">{e.dish.name}</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "} {" – "}
{formatPrice(e.dish.price)} {formatPrice(e.dish.price)}
</div> </div>
))} );
})}
</td> </td>
))} ))}
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div>
</div>
{/* Allergene & Zusatzstoffe (gelten für die komplette Speisekarte) */} {/* Allergene & Zusatzstoffe: Legende zu den Kürzeln bei den Gerichten */}
<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">Allergene</h2>
+337
View File
@@ -0,0 +1,337 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import PasskeyManager from "@/components/PasskeyManager";
const ROLE_LABELS: Record<string, string> = {
admin: "Administrator",
staff: "Kantinenmitarbeiter",
};
function germanAuthError(message: string): string {
if (/invalid login credentials/i.test(message))
return "Das aktuelle Passwort ist falsch.";
if (/should be at least/i.test(message))
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({
role,
initialFullName,
initialEmail,
initialPasswordAuthEnabled,
}: {
role: string;
initialFullName: string;
initialEmail: string;
initialPasswordAuthEnabled: boolean;
}) {
const supabase = useMemo(() => createClient(), []);
const router = useRouter();
// Persönliche Daten
const [fullName, setFullName] = useState(initialFullName);
const [nameError, setNameError] = useState<string | null>(null);
const [nameInfo, setNameInfo] = useState<string | null>(null);
const [nameBusy, setNameBusy] = useState(false);
// E-Mail-Adresse
const [email, setEmail] = useState(initialEmail);
const [emailError, setEmailError] = useState<string | null>(null);
const [emailInfo, setEmailInfo] = useState<string | null>(null);
const [emailBusy, setEmailBusy] = useState(false);
// Passwort
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newPasswordRepeat, setNewPasswordRepeat] = useState("");
const [pwError, setPwError] = useState<string | null>(null);
const [pwInfo, setPwInfo] = useState<string | null>(null);
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(() => {
supabase.auth.passkey
.list()
.then(({ data }) => setPasskeyCount((data ?? []).length));
}, [supabase]);
async function saveName(e: React.FormEvent) {
e.preventDefault();
setNameError(null);
setNameInfo(null);
const name = fullName.trim();
if (!name) {
setNameError("Bitte einen Namen angeben.");
return;
}
setNameBusy(true);
const { error } = await supabase
.from("profiles")
.update({ full_name: name })
.eq("id", (await supabase.auth.getUser()).data.user?.id ?? "");
if (error) setNameError(`Speichern fehlgeschlagen: ${error.message}`);
else {
setNameInfo("Name gespeichert.");
router.refresh();
}
setNameBusy(false);
}
async function saveEmail(e: React.FormEvent) {
e.preventDefault();
setEmailError(null);
setEmailInfo(null);
const next = email.trim();
if (!next || next === initialEmail) return;
setEmailBusy(true);
const { error } = await supabase.auth.updateUser({ email: next });
if (error) {
setEmailError(`Ändern fehlgeschlagen: ${germanAuthError(error.message)}`);
} else {
setEmailInfo(
"Bestätigungs-E-Mails wurden verschickt. Die neue Adresse wird erst nach Bestätigung des Links aktiv."
);
}
setEmailBusy(false);
}
async function savePassword(e: React.FormEvent) {
e.preventDefault();
setPwError(null);
setPwInfo(null);
if (newPassword.length < 8) {
setPwError("Das neue Passwort muss mindestens 8 Zeichen lang sein.");
return;
}
if (newPassword !== newPasswordRepeat) {
setPwError("Die Passwörter stimmen nicht überein.");
return;
}
setPwBusy(true);
// Zur Verifizierung: aktuelles Passwort gegenprüfen, bevor geändert wird.
const { error: verifyError } = await supabase.auth.signInWithPassword({
email: initialEmail,
password: currentPassword,
});
if (verifyError) {
setPwError(germanAuthError(verifyError.message));
setPwBusy(false);
return;
}
const { error } = await supabase.auth.updateUser({ password: newPassword });
if (error) {
setPwError(`Ändern fehlgeschlagen: ${germanAuthError(error.message)}`);
} else {
setPwInfo("Passwort geändert.");
setCurrentPassword("");
setNewPassword("");
setNewPasswordRepeat("");
}
setPwBusy(false);
}
async function togglePasswordAuth() {
setAuthError(null);
setAuthInfo(null);
const next = !passwordAuthEnabled;
if (!next && (passkeyCount ?? 0) === 0) {
setAuthError(
"Ohne registrierten Passkey kannst du die Passwort-Anmeldung nicht deaktivieren – sonst wärst du ausgesperrt. Registriere zuerst einen Passkey weiter unten."
);
return;
}
if (
!next &&
!window.confirm(
"Anmeldung mit E-Mail und Passwort wirklich deaktivieren? Du kannst dich danach nur noch per Passkey anmelden."
)
)
return;
setAuthBusy(true);
const { error } = await supabase
.from("profiles")
.update({ password_auth_enabled: next })
.eq("id", (await supabase.auth.getUser()).data.user?.id ?? "");
if (error) {
setAuthError(`Speichern fehlgeschlagen: ${error.message}`);
} else {
setPasswordAuthEnabled(next);
setAuthInfo(
next ? "Passwort-Anmeldung aktiviert." : "Passwort-Anmeldung deaktiviert."
);
}
setAuthBusy(false);
}
return (
<div className="max-w-3xl space-y-6">
<div>
<h1 className="text-xl font-bold text-[var(--c-primary)]">Mein Konto</h1>
<p className="text-sm text-gray-500">
{initialEmail} – {ROLE_LABELS[role] ?? role}
</p>
</div>
<section className="card">
<h2 className="font-semibold mb-3">Persönliche Daten</h2>
<form onSubmit={saveName} className="space-y-3">
<div className="max-w-80">
<label className="label" htmlFor="account-name">
Name
</label>
<input
id="account-name"
className="input"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
/>
</div>
{nameError && (
<p className="text-sm text-[var(--c-del)]" role="alert">
{nameError}
</p>
)}
{nameInfo && <p className="text-sm text-green-700">{nameInfo}</p>}
<button type="submit" className="btn btn-add" disabled={nameBusy}>
Name speichern
</button>
</form>
</section>
<section className="card">
<h2 className="font-semibold mb-3">E-Mail-Adresse</h2>
<form onSubmit={saveEmail} className="space-y-3">
<div className="max-w-80">
<label className="label" htmlFor="account-email">
E-Mail-Adresse
</label>
<input
id="account-email"
type="email"
className="input"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
{emailError && (
<p className="text-sm text-[var(--c-del)]" role="alert">
{emailError}
</p>
)}
{emailInfo && <p className="text-sm text-green-700">{emailInfo}</p>}
<button
type="submit"
className="btn btn-add"
disabled={emailBusy || email.trim() === initialEmail}
>
E-Mail-Adresse ändern
</button>
</form>
</section>
<section className="card">
<h2 className="font-semibold mb-3">Passwort ändern</h2>
<form onSubmit={savePassword} className="space-y-3">
<div className="max-w-80">
<label className="label" htmlFor="account-current-password">
Aktuelles Passwort (zur Verifizierung)
</label>
<input
id="account-current-password"
type="password"
className="input"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<div className="max-w-80">
<label className="label" htmlFor="account-new-password">
Neues Passwort
</label>
<input
id="account-new-password"
type="password"
className="input"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
</div>
<div className="max-w-80">
<label className="label" htmlFor="account-new-password-repeat">
Neues Passwort wiederholen
</label>
<input
id="account-new-password-repeat"
type="password"
className="input"
value={newPasswordRepeat}
onChange={(e) => setNewPasswordRepeat(e.target.value)}
autoComplete="new-password"
minLength={8}
required
/>
</div>
{pwError && (
<p className="text-sm text-[var(--c-del)]" role="alert">
{pwError}
</p>
)}
{pwInfo && <p className="text-sm text-green-700">{pwInfo}</p>}
<button type="submit" className="btn btn-add" disabled={pwBusy}>
Passwort ändern
</button>
</form>
</section>
<section className="card">
<h2 className="font-semibold mb-1">Anmeldung mit Passwort</h2>
<p className="text-sm text-gray-600 mb-3">
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.
</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}
/>
Anmeldung mit E-Mail und Passwort erlaubt
</label>
</section>
<PasskeyManager />
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
// Gemeinsamer Kopfbereich für alle internen Seiten. Statt zweier
// getrennter Bereiche (Kantine/Verwaltung) gibt es eine Navigation:
// Admins sehen zusätzlich die Verwaltungs-Links, der aktive Punkt
// wird hervorgehoben.
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { Role } from "@/lib/types";
const MENU_LINKS = [
{ 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({
role,
displayName,
}: {
role: Role;
displayName: string;
}) {
const pathname = usePathname();
const linkClass = (href: string) =>
`rounded px-2 py-0.5 transition-colors hover:bg-white/10 ${
pathname === href ? "bg-white/20 font-semibold" : "text-white/90"
}`;
return (
<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">
<span className="font-bold text-base">Kantine</span>
<nav
className="flex flex-wrap items-center gap-x-1 gap-y-1"
aria-label="Speiseplan"
>
<span className="mr-1 text-xs uppercase tracking-wider text-white/50">
Speiseplan
</span>
{MENU_LINKS.map((l) => (
<Link key={l.href} href={l.href} className={linkClass(l.href)}>
{l.label}
</Link>
))}
</nav>
{role === "admin" && (
<nav
className="flex flex-wrap items-center gap-x-1 gap-y-1"
aria-label="Verwaltung"
>
<span className="mr-1 text-xs uppercase tracking-wider text-white/50">
Verwaltung
</span>
{ADMIN_LINKS.map((l) => (
<Link key={l.href} href={l.href} className={linkClass(l.href)}>
{l.label}
</Link>
))}
</nav>
)}
<Link
href="/"
className="rounded px-2 py-0.5 text-white/70 hover:bg-white/10"
>
Speisekarte ansehen
</Link>
<div className="ml-auto flex items-center gap-3">
<Link
href="/konto"
title="Mein Konto"
className={`rounded px-2 py-0.5 hover:bg-white/10 ${
pathname === "/konto"
? "bg-white/20 font-semibold"
: "text-white/80"
}`}
>
{displayName}
</Link>
<form action="/logout" method="post">
<button type="submit" className="btn btn-del">
Abmelden
</button>
</form>
</div>
</div>
</header>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import type { CodeItem } from "@/lib/types";
export default function ChipPicker({
items,
selected,
onToggle,
label,
}: {
items: CodeItem[];
selected: string[];
onToggle: (code: string) => void;
label: string;
}) {
return (
<div>
<label className="label">{label}</label>
{items.length === 0 ? (
<p className="text-sm text-gray-400">Keine Einträge vorhanden.</p>
) : (
<div className="flex flex-wrap gap-1.5">
{items.map((item) => {
const active = selected.includes(item.code);
return (
<button
key={item.code}
type="button"
onClick={() => onToggle(item.code)}
className={`chip ${active ? "chip-active" : ""}`}
aria-pressed={active}
title={item.name}
>
<span className="font-semibold">{item.code}</span> {item.name}
</button>
);
})}
</div>
)}
</div>
);
}
+80 -21
View File
@@ -3,13 +3,21 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { COLOR_LABELS, DEFAULT_COLORS, logoUrl } from "@/lib/types"; import { COLOR_LABELS, DEFAULT_COLORS, faviconUrl, logoUrl } from "@/lib/types";
type AssetKind = "logo" | "favicon";
const ASSET_COLUMN: Record<AssetKind, "logo_path" | "favicon_path"> = {
logo: "logo_path",
favicon: "favicon_path",
};
export default function DesignEditor() { export default function DesignEditor() {
const supabase = useMemo(() => createClient(), []); const supabase = useMemo(() => createClient(), []);
const router = useRouter(); const router = useRouter();
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);
@@ -17,7 +25,7 @@ export default function DesignEditor() {
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, error } = await supabase const { data, error } = await supabase
.from("settings") .from("settings")
.select("colors, logo_path") .select("colors, logo_path, favicon_path")
.eq("id", 1) .eq("id", 1)
.maybeSingle(); .maybeSingle();
if (error) { if (error) {
@@ -26,6 +34,7 @@ export default function DesignEditor() {
} }
setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) }); setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) });
setLogoPath(data?.logo_path ?? null); setLogoPath(data?.logo_path ?? null);
setFaviconPath(data?.favicon_path ?? null);
}, [supabase]); }, [supabase]);
useEffect(() => { useEffect(() => {
@@ -52,20 +61,21 @@ 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("Bitte eine Bilddatei auswählen (PNG, JPG, SVG, WebP, ICO).");
return; return;
} }
setBusy(true); setBusy(true);
setError(null); setError(null);
setInfo(null); setInfo(null);
const oldPath = kind === "logo" ? logoPath : faviconPath;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "png"; const ext = file.name.split(".").pop()?.toLowerCase() ?? "png";
const path = `logo-${Date.now()}.${ext}`; const path = `${kind}-${Date.now()}.${ext}`;
const { error: uploadError } = await supabase.storage const { error: uploadError } = await supabase.storage
.from("branding") .from("branding")
.upload(path, file, { contentType: file.type }); .upload(path, file, { contentType: file.type });
@@ -75,46 +85,53 @@ export default function DesignEditor() {
return; return;
} }
const oldPath = logoPath;
const { error: settingsError } = await supabase const { error: settingsError } = await supabase
.from("settings") .from("settings")
.update({ logo_path: path }) .update({ [ASSET_COLUMN[kind]]: path })
.eq("id", 1); .eq("id", 1);
if (settingsError) { if (settingsError) {
setError(`Speichern fehlgeschlagen: ${settingsError.message}`); setError(`Speichern fehlgeschlagen: ${settingsError.message}`);
setBusy(false); setBusy(false);
return; return;
} }
if (oldPath) { if (oldPath) await supabase.storage.from("branding").remove([oldPath]);
await supabase.storage.from("branding").remove([oldPath]);
} if (kind === "logo") setLogoPath(path);
setLogoPath(path); else setFaviconPath(path);
setInfo("Logo aktualisiert."); setInfo(kind === "logo" ? "Logo aktualisiert." : "Favicon 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" ? "Logo" : "Favicon"} wirklich entfernen?`
)
)
return;
setBusy(true); setBusy(true);
setError(null); setError(null);
const { error } = await supabase const { error } = await supabase
.from("settings") .from("settings")
.update({ logo_path: null }) .update({ [ASSET_COLUMN[kind]]: null })
.eq("id", 1); .eq("id", 1);
if (error) { if (error) {
setError(`Speichern fehlgeschlagen: ${error.message}`); setError(`Speichern fehlgeschlagen: ${error.message}`);
} else { } else {
await supabase.storage.from("branding").remove([logoPath]); await supabase.storage.from("branding").remove([path]);
setLogoPath(null); if (kind === "logo") setLogoPath(null);
setInfo("Logo entfernt."); else setFaviconPath(null);
setInfo(kind === "logo" ? "Logo entfernt." : "Favicon entfernt.");
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">
@@ -149,7 +166,7 @@ export default function DesignEditor() {
type="file" type="file"
accept="image/*" accept="image/*"
className="hidden" className="hidden"
onChange={uploadLogo} onChange={(e) => uploadAsset("logo", e)}
disabled={busy} disabled={busy}
/> />
</label> </label>
@@ -157,7 +174,7 @@ 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 Logo entfernen
@@ -166,6 +183,48 @@ export default function DesignEditor() {
</div> </div>
</section> </section>
<section className="card">
<h2 className="font-semibold mb-3">Favicon</h2>
<p className="text-sm text-gray-600 mb-3">
Wird als Icon im Browser-Tab angezeigt. Ohne eigenes Favicon gilt
das Standard-Icon der Anwendung.
</p>
{currentFavicon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={currentFavicon}
alt="Aktuelles 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">
Kein eigenes Favicon hinterlegt.
</p>
)}
<div className="flex gap-2 items-center">
<label className="btn btn-primary cursor-pointer">
Favicon hochladen
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => uploadAsset("favicon", e)}
disabled={busy}
/>
</label>
{currentFavicon && (
<button
type="button"
className="btn btn-del"
onClick={() => removeAsset("favicon")}
disabled={busy}
>
Favicon entfernen
</button>
)}
</div>
</section>
<section className="card"> <section className="card">
<h2 className="font-semibold mb-3">Farbgebung</h2> <h2 className="font-semibold mb-3">Farbgebung</h2>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
+181 -15
View File
@@ -2,31 +2,57 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { formatPrice } from "@/lib/menu"; import { dishCodes, formatPrice } from "@/lib/menu";
import type { Dish } from "@/lib/types"; import type { CodeItem, Dish } from "@/lib/types";
import ChipPicker from "@/components/ChipPicker";
interface DishForm { interface DishForm {
id: string | null; // null = neues Gericht id: string | null; // null = neues Gericht
name: string; name: string;
price: string; price: string;
allergens: string[];
additives: string[];
} }
const EMPTY_FORM: DishForm = { id: null, name: "", price: "" }; const EMPTY_FORM: DishForm = {
id: null,
name: "",
price: "",
allergens: [],
additives: [],
};
export default function DishManager() { export default function DishManager() {
const supabase = useMemo(() => createClient(), []); const supabase = useMemo(() => createClient(), []);
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 [bulkBusy, setBulkBusy] = useState(false);
const [search, setSearch] = useState("");
// Live-Suche: filtert die Liste bereits während des Tippens
const visible = useMemo(() => {
const q = search.trim().toLowerCase();
return q ? dishes.filter((d) => d.name.toLowerCase().includes(q)) : dishes;
}, [dishes, search]);
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, error } = await supabase const [dishesRes, allergensRes, additivesRes] = await Promise.all([
.from("dishes") supabase.from("dishes").select("*").order("name"),
.select("*") supabase.from("allergens").select("*").order("sort"),
.order("name"); supabase.from("additives").select("*").order("sort"),
if (error) setError(`Laden fehlgeschlagen: ${error.message}`); ]);
else setDishes(data as Dish[]); const problem = dishesRes.error ?? allergensRes.error ?? additivesRes.error;
if (problem) setError(`Laden fehlgeschlagen: ${problem.message}`);
else {
setDishes(dishesRes.data as Dish[]);
setAllergens(allergensRes.data as CodeItem[]);
setAdditives(additivesRes.data as CodeItem[]);
}
setLoading(false); setLoading(false);
}, [supabase]); }, [supabase]);
@@ -43,7 +69,12 @@ export default function DishManager() {
setError("Bitte einen gültigen Preis angeben, z. B. 3,50"); setError("Bitte einen gültigen Preis angeben, z. B. 3,50");
return; return;
} }
const payload = { name: form.name.trim(), price }; const payload = {
name: form.name.trim(),
price,
allergens: form.allergens,
additives: form.additives,
};
const { error } = form.id const { error } = form.id
? await supabase.from("dishes").update(payload).eq("id", form.id) ? await supabase.from("dishes").update(payload).eq("id", form.id)
: await supabase.from("dishes").insert(payload); : await supabase.from("dishes").insert(payload);
@@ -64,9 +95,78 @@ export default function DishManager() {
return; return;
const { error } = await supabase.from("dishes").delete().eq("id", dish.id); const { error } = await supabase.from("dishes").delete().eq("id", dish.id);
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`); if (error) setError(`Löschen fehlgeschlagen: ${error.message}`);
setSelected((prev) => {
const next = new Set(prev);
next.delete(dish.id);
return next;
});
await load(); await load();
} }
function toggleOne(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
// "Alle auswählen" bezieht sich auf die aktuell gefilterte Liste
function toggleAll() {
setSelected((prev) => {
const next = new Set(prev);
const everyVisibleSelected = visible.every((d) => next.has(d.id));
visible.forEach((d) =>
everyVisibleSelected ? next.delete(d.id) : next.add(d.id)
);
return next;
});
}
async function removeSelected() {
if (selected.size === 0) return;
if (
!window.confirm(
`${selected.size} Gericht(e) wirklich löschen? Sie werden auch aus allen Wochenplänen entfernt.`
)
)
return;
setBulkBusy(true);
setError(null);
const { error } = await supabase
.from("dishes")
.delete()
.in("id", Array.from(selected));
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`);
setSelected(new Set());
setBulkBusy(false);
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],
});
}
const allSelected =
visible.length > 0 && visible.every((d) => selected.has(d.id));
return ( return (
<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">
@@ -89,7 +189,7 @@ 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 ? "Gericht bearbeiten" : "Neues Gericht"}
</h2> </h2>
@@ -119,6 +219,18 @@ export default function DishManager() {
required required
/> />
</div> </div>
<ChipPicker
label="Allergene"
items={allergens}
selected={form.allergens}
onToggle={toggleAllergen}
/>
<ChipPicker
label="Zusatzstoffe"
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 Speichern
@@ -137,12 +249,57 @@ export default function DishManager() {
{loading ? ( {loading ? (
<p className="text-gray-500">Lade Gerichte…</p> <p className="text-gray-500">Lade Gerichte…</p>
) : ( ) : (
<>
{dishes.length > 0 && (
<input
type="search"
className="input mb-3"
placeholder="Gericht suchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Gerichte durchsuchen"
/>
)}
{dishes.length > 0 && (
<div className="flex items-center gap-3 mb-2 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={allSelected}
onChange={toggleAll}
/>
Alle auswählen
</label>
<span className="text-gray-500">
{selected.size} ausgewählt
</span>
<button
type="button"
className="btn btn-del ml-auto"
onClick={removeSelected}
disabled={selected.size === 0 || bulkBusy}
>
Ausgewählte löschen{selected.size > 0 ? ` (${selected.size})` : ""}
</button>
</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">
{dishes.map((dish) => ( {visible.map((dish) => {
const codes = dishCodes(dish);
return (
<li key={dish.id} className="flex items-center gap-3 px-4 py-2"> <li key={dish.id} className="flex items-center gap-3 px-4 py-2">
<input
type="checkbox"
checked={selected.has(dish.id)}
onChange={() => toggleOne(dish.id)}
aria-label={`${dish.name} auswählen`}
/>
<span className="mr-auto"> <span className="mr-auto">
<span className="font-semibold">{dish.name}</span>{" "} <span className="font-semibold">{dish.name}</span>
<span className="text-gray-500">– {formatPrice(dish.price)}</span> {codes && <sup className="dish-codes">{codes}</sup>}{" "}
<span className="text-gray-500">
– {formatPrice(dish.price)}
</span>
</span> </span>
<button <button
type="button" type="button"
@@ -152,6 +309,8 @@ export default function DishManager() {
id: dish.id, id: dish.id,
name: dish.name, name: dish.name,
price: String(dish.price).replace(".", ","), price: String(dish.price).replace(".", ","),
allergens: dish.allergens ?? [],
additives: dish.additives ?? [],
}) })
} }
> >
@@ -165,13 +324,20 @@ export default function DishManager() {
Löschen Löschen
</button> </button>
</li> </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">
Noch keine Gerichte angelegt. Noch keine Gerichte angelegt.
</li> </li>
)} )}
{dishes.length > 0 && visible.length === 0 && (
<li className="px-4 py-3 text-gray-500">
Keine Treffer für „{search}“.
</li>
)}
</ul> </ul>
</>
)} )}
</div> </div>
); );
+10 -12
View File
@@ -57,11 +57,9 @@ export default function PasskeyManager() {
} }
return ( return (
<div className="max-w-2xl"> <section className="card">
<h1 className="text-xl font-bold text-[var(--c-primary)] mb-2"> <h2 className="font-semibold mb-1">Passkeys</h2>
Passkeys <p className="text-sm text-gray-600 mb-3">
</h1>
<p className="text-sm text-gray-600 mb-4">
Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder
Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden. Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden.
Passkeys gelten pro Domain – für den Live-Betrieb muss die Domain in Passkeys gelten pro Domain – für den Live-Betrieb muss die Domain in
@@ -69,24 +67,24 @@ export default function PasskeyManager() {
</p> </p>
{error && ( {error && (
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert"> <p className="mb-2 text-sm text-[var(--c-del)]" role="alert">
{error} {error}
</p> </p>
)} )}
{info && <p className="mb-3 text-sm text-green-700">{info}</p>} {info && <p className="mb-2 text-sm text-green-700">{info}</p>}
<button <button
type="button" type="button"
className="btn btn-add mb-4" className="btn btn-add mb-3"
onClick={register} onClick={register}
disabled={busy} disabled={busy}
> >
Neuen Passkey registrieren Neuen Passkey registrieren
</button> </button>
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white"> <ul className="divide-y divide-gray-100">
{passkeys.map((pk) => ( {passkeys.map((pk) => (
<li key={pk.id} className="flex items-center gap-3 px-4 py-2 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.friendly_name || "Passkey"} {pk.friendly_name || "Passkey"}
@@ -106,11 +104,11 @@ export default function PasskeyManager() {
</li> </li>
))} ))}
{passkeys.length === 0 && ( {passkeys.length === 0 && (
<li className="px-4 py-3 text-gray-500 text-sm"> <li className="py-2 text-gray-500 text-sm">
Noch keine Passkeys registriert. Noch keine Passkeys registriert.
</li> </li>
)} )}
</ul> </ul>
</div> </section>
); );
} }
+169
View File
@@ -0,0 +1,169 @@
"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,
WEEKDAYS,
WEEKDAYS_SHORT,
} from "@/lib/menu";
import type { MenuEntry, Section } from "@/lib/types";
export default function PublicMobileMenu({
monday,
sections,
entries,
}: {
monday: string;
sections: Section[];
entries: MenuEntry[];
}) {
const router = useRouter();
const days = WEEKDAYS.map((weekday, i) => ({
weekday,
short: 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)}
</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">{s.label}</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">–</p>
) : (
items.map((e) => {
const codes = dishCodes(e.dish);
return (
<p key={e.id} className="mb-1 last:mb-0">
<span className="font-semibold">{e.dish.name}</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 gap-2 mb-3">
<Link
href={`/?woche=${addDays(monday, -7)}`}
className="btn btn-add flex-1 justify-center"
>
← Woche
</Link>
<button
type="button"
onClick={goToday}
className="btn btn-primary flex-1"
>
Heute
</button>
<Link
href={`/?woche=${addDays(monday, 7)}`}
className="btn btn-add flex-1 justify-center"
>
Woche →
</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"}`}
>
Tag
</button>
<button
type="button"
role="tab"
aria-selected={tab === "woche"}
onClick={() => setTab("woche")}
className={`btn flex-1 ${tab === "woche" ? "btn-primary" : "btn-neutral"}`}
>
Woche
</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>
)}
</div>
);
}
+26
View File
@@ -10,6 +10,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,6 +19,7 @@ const EMPTY_FORM: UserForm = {
full_name: "", full_name: "",
role: "staff", role: "staff",
password: "", password: "",
passwordRepeat: "",
}; };
const ROLE_LABELS: Record<Role, string> = { const ROLE_LABELS: Record<Role, string> = {
@@ -74,6 +76,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("Die Passwörter stimmen nicht überein.");
return;
}
const ok = form.id const ok = form.id
? await callApi("PATCH", { ? await callApi("PATCH", {
id: form.id, id: form.id,
@@ -186,6 +192,25 @@ export default function UserManager() {
autoComplete="new-password" autoComplete="new-password"
/> />
</div> </div>
{(!form.id || form.password) && (
<div className="max-w-60">
<label className="label" htmlFor="user-password-repeat">
Passwort wiederholen
</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 Speichern
@@ -221,6 +246,7 @@ export default function UserManager() {
full_name: p.full_name, full_name: p.full_name,
role: p.role, role: p.role,
password: "", password: "",
passwordRepeat: "",
}) })
} }
> >
+25 -7
View File
@@ -3,7 +3,16 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { addDays, formatDateLong, formatPrice, WEEKDAYS } from "@/lib/menu"; import {
addDays,
dishCodes,
entriesFor,
formatDateLong,
formatPrice,
mondayOf,
todayISO,
WEEKDAYS,
} from "@/lib/menu";
import type { Dish, MenuEntry, Section } from "@/lib/types"; import type { Dish, MenuEntry, Section } from "@/lib/types";
export default function WeekEditor({ monday }: { monday: string }) { export default function WeekEditor({ monday }: { monday: string }) {
@@ -32,7 +41,9 @@ export default function WeekEditor({ monday }: { monday: string }) {
supabase.from("dishes").select("*").order("name"), supabase.from("dishes").select("*").order("name"),
supabase supabase
.from("menu_entries") .from("menu_entries")
.select("id, menu_date, section_key, position, dish:dishes(id, name, price)") .select(
"id, menu_date, section_key, position, dish:dishes(id, name, price, allergens, additives)"
)
.gte("menu_date", monday) .gte("menu_date", monday)
.lte("menu_date", friday) .lte("menu_date", friday)
.order("position") .order("position")
@@ -79,9 +90,6 @@ export default function WeekEditor({ monday }: { monday: string }) {
await load(); await load();
} }
const cell = (iso: string, sectionKey: string) =>
entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey);
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">
@@ -91,6 +99,12 @@ export default function WeekEditor({ monday }: { monday: string }) {
<Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add"> <Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add">
← Vorherige Woche ← Vorherige Woche
</Link> </Link>
<Link
href={`/intern?woche=${mondayOf(todayISO())}`}
className="btn btn-primary"
>
Heute
</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 → Nächste Woche →
</Link> </Link>
@@ -129,9 +143,12 @@ 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) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish"> <div key={e.id} className="dish">
<span className="font-bold">{e.dish.name}</span> <span className="font-bold">{e.dish.name}</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "} {" – "}
{formatPrice(e.dish.price)} {formatPrice(e.dish.price)}
<div className="mt-1"> <div className="mt-1">
@@ -144,7 +161,8 @@ export default function WeekEditor({ monday }: { monday: string }) {
</button> </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 <select
+22 -5
View File
@@ -1,5 +1,5 @@
# ============================================================= # =============================================================
# Speisekarte – Docker-Setup # Canteen Menu – Docker-Setup
# #
# Produktion (App + lokale PostgreSQL-Kopie): # Produktion (App + lokale PostgreSQL-Kopie):
# docker compose --env-file .env.local up -d --build # docker compose --env-file .env.local up -d --build
@@ -9,8 +9,13 @@
# #
# Die --env-file-Angabe speist NEXT_PUBLIC_* in den Build und # Die --env-file-Angabe speist NEXT_PUBLIC_* in den Build und
# SUPABASE_SERVICE_ROLE_KEY in die Laufzeit-Umgebung. # SUPABASE_SERVICE_ROLE_KEY in die Laufzeit-Umgebung.
#
# "name" fixiert den Compose-Projektnamen explizit auf "canteen-menu" –
# ohne diese Angabe leitet Compose ihn aus dem Ordnernamen ab.
# ============================================================= # =============================================================
name: canteen-menu
services: services:
# ---------- Next.js-App (Produktions-Build) ---------- # ---------- Next.js-App (Produktions-Build) ----------
app: app:
@@ -19,12 +24,20 @@ services:
args: args:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
image: canteen-menu-app
container_name: canteen-menu-app
environment: environment:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
# Öffentliche URL der App (Domain hinter dem Reverse-Proxy).
# Wird für den CSRF-Origin-Check genutzt; dieselbe URL ist die
# Passkey-Domain (siehe scripts/configure-passkeys.mjs).
APP_URL: ${APP_URL:-http://localhost:3000}
# Titel im Browser-Tab und Überschrift, solange kein Logo hochgeladen ist.
SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
ports: ports:
- "3000:3000" - "0.0.0.0:3000:3000"
restart: unless-stopped restart: unless-stopped
# ---------- Lokale PostgreSQL-Kopie (Ablage/Auswertungen) ---------- # ---------- Lokale PostgreSQL-Kopie (Ablage/Auswertungen) ----------
@@ -32,9 +45,10 @@ services:
# automatisch in alphabetischer Reihenfolge eingespielt. # automatisch in alphabetischer Reihenfolge eingespielt.
db: db:
image: postgres:17-alpine image: postgres:17-alpine
container_name: canteen-menu-db
environment: environment:
POSTGRES_DB: speisekarte POSTGRES_DB: canteen_menu
POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD:-speisekarte} POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD:-canteen_menu}
ports: ports:
# Host-Port 5433, da 5432 meist schon von einer nativen # Host-Port 5433, da 5432 meist schon von einer nativen
# PostgreSQL-Installation belegt ist. # PostgreSQL-Installation belegt ist.
@@ -47,6 +61,7 @@ services:
# ---------- Dev-Server (nur mit --profile dev) ---------- # ---------- Dev-Server (nur mit --profile dev) ----------
dev: dev:
image: node:24-alpine image: node:24-alpine
container_name: canteen-menu-dev
profiles: ["dev"] profiles: ["dev"]
working_dir: /app working_dir: /app
command: sh -c "npm install && npm run dev" command: sh -c "npm install && npm run dev"
@@ -54,8 +69,10 @@ services:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
APP_URL: ${APP_URL:-http://localhost:3000}
SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
ports: ports:
- "3000:3000" - "0.0.0.0:3000:3000"
volumes: volumes:
- .:/app - .:/app
- dev_node_modules:/app/node_modules - dev_node_modules:/app/node_modules
+28
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,7 +33,19 @@ export function addDays(iso: string, days: number): string {
return toISO(d); return toISO(d);
} }
/** Heutiges Datum als YYYY-MM-DD (UTC). */
export function todayISO(): string {
return toISO(new Date());
}
/** Index (0=Montag…4=Freitag) von `iso` innerhalb seiner Woche, sonst -1 (Wochenende). */
export function weekdayIndex(iso: string): number {
const day = (toDate(iso).getUTCDay() + 6) % 7;
return day <= 4 ? day : -1;
}
export const WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"]; export const WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"];
export const WEEKDAYS_SHORT = ["Mo", "Di", "Mi", "Do", "Fr"];
/** z. B. "13. Juli 2026" */ /** z. B. "13. Juli 2026" */
export function formatDateLong(iso: string): string { export function formatDateLong(iso: string): string {
@@ -47,3 +61,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/Bereichs 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);
}
+6 -1
View File
@@ -10,7 +10,12 @@ export function isSameOrigin(request: Request): boolean {
if (!origin) return true; if (!origin) return true;
const host = request.headers.get("host"); const host = request.headers.get("host");
try { try {
return new URL(origin).host === host; const originUrl = new URL(origin);
if (originUrl.host === host) return true;
// Hinter einem Reverse-Proxy, der den Host-Header umschreibt, gilt
// zusätzlich die öffentlich konfigurierte App-URL als eigene Origin.
const appUrl = process.env.APP_URL;
return !!appUrl && originUrl.origin === new URL(appUrl).origin;
} catch { } catch {
return false; return false;
} }
+10
View File
@@ -5,6 +5,7 @@ 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;
} }
@@ -19,6 +20,8 @@ export interface Dish {
id: string; id: string;
name: string; name: string;
price: number; price: number;
allergens: string[];
additives: string[];
} }
export interface MenuEntry { export interface MenuEntry {
@@ -73,3 +76,10 @@ 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 `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/branding/${path}`;
} }
export function faviconUrl(path: string | null | undefined): string | null {
if (!path) return null;
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/branding/${path}`;
}
export const SITE_TITLE = process.env.SITE_TITLE?.trim() || "Canteen Menu";
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "speisekarte", "name": "canteen-menu",
"version": "0.1.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "speisekarte", "name": "canteen-menu",
"version": "0.1.0", "version": "1.2.0",
"dependencies": { "dependencies": {
"@supabase/ssr": "^0.12.3", "@supabase/ssr": "^0.12.3",
"@supabase/supabase-js": "^2.110.7", "@supabase/supabase-js": "^2.110.7",
+4 -4
View File
@@ -1,11 +1,11 @@
{ {
"name": "speisekarte", "name": "canteen-menu",
"version": "0.1.0", "version": "1.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev -H 0.0.0.0",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start -H 0.0.0.0",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
+9 -5
View File
@@ -31,15 +31,19 @@ export async function proxy(request: NextRequest) {
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
if (!user) { if (!user) {
const url = request.nextUrl.clone(); // Absolute URL aus dem Host-Header des Browsers bauen: request.nextUrl
url.pathname = "/login"; // trägt bei "-H 0.0.0.0" die Server-Bindung statt des aufgerufenen
url.search = ""; // Hosts, und relative Location-Header lehnt die Middleware ab.
return NextResponse.redirect(url); const host = request.headers.get("host") ?? request.nextUrl.host;
const proto =
request.headers.get("x-forwarded-proto") ??
request.nextUrl.protocol.replace(":", "");
return NextResponse.redirect(`${proto}://${host}/login`, 303);
} }
return response; return response;
} }
export const config = { export const config = {
matcher: ["/intern/:path*", "/admin/:path*"], matcher: ["/intern/:path*", "/admin/:path*", "/konto/:path*"],
}; };

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+47
View File
@@ -0,0 +1,47 @@
// 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."
);
+4 -4
View File
@@ -37,11 +37,11 @@ $SchemaFile = Join-Path $LocalDir "10_schema.sql"
# 2. Lokale Datenbank neu aufbauen # 2. Lokale Datenbank neu aufbauen
$env:PGPASSWORD = $env:LOCAL_PG_PASSWORD $env:PGPASSWORD = $env:LOCAL_PG_PASSWORD
& "$PgBin\psql" -h localhost -U postgres -c "drop database if exists speisekarte;" & "$PgBin\psql" -h localhost -U postgres -c "drop database if exists canteen_menu;"
& "$PgBin\psql" -h localhost -U postgres -c "create database speisekarte;" & "$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")) { foreach ($file in @("00_stubs.sql", "10_schema.sql", "15_auth_users.sql", "20_data.sql")) {
& "$PgBin\psql" -h localhost -U postgres -d speisekarte -q -f (Join-Path $LocalDir $file) & "$PgBin\psql" -h localhost -U postgres -d canteen_menu -q -f (Join-Path $LocalDir $file)
} }
Write-Host "Lokale Datenbank 'speisekarte' wurde aktualisiert." Write-Host "Lokale Datenbank 'canteen_menu' wurde aktualisiert."
+29 -4
View File
@@ -2,6 +2,7 @@
-- PostgreSQL database dump -- PostgreSQL database dump
-- --
-- Dumped from database version 17.6 -- Dumped from database version 17.6
-- Dumped by pg_dump version 17.10 -- Dumped by pg_dump version 17.10
@@ -21,9 +22,6 @@ SET row_security = off;
-- Name: public; Type: SCHEMA; Schema: -; Owner: - -- 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; CREATE SCHEMA IF NOT EXISTS public;
@@ -68,6 +66,21 @@ 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: - -- Name: touch_updated_at(); Type: FUNCTION; Schema: public; Owner: -
-- --
@@ -117,7 +130,9 @@ CREATE TABLE public.dishes (
name text NOT NULL, name text NOT NULL,
price numeric(6,2) DEFAULT 0 NOT NULL, price numeric(6,2) DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL updated_at timestamp with time zone DEFAULT now() NOT NULL,
allergens text[] DEFAULT '{}'::text[] NOT NULL,
additives text[] DEFAULT '{}'::text[] NOT NULL
); );
@@ -145,6 +160,7 @@ CREATE TABLE public.profiles (
full_name text DEFAULT ''::text NOT NULL, full_name text DEFAULT ''::text NOT NULL,
role text DEFAULT 'staff'::text NOT NULL, role text DEFAULT 'staff'::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL,
password_auth_enabled boolean DEFAULT true NOT NULL,
CONSTRAINT profiles_role_check CHECK ((role = ANY (ARRAY['admin'::text, 'staff'::text]))) CONSTRAINT profiles_role_check CHECK ((role = ANY (ARRAY['admin'::text, 'staff'::text])))
); );
@@ -170,6 +186,7 @@ CREATE TABLE public.settings (
colors jsonb DEFAULT '{}'::jsonb NOT NULL, colors jsonb DEFAULT '{}'::jsonb NOT NULL,
logo_path text, logo_path text,
updated_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL,
favicon_path text,
CONSTRAINT settings_id_check CHECK ((id = 1)) CONSTRAINT settings_id_check CHECK ((id = 1))
); );
@@ -376,6 +393,13 @@ ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
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))); CREATE POLICY profiles_select_own_or_admin ON public.profiles FOR SELECT TO authenticated USING (((id = ( SELECT auth.uid() AS uid)) OR (public.app_role() = 'admin'::text)));
--
-- Name: profiles profiles_update_own; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY profiles_update_own ON public.profiles FOR UPDATE TO authenticated USING ((id = ( SELECT auth.uid() AS uid))) WITH CHECK ((id = ( SELECT auth.uid() AS uid)));
-- --
-- Name: sections; Type: ROW SECURITY; Schema: public; Owner: - -- Name: sections; Type: ROW SECURITY; Schema: public; Owner: -
-- --
@@ -413,3 +437,4 @@ CREATE POLICY settings_public_read ON public.settings FOR SELECT TO authenticate
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
-- --
+28 -28
View File
@@ -2,7 +2,7 @@
-- PostgreSQL database dump -- PostgreSQL database dump
-- --
\restrict HfcGijNcMcy9jPlPpFachgl8LfchhPK39kzgfhc84Q6jsOJpHN3jqf6T368FXri \restrict WmQEI7C1lM8AheyDeBFMpJzZHr1ZhZm5NphQHzcUeS7IAzg73qb94aso6w4qDad
-- Dumped from database version 17.6 -- Dumped from database version 17.6
-- Dumped by pg_dump version 17.10 -- Dumped by pg_dump version 17.10
@@ -76,25 +76,24 @@ ALTER TABLE public.allergens ENABLE TRIGGER ALL;
ALTER TABLE public.dishes DISABLE TRIGGER ALL; ALTER TABLE public.dishes DISABLE TRIGGER ALL;
COPY public.dishes (id, name, price, created_at, updated_at) FROM stdin; COPY public.dishes (id, name, price, created_at, updated_at, allergens, additives) FROM stdin;
2b136266-a80d-4c76-8520-38001c92a634 1 Stk. Rindskäsewurst mit Brötchen 2.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 bf612ae7-8cdd-4149-8b21-f651631ccc02 Hackfleischbaguette 3.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
bf612ae7-8cdd-4149-8b21-f651631ccc02 Hackfleischbaguette 3.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 81c58cc6-7298-4d4a-9997-71d17df5aedf Knusperhähnchen Wrap 3.70 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
81c58cc6-7298-4d4a-9997-71d17df5aedf Knusperhähnchen Wrap 3.70 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 Leberkäse im Brötchen 2.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 Leberkäse im Brötchen 2.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 14ef6893-93a7-4649-afe3-d6b6bdf5903c Schnitzel Baguette 3.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
a987c96f-25a0-4e5e-b8a8-aab683a01feb Chili con Carne mit Brötchen 3.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 Penne mit Grünen Spargel und Garnelenspieße 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
14ef6893-93a7-4649-afe3-d6b6bdf5903c Schnitzel Baguette 3.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 dbe5ee6e-8b04-4bf7-99bb-f936f6564217 Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 Penne mit Grünen Spargel und Garnelenspieße 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 c0613fe7-e5c6-489e-afe5-6da2533b2bd4 Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
dbe5ee6e-8b04-4bf7-99bb-f936f6564217 Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 c5cfa5b4-e502-4b56-b579-b9fbbf817f25 Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
c0613fe7-e5c6-489e-afe5-6da2533b2bd4 Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 f8ba1844-1f23-4b23-9e03-f59148f8b2ed Spaghetti mit fruchtiger Tomatensauce und Parmesan 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
c5cfa5b4-e502-4b56-b579-b9fbbf817f25 Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a Gebackener Feta mit griechischen Bauernsalat und Baguette 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
f8ba1844-1f23-4b23-9e03-f59148f8b2ed Spaghetti mit fruchtiger Tomatensauce und Parmesan 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 ff40ea65-63bc-4d01-b626-e0c9e5d2acb3 Gemüse-Curry mit Basmatireis 4.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a Gebackener Feta mit griechischen Bauernsalat und Baguette 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 ea26f18b-ddf3-4899-95e7-5dbd0878d90c Kartoffelcremesuppe 1.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
ff40ea65-63bc-4d01-b626-e0c9e5d2acb3 Gemüse-Curry mit Basmatireis 4.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 Gulaschsuppe 2.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
ea26f18b-ddf3-4899-95e7-5dbd0878d90c Kartoffelcremesuppe 1.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 3c8cf0c2-b0a9-43e3-8233-723d5718a157 Tomatensuppe mit Croutons 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 Gulaschsuppe 2.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 2dd85835-5425-4271-9fc0-e5215bbce476 Kürbiscremesuppe 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
3c8cf0c2-b0a9-43e3-8233-723d5718a157 Tomatensuppe mit Croutons 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 06df753a-65ed-4bd0-a325-d32c78ec88fa Linseneintopf mit Würstchen 2.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
2dd85835-5425-4271-9fc0-e5215bbce476 Kürbiscremesuppe 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 a987c96f-25a0-4e5e-b8a8-aab683a01feb Chili con Carne mit Brötchen 3.90 2026-07-17 15:17:02.979184+00 2026-07-17 22:21:35.276672+00 {A} {4}
06df753a-65ed-4bd0-a325-d32c78ec88fa Linseneintopf mit Würstchen 2.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
\. \.
@@ -123,7 +122,6 @@ ALTER TABLE public.sections ENABLE TRIGGER ALL;
ALTER TABLE public.menu_entries DISABLE TRIGGER ALL; ALTER TABLE public.menu_entries DISABLE TRIGGER ALL;
COPY public.menu_entries (id, menu_date, section_key, dish_id, "position", created_at) FROM stdin; COPY public.menu_entries (id, menu_date, section_key, dish_id, "position", created_at) FROM stdin;
5d1ea849-61b3-48ac-b1e9-48b6b2c7d8c1 2026-07-13 brotzeit 2b136266-a80d-4c76-8520-38001c92a634 0 2026-07-17 15:17:02.979184+00
70469091-417b-47ed-aab5-747ff97e4ad4 2026-07-14 brotzeit bf612ae7-8cdd-4149-8b21-f651631ccc02 0 2026-07-17 15:17:02.979184+00 70469091-417b-47ed-aab5-747ff97e4ad4 2026-07-14 brotzeit bf612ae7-8cdd-4149-8b21-f651631ccc02 0 2026-07-17 15:17:02.979184+00
1e1f9b4e-75e7-413f-a747-c2884675d9f7 2026-07-15 brotzeit 81c58cc6-7298-4d4a-9997-71d17df5aedf 0 2026-07-17 15:17:02.979184+00 1e1f9b4e-75e7-413f-a747-c2884675d9f7 2026-07-15 brotzeit 81c58cc6-7298-4d4a-9997-71d17df5aedf 0 2026-07-17 15:17:02.979184+00
7b2089f7-7c01-4d6a-9e68-ebfa594bb713 2026-07-16 brotzeit cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 0 2026-07-17 15:17:02.979184+00 7b2089f7-7c01-4d6a-9e68-ebfa594bb713 2026-07-16 brotzeit cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 0 2026-07-17 15:17:02.979184+00
@@ -140,7 +138,6 @@ e2bfe62e-12c4-4f68-a7e7-ca1ee30a6039 2026-07-14 suppe 6222d5f4-05e3-4af4-a41c-3f
662902e0-bb5e-4268-b977-cef2ac20dc35 2026-07-15 suppe 3c8cf0c2-b0a9-43e3-8233-723d5718a157 0 2026-07-17 15:17:02.979184+00 662902e0-bb5e-4268-b977-cef2ac20dc35 2026-07-15 suppe 3c8cf0c2-b0a9-43e3-8233-723d5718a157 0 2026-07-17 15:17:02.979184+00
6083a3f3-a54e-40c5-be1f-c96e474eb8a0 2026-07-16 suppe 2dd85835-5425-4271-9fc0-e5215bbce476 0 2026-07-17 15:17:02.979184+00 6083a3f3-a54e-40c5-be1f-c96e474eb8a0 2026-07-16 suppe 2dd85835-5425-4271-9fc0-e5215bbce476 0 2026-07-17 15:17:02.979184+00
36279cd1-b0e2-46a8-b46c-c23f9f5dbb1b 2026-07-17 suppe 06df753a-65ed-4bd0-a325-d32c78ec88fa 0 2026-07-17 15:17:02.979184+00 36279cd1-b0e2-46a8-b46c-c23f9f5dbb1b 2026-07-17 suppe 06df753a-65ed-4bd0-a325-d32c78ec88fa 0 2026-07-17 15:17:02.979184+00
ae3f7ced-23b1-42a9-a5d4-2bfab6c333b7 2026-07-22 brotzeit 2b136266-a80d-4c76-8520-38001c92a634 0 2026-07-17 15:17:02.979184+00
bfa36cb8-82d6-466c-b954-bf82c6a67b95 2026-07-23 brotzeit bf612ae7-8cdd-4149-8b21-f651631ccc02 0 2026-07-17 15:17:02.979184+00 bfa36cb8-82d6-466c-b954-bf82c6a67b95 2026-07-23 brotzeit bf612ae7-8cdd-4149-8b21-f651631ccc02 0 2026-07-17 15:17:02.979184+00
b86caf93-34eb-46a3-bbe4-3a67dcf79be0 2026-07-21 brotzeit 81c58cc6-7298-4d4a-9997-71d17df5aedf 0 2026-07-17 15:17:02.979184+00 b86caf93-34eb-46a3-bbe4-3a67dcf79be0 2026-07-21 brotzeit 81c58cc6-7298-4d4a-9997-71d17df5aedf 0 2026-07-17 15:17:02.979184+00
d8a962cd-f1cf-4f87-95fb-f5af79cbd0a5 2026-07-20 brotzeit cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 0 2026-07-17 15:17:02.979184+00 d8a962cd-f1cf-4f87-95fb-f5af79cbd0a5 2026-07-20 brotzeit cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 0 2026-07-17 15:17:02.979184+00
@@ -158,6 +155,7 @@ a6f66639-7a0a-4129-bcea-c6ac9cbccac3 2026-07-20 suppe 6222d5f4-05e3-4af4-a41c-3f
b0dbf487-c704-4ab2-af67-082addc88039 2026-07-23 suppe 3c8cf0c2-b0a9-43e3-8233-723d5718a157 0 2026-07-17 15:17:02.979184+00 b0dbf487-c704-4ab2-af67-082addc88039 2026-07-23 suppe 3c8cf0c2-b0a9-43e3-8233-723d5718a157 0 2026-07-17 15:17:02.979184+00
d7b5d414-85c2-495a-9945-2ffc10de32ea 2026-07-24 suppe 2dd85835-5425-4271-9fc0-e5215bbce476 0 2026-07-17 15:17:02.979184+00 d7b5d414-85c2-495a-9945-2ffc10de32ea 2026-07-24 suppe 2dd85835-5425-4271-9fc0-e5215bbce476 0 2026-07-17 15:17:02.979184+00
7a025e66-44b7-43e7-a51b-5564ab8150b1 2026-07-22 suppe 06df753a-65ed-4bd0-a325-d32c78ec88fa 0 2026-07-17 15:17:02.979184+00 7a025e66-44b7-43e7-a51b-5564ab8150b1 2026-07-22 suppe 06df753a-65ed-4bd0-a325-d32c78ec88fa 0 2026-07-17 15:17:02.979184+00
2eb81b52-3c50-4449-bef9-bb12aac9991e 2026-07-13 brotzeit 6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 0 2026-07-17 15:43:22.829239+00
\. \.
@@ -169,9 +167,11 @@ ALTER TABLE public.menu_entries ENABLE TRIGGER ALL;
ALTER TABLE public.profiles DISABLE TRIGGER ALL; ALTER TABLE public.profiles DISABLE TRIGGER ALL;
COPY public.profiles (id, email, full_name, role, created_at) FROM stdin; COPY public.profiles (id, email, full_name, role, created_at, password_auth_enabled) FROM stdin;
cee4160a-ab3a-4109-b706-b9f27d515596 fw@wildeflorian.de Florian Wilde admin 2026-07-17 15:17:33.46696+00 cee4160a-ab3a-4109-b706-b9f27d515596 fw@wildeflorian.de Florian Wilde admin 2026-07-17 15:17:33.46696+00 t
102d405c-b010-41f6-9409-244004a3c567 kantine@example.com Kantinen-Team staff 2026-07-17 15:17:34.233848+00 6e3ebbd4-a00a-4b08-a799-1a445a3f9eaa test@test.de Hans admin 2026-07-17 16:56:13.331443+00 t
102d405c-b010-41f6-9409-244004a3c567 kantine@example.com Kantinen-Team staff 2026-07-17 15:17:34.233848+00 t
952b21ad-65a0-4a12-aed1-6c86709e0eb2 kantine@kantine.de Kantine staff 2026-07-17 22:02:36.854844+00 t
\. \.
@@ -183,8 +183,8 @@ ALTER TABLE public.profiles ENABLE TRIGGER ALL;
ALTER TABLE public.settings DISABLE TRIGGER ALL; ALTER TABLE public.settings DISABLE TRIGGER ALL;
COPY public.settings (id, colors, logo_path, updated_at) FROM stdin; COPY public.settings (id, colors, logo_path, updated_at, favicon_path) FROM stdin;
1 {"rowBg": "#e3e4f1", "accent": "#ff6900", "addBtn": "#6da54c", "primary": "#003388", "rowBgAlt": "#f4f4fa", "deleteBtn": "#e2371d", "headerText": "#ffffff", "primaryDark": "#0d315c"} \N 2026-07-17 15:39:01.866456+00 1 {"rowBg": "#e3e4f1", "accent": "#ff6900", "addBtn": "#6da54c", "primary": "#003388", "rowBgAlt": "#f4f4fa", "deleteBtn": "#e2371d", "headerText": "#ffffff", "primaryDark": "#0d315c"} logo-1784305413466.webp 2026-07-17 22:40:52.997491+00 \N
\. \.
@@ -194,5 +194,5 @@ ALTER TABLE public.settings ENABLE TRIGGER ALL;
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
-- --
\unrestrict HfcGijNcMcy9jPlPpFachgl8LfchhPK39kzgfhc84Q6jsOJpHN3jqf6T368FXri \unrestrict WmQEI7C1lM8AheyDeBFMpJzZHr1ZhZm5NphQHzcUeS7IAzg73qb94aso6w4qDad
@@ -0,0 +1,36 @@
-- =============================================================
-- Selbstverwaltung des eigenen Kontos (Name, E-Mail-Abgleich)
-- =============================================================
-- Jeder Benutzer darf seinen eigenen Anzeigenamen ändern. Die Spalte
-- "role" bleibt geschützt: Rechte werden nicht per RLS eingeschränkt,
-- sondern per Spalten-Grant – selbst ein manuell gebauter Request mit
-- {"role": "admin"} im Payload scheitert an fehlenden Rechten auf die
-- Spalte. Rollenänderungen laufen weiterhin ausschließlich über die
-- Admin-API mit dem Service-Role-Key.
revoke update on public.profiles from authenticated;
grant update (full_name) on public.profiles to authenticated;
create policy "profiles_update_own_name"
on public.profiles for update
to authenticated
using (id = (select auth.uid()))
with check (id = (select auth.uid()));
-- Hält profiles.email synchron, sobald eine E-Mail-Änderung in
-- auth.users bestätigt wurde (supabase.auth.updateUser({ email })).
create or replace function public.handle_user_email_change()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
update public.profiles set email = new.email where id = new.id;
return new;
end;
$$;
create trigger on_auth_user_email_updated
after update of email on auth.users
for each row execute function public.handle_user_email_change();
@@ -0,0 +1,32 @@
-- =============================================================
-- Allergene/Zusatzstoffe pro Gericht, Passwort-Login abschaltbar,
-- austauschbares Favicon
-- =============================================================
-- ---------- Allergene/Zusatzstoffe je Gericht ----------
-- Referenziert Kürzel aus den globalen Listen (allergens/additives),
-- die weiterhin unterhalb der Speisekarte als Legende dienen.
alter table public.dishes
add column allergens text[] not null default '{}',
add column additives text[] not null default '{}';
-- ---------- Passwort-Login pro Benutzer abschaltbar ----------
alter table public.profiles
add column password_auth_enabled boolean not null default true;
-- Selbstverwaltung: Nutzer dürfen zusätzlich zum Namen (Migration 0003)
-- jetzt auch dieses eine Flag für ihr eigenes Konto setzen. Weiterhin
-- kein Zugriff auf "role" oder "email" über diesen Weg.
grant update (password_auth_enabled) on public.profiles to authenticated;
-- Klarerer Name für die inzwischen zwei selbstverwaltbaren Spalten.
drop policy "profiles_update_own_name" on public.profiles;
create policy "profiles_update_own"
on public.profiles for update
to authenticated
using (id = (select auth.uid()))
with check (id = (select auth.uid()));
-- ---------- Austauschbares Favicon ----------
alter table public.settings
add column favicon_path text;