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",
"configurations": [
{
"name": "speisekarte-dev",
"name": "canteen-menu-dev",
"runtimeExecutable": "C:\\Program Files\\nodejs\\npm.cmd",
"runtimeArgs": ["run", "dev"],
"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)
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
ö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
(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
| 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 |
| 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 |
| Admin (`/admin`) | Kantinenmitarbeiter anlegen/bearbeiten/löschen (inkl. Passwort-Reset und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo hochladen/entfernen |
| Ö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 (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 mit Wiederholung und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo und Favicon hochladen/entfernen |
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
@@ -53,7 +58,7 @@ docker compose --env-file .env.local --profile dev up dev
```bash
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)
@@ -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_ANON_KEY` | Öffentlicher anon-Key (RLS schützt die Daten) | nein |
| `SUPABASE_SERVICE_ROLE_KEY` | Service-Role-Key für die Benutzerverwaltung | **ja – niemals im Browser/Repo!** |
| `APP_URL` | Öffentliche URL der App, z. B. `https://menu.example.de` (Origin-Check hinter Reverse-Proxy; zugleich Passkey-Domain) | nein |
| `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
PostgreSQL müssen nicht installiert sein):
@@ -81,18 +88,29 @@ Oder klassisch ohne Docker:
```bash
npm ci
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 sind an die Domain gebunden. Aktuell ist `localhost` konfiguriert.
Vor dem Livegang in der Supabase-Konfiguration (Management API oder
Dashboard → Authentication → Settings) anpassen:
Vor dem Livegang: `APP_URL` auf die öffentliche URL setzen und einmalig
- `webauthn_rp_id` = `speisekarte.example.de` (die echte Domain, ohne Protokoll)
- `webauthn_rp_origins` = `https://speisekarte.example.de`
- `passkey_enabled` = `true` (bereits aktiv)
```bash
SUPABASE_ACCESS_TOKEN=<personal-access-token> node scripts/configure-passkeys.mjs
```
ausführen (liest `APP_URL` aus der Umgebung; die URL kann alternativ als
Argument übergeben werden). Ohne lokales Node.js geht es auch über Docker:
```bash
docker run --rm -v .:/app -w /app \
-e SUPABASE_ACCESS_TOKEN=<token> -e APP_URL=https://menu.example.de \
node:24-alpine node scripts/configure-passkeys.mjs
```
Achtung: Bereits registrierte Passkeys der alten Domain verlieren dabei
ihre Gültigkeit und müssen neu registriert werden.
## Datenbank
@@ -116,7 +134,7 @@ Sicherheitsmodell (Row Level Security):
### 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:
**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 { createClient } from "@/lib/supabase/server";
import AppHeader from "@/components/AppHeader";
export default async function AdminLayout({
children,
@@ -22,35 +22,10 @@ export default async function AdminLayout({
return (
<div className="flex-1 flex flex-col">
<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-5 gap-y-2">
<span className="font-bold">Verwaltung</span>
<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>
<AppHeader
role="admin"
displayName={profile.full_name || user.email || ""}
/>
<main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
{children}
</main>
+28
View File
@@ -108,3 +108,31 @@ body {
color: #4b5563;
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 { createClient } from "@/lib/supabase/server";
import AppHeader from "@/components/AppHeader";
export default async function InternLayout({
children,
@@ -22,43 +22,10 @@ export default async function InternLayout({
return (
<div className="flex-1 flex flex-col">
<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-5 gap-y-2">
<span className="font-bold">Kantinen-Bereich</span>
<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>
<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">
{children}
</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>
);
}
+23 -6
View File
@@ -2,9 +2,10 @@ import type { Metadata } from "next";
import { Geist } from "next/font/google";
import "./globals.css";
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";
const geistSans = Geist({
@@ -12,10 +13,23 @@ const geistSans = Geist({
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Speisekarte",
description: "Speisekarte der Betriebskantine",
};
export async function generateMetadata(): Promise<Metadata> {
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",
// 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({
children,
@@ -41,6 +55,9 @@ export default async function RootLayout({
<html lang="de" className={`${geistSans.variable} h-full antialiased`}>
<body style={style} className="min-h-full flex flex-col">
{children}
<footer className="mt-auto px-4 py-2 text-right text-xs text-gray-400 select-none">
Version {packageJson.version}
</footer>
</body>
</html>
);
+16 -1
View File
@@ -46,7 +46,22 @@ export default function LoginPage() {
setBusy(false);
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() {
+7 -1
View File
@@ -8,5 +8,11 @@ export async function POST(request: Request) {
}
const supabase = await createClient();
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" },
});
}
+69 -53
View File
@@ -1,15 +1,18 @@
import Link from "next/link";
import Image from "next/image";
import { createClient } from "@/lib/supabase/server";
import {
addDays,
dishCodes,
entriesFor,
formatDateLong,
formatPrice,
mondayOf,
todayISO,
WEEKDAYS,
} from "@/lib/menu";
import { logoUrl } from "@/lib/types";
import { logoUrl, SITE_TITLE } from "@/lib/types";
import type { CodeItem, MenuEntry, Section } from "@/lib/types";
import PublicMobileMenu from "@/components/PublicMobileMenu";
export default async function Home({
searchParams,
@@ -19,6 +22,7 @@ export default async function Home({
const { woche } = await searchParams;
const monday = mondayOf(woche);
const friday = addDays(monday, 4);
const currentWeek = mondayOf(todayISO());
const days = WEEKDAYS.map((weekday, i) => ({
weekday,
iso: addDays(monday, i),
@@ -30,7 +34,9 @@ export default async function Home({
supabase.from("sections").select("*").order("sort"),
supabase
.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)
.lte("menu_date", friday)
.order("position")
@@ -46,9 +52,6 @@ export default async function Home({
const additives = (additivesRes.data ?? []) as CodeItem[];
const logo = logoUrl(settingsRes.data?.logo_path);
const cell = (iso: string, sectionKey: string) =>
entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey);
return (
<div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full">
{/* 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" />
) : (
<h1 className="text-4xl font-extrabold tracking-tight text-[var(--c-primary)]">
Speisekarte
{SITE_TITLE}
</h1>
)}
</div>
@@ -71,54 +74,67 @@ export default async function Home({
</Link>
</header>
{/* Wochen-Navigation */}
<div className="flex gap-3 mb-4">
<Link href={`/?woche=${addDays(monday, -7)}`} className="btn btn-add">
← Vorherige Woche
</Link>
<Link href={`/?woche=${addDays(monday, 7)}`} className="btn btn-add">
Nächste Woche →
</Link>
{/* 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">
<Link href={`/?woche=${addDays(monday, -7)}`} className="btn btn-add">
← Vorherige Woche
</Link>
<Link href={`/?woche=${currentWeek}`} className="btn btn-primary">
Heute
</Link>
<Link href={`/?woche=${addDays(monday, 7)}`} className="btn btn-add">
Nächste Woche →
</Link>
</div>
<div className="overflow-x-auto">
<table className="menu-table">
<thead>
<tr>
<th className="section-head w-56" aria-hidden />
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
<tr key={s.key}>
<th className="section-head">
<div>{s.label}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
<td key={d.iso} className="cell">
{entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">{e.dish.name}</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "}
{formatPrice(e.dish.price)}
</div>
);
})}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Speisekarte */}
<table className="menu-table">
<thead>
<tr>
<th className="section-head w-56" aria-hidden />
{days.map((d) => (
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
<tr key={s.key}>
<th className="section-head">
<div>{s.label}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
<td key={d.iso} className="cell">
{cell(d.iso, s.key).map((e) => (
<div key={e.id} className="dish">
<span className="font-bold">{e.dish.name}</span>
{" – "}
{formatPrice(e.dish.price)}
</div>
))}
</td>
))}
</tr>
))}
</tbody>
</table>
{/* Allergene & Zusatzstoffe (gelten für die komplette Speisekarte) */}
{/* 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">
<div>
<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 { useRouter } from "next/navigation";
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() {
const supabase = useMemo(() => createClient(), []);
const router = useRouter();
const [colors, setColors] = useState<Record<string, string>>(DEFAULT_COLORS);
const [logoPath, setLogoPath] = useState<string | null>(null);
const [faviconPath, setFaviconPath] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -17,7 +25,7 @@ export default function DesignEditor() {
const load = useCallback(async () => {
const { data, error } = await supabase
.from("settings")
.select("colors, logo_path")
.select("colors, logo_path, favicon_path")
.eq("id", 1)
.maybeSingle();
if (error) {
@@ -26,6 +34,7 @@ export default function DesignEditor() {
}
setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) });
setLogoPath(data?.logo_path ?? null);
setFaviconPath(data?.favicon_path ?? null);
}, [supabase]);
useEffect(() => {
@@ -52,20 +61,21 @@ export default function DesignEditor() {
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];
e.target.value = "";
if (!file) return;
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;
}
setBusy(true);
setError(null);
setInfo(null);
const oldPath = kind === "logo" ? logoPath : faviconPath;
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
.from("branding")
.upload(path, file, { contentType: file.type });
@@ -75,46 +85,53 @@ export default function DesignEditor() {
return;
}
const oldPath = logoPath;
const { error: settingsError } = await supabase
.from("settings")
.update({ logo_path: path })
.update({ [ASSET_COLUMN[kind]]: path })
.eq("id", 1);
if (settingsError) {
setError(`Speichern fehlgeschlagen: ${settingsError.message}`);
setBusy(false);
return;
}
if (oldPath) {
await supabase.storage.from("branding").remove([oldPath]);
}
setLogoPath(path);
setInfo("Logo aktualisiert.");
if (oldPath) await supabase.storage.from("branding").remove([oldPath]);
if (kind === "logo") setLogoPath(path);
else setFaviconPath(path);
setInfo(kind === "logo" ? "Logo aktualisiert." : "Favicon aktualisiert.");
router.refresh();
setBusy(false);
}
async function removeLogo() {
if (!logoPath) return;
if (!window.confirm("Logo wirklich entfernen?")) return;
async function removeAsset(kind: AssetKind) {
const path = kind === "logo" ? logoPath : faviconPath;
if (!path) return;
if (
!window.confirm(
`${kind === "logo" ? "Logo" : "Favicon"} wirklich entfernen?`
)
)
return;
setBusy(true);
setError(null);
const { error } = await supabase
.from("settings")
.update({ logo_path: null })
.update({ [ASSET_COLUMN[kind]]: null })
.eq("id", 1);
if (error) {
setError(`Speichern fehlgeschlagen: ${error.message}`);
} else {
await supabase.storage.from("branding").remove([logoPath]);
setLogoPath(null);
setInfo("Logo entfernt.");
await supabase.storage.from("branding").remove([path]);
if (kind === "logo") setLogoPath(null);
else setFaviconPath(null);
setInfo(kind === "logo" ? "Logo entfernt." : "Favicon entfernt.");
router.refresh();
}
setBusy(false);
}
const currentLogo = logoUrl(logoPath);
const currentFavicon = faviconUrl(faviconPath);
return (
<div className="max-w-3xl space-y-6">
@@ -149,7 +166,7 @@ export default function DesignEditor() {
type="file"
accept="image/*"
className="hidden"
onChange={uploadLogo}
onChange={(e) => uploadAsset("logo", e)}
disabled={busy}
/>
</label>
@@ -157,7 +174,7 @@ export default function DesignEditor() {
<button
type="button"
className="btn btn-del"
onClick={removeLogo}
onClick={() => removeAsset("logo")}
disabled={busy}
>
Logo entfernen
@@ -166,6 +183,48 @@ export default function DesignEditor() {
</div>
</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">
<h2 className="font-semibold mb-3">Farbgebung</h2>
<div className="grid gap-3 sm:grid-cols-2">
+206 -40
View File
@@ -2,31 +2,57 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { formatPrice } from "@/lib/menu";
import type { Dish } from "@/lib/types";
import { dishCodes, formatPrice } from "@/lib/menu";
import type { CodeItem, Dish } from "@/lib/types";
import ChipPicker from "@/components/ChipPicker";
interface DishForm {
id: string | null; // null = neues Gericht
name: 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() {
const supabase = useMemo(() => createClient(), []);
const [dishes, setDishes] = useState<Dish[]>([]);
const [allergens, setAllergens] = useState<CodeItem[]>([]);
const [additives, setAdditives] = useState<CodeItem[]>([]);
const [form, setForm] = useState<DishForm | null>(null);
const [error, setError] = useState<string | null>(null);
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 { data, error } = await supabase
.from("dishes")
.select("*")
.order("name");
if (error) setError(`Laden fehlgeschlagen: ${error.message}`);
else setDishes(data as Dish[]);
const [dishesRes, allergensRes, additivesRes] = await Promise.all([
supabase.from("dishes").select("*").order("name"),
supabase.from("allergens").select("*").order("sort"),
supabase.from("additives").select("*").order("sort"),
]);
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);
}, [supabase]);
@@ -43,7 +69,12 @@ export default function DishManager() {
setError("Bitte einen gültigen Preis angeben, z. B. 3,50");
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
? await supabase.from("dishes").update(payload).eq("id", form.id)
: await supabase.from("dishes").insert(payload);
@@ -64,9 +95,78 @@ export default function DishManager() {
return;
const { error } = await supabase.from("dishes").delete().eq("id", dish.id);
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`);
setSelected((prev) => {
const next = new Set(prev);
next.delete(dish.id);
return next;
});
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 (
<div className="max-w-3xl">
<div className="flex items-center gap-3 mb-4">
@@ -89,7 +189,7 @@ export default function DishManager() {
)}
{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">
{form.id ? "Gericht bearbeiten" : "Neues Gericht"}
</h2>
@@ -119,6 +219,18 @@ export default function DishManager() {
required
/>
</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">
<button type="submit" className="btn btn-add">
Speichern
@@ -137,41 +249,95 @@ export default function DishManager() {
{loading ? (
<p className="text-gray-500">Lade Gerichte…</p>
) : (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{dishes.map((dish) => (
<li key={dish.id} className="flex items-center gap-3 px-4 py-2">
<span className="mr-auto">
<span className="font-semibold">{dish.name}</span>{" "}
<span className="text-gray-500">– {formatPrice(dish.price)}</span>
<>
{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-primary"
onClick={() =>
setForm({
id: dish.id,
name: dish.name,
price: String(dish.price).replace(".", ","),
})
}
className="btn btn-del ml-auto"
onClick={removeSelected}
disabled={selected.size === 0 || bulkBusy}
>
Bearbeiten
Ausgewählte löschen{selected.size > 0 ? ` (${selected.size})` : ""}
</button>
<button
type="button"
className="btn btn-del"
onClick={() => remove(dish)}
>
Löschen
</button>
</li>
))}
{dishes.length === 0 && (
<li className="px-4 py-3 text-gray-500">
Noch keine Gerichte angelegt.
</li>
</div>
)}
</ul>
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
{visible.map((dish) => {
const codes = dishCodes(dish);
return (
<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="font-semibold">{dish.name}</span>
{codes && <sup className="dish-codes">{codes}</sup>}{" "}
<span className="text-gray-500">
– {formatPrice(dish.price)}
</span>
</span>
<button
type="button"
className="btn btn-primary"
onClick={() =>
setForm({
id: dish.id,
name: dish.name,
price: String(dish.price).replace(".", ","),
allergens: dish.allergens ?? [],
additives: dish.additives ?? [],
})
}
>
Bearbeiten
</button>
<button
type="button"
className="btn btn-del"
onClick={() => remove(dish)}
>
Löschen
</button>
</li>
);
})}
{dishes.length === 0 && (
<li className="px-4 py-3 text-gray-500">
Noch keine Gerichte angelegt.
</li>
)}
{dishes.length > 0 && visible.length === 0 && (
<li className="px-4 py-3 text-gray-500">
Keine Treffer für „{search}“.
</li>
)}
</ul>
</>
)}
</div>
);
+10 -12
View File
@@ -57,11 +57,9 @@ export default function PasskeyManager() {
}
return (
<div className="max-w-2xl">
<h1 className="text-xl font-bold text-[var(--c-primary)] mb-2">
Passkeys
</h1>
<p className="text-sm text-gray-600 mb-4">
<section className="card">
<h2 className="font-semibold mb-1">Passkeys</h2>
<p className="text-sm text-gray-600 mb-3">
Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder
Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden.
Passkeys gelten pro Domain – für den Live-Betrieb muss die Domain in
@@ -69,24 +67,24 @@ export default function PasskeyManager() {
</p>
{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}
</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
type="button"
className="btn btn-add mb-4"
className="btn btn-add mb-3"
onClick={register}
disabled={busy}
>
Neuen Passkey registrieren
</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) => (
<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="font-semibold">
{pk.friendly_name || "Passkey"}
@@ -106,11 +104,11 @@ export default function PasskeyManager() {
</li>
))}
{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.
</li>
)}
</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;
role: Role;
password: string; // bei Bearbeitung optional (leer = unverändert)
passwordRepeat: string;
}
const EMPTY_FORM: UserForm = {
@@ -18,6 +19,7 @@ const EMPTY_FORM: UserForm = {
full_name: "",
role: "staff",
password: "",
passwordRepeat: "",
};
const ROLE_LABELS: Record<Role, string> = {
@@ -74,6 +76,10 @@ export default function UserManager() {
async function save(e: React.FormEvent) {
e.preventDefault();
if (!form) return;
if (form.password && form.password !== form.passwordRepeat) {
setError("Die Passwörter stimmen nicht überein.");
return;
}
const ok = form.id
? await callApi("PATCH", {
id: form.id,
@@ -186,6 +192,25 @@ export default function UserManager() {
autoComplete="new-password"
/>
</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">
<button type="submit" className="btn btn-add" disabled={busy}>
Speichern
@@ -221,6 +246,7 @@ export default function UserManager() {
full_name: p.full_name,
role: p.role,
password: "",
passwordRepeat: "",
})
}
>
+25 -7
View File
@@ -3,7 +3,16 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
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";
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("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)
.lte("menu_date", friday)
.order("position")
@@ -79,9 +90,6 @@ export default function WeekEditor({ monday }: { monday: string }) {
await load();
}
const cell = (iso: string, sectionKey: string) =>
entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey);
return (
<div>
<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">
← Vorherige Woche
</Link>
<Link
href={`/intern?woche=${mondayOf(todayISO())}`}
className="btn btn-primary"
>
Heute
</Link>
<Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add">
Nächste Woche →
</Link>
@@ -129,9 +143,12 @@ export default function WeekEditor({ monday }: { monday: string }) {
adding?.iso === d.iso && adding?.section === s.key;
return (
<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">
<span className="font-bold">{e.dish.name}</span>
{codes && <sup className="dish-codes">{codes}</sup>}
{" – "}
{formatPrice(e.dish.price)}
<div className="mt-1">
@@ -144,7 +161,8 @@ export default function WeekEditor({ monday }: { monday: string }) {
</button>
</div>
</div>
))}
);
})}
{isAdding ? (
<div className="mt-2 space-y-1 text-left">
<select
+22 -5
View File
@@ -1,5 +1,5 @@
# =============================================================
# Speisekarte – Docker-Setup
# Canteen Menu – Docker-Setup
#
# Produktion (App + lokale PostgreSQL-Kopie):
# docker compose --env-file .env.local up -d --build
@@ -9,8 +9,13 @@
#
# Die --env-file-Angabe speist NEXT_PUBLIC_* in den Build und
# SUPABASE_SERVICE_ROLE_KEY in die Laufzeit-Umgebung.
#
# "name" fixiert den Compose-Projektnamen explizit auf "canteen-menu" –
# ohne diese Angabe leitet Compose ihn aus dem Ordnernamen ab.
# =============================================================
name: canteen-menu
services:
# ---------- Next.js-App (Produktions-Build) ----------
app:
@@ -19,12 +24,20 @@ services:
args:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
image: canteen-menu-app
container_name: canteen-menu-app
environment:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
# Ö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:
- "3000:3000"
- "0.0.0.0:3000:3000"
restart: unless-stopped
# ---------- Lokale PostgreSQL-Kopie (Ablage/Auswertungen) ----------
@@ -32,9 +45,10 @@ services:
# automatisch in alphabetischer Reihenfolge eingespielt.
db:
image: postgres:17-alpine
container_name: canteen-menu-db
environment:
POSTGRES_DB: speisekarte
POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD:-speisekarte}
POSTGRES_DB: canteen_menu
POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD:-canteen_menu}
ports:
# Host-Port 5433, da 5432 meist schon von einer nativen
# PostgreSQL-Installation belegt ist.
@@ -47,6 +61,7 @@ services:
# ---------- Dev-Server (nur mit --profile dev) ----------
dev:
image: node:24-alpine
container_name: canteen-menu-dev
profiles: ["dev"]
working_dir: /app
command: sh -c "npm install && npm run dev"
@@ -54,8 +69,10 @@ services:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
APP_URL: ${APP_URL:-http://localhost:3000}
SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
ports:
- "3000:3000"
- "0.0.0.0:3000:3000"
volumes:
- .:/app
- dev_node_modules:/app/node_modules
+28
View File
@@ -2,6 +2,8 @@
// Alle Berechnungen laufen über UTC, damit Server und Browser
// unabhängig von der Zeitzone dieselbe Woche ermitteln.
import type { MenuEntry } from "./types";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function toDate(iso: string): Date {
@@ -31,7 +33,19 @@ export function addDays(iso: string, days: number): string {
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_SHORT = ["Mo", "Di", "Mi", "Do", "Fr"];
/** z. B. "13. Juli 2026" */
export function formatDateLong(iso: string): string {
@@ -47,3 +61,17 @@ export function formatDateLong(iso: string): string {
export function formatPrice(price: number | string): string {
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;
const host = request.headers.get("host");
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 {
return false;
}
+10
View File
@@ -5,6 +5,7 @@ export interface Profile {
email: string;
full_name: string;
role: Role;
password_auth_enabled: boolean;
created_at: string;
}
@@ -19,6 +20,8 @@ export interface Dish {
id: string;
name: string;
price: number;
allergens: string[];
additives: string[];
}
export interface MenuEntry {
@@ -73,3 +76,10 @@ export function logoUrl(path: string | null | undefined): string | null {
if (!path) return null;
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/branding/${path}`;
}
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",
"version": "0.1.0",
"name": "canteen-menu",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "speisekarte",
"version": "0.1.0",
"name": "canteen-menu",
"version": "1.2.0",
"dependencies": {
"@supabase/ssr": "^0.12.3",
"@supabase/supabase-js": "^2.110.7",
+4 -4
View File
@@ -1,11 +1,11 @@
{
"name": "speisekarte",
"version": "0.1.0",
"name": "canteen-menu",
"version": "1.2.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -H 0.0.0.0",
"build": "next build",
"start": "next start",
"start": "next start -H 0.0.0.0",
"lint": "eslint"
},
"dependencies": {
+9 -5
View File
@@ -31,15 +31,19 @@ export async function proxy(request: NextRequest) {
} = await supabase.auth.getUser();
if (!user) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.search = "";
return NextResponse.redirect(url);
// Absolute URL aus dem Host-Header des Browsers bauen: request.nextUrl
// trägt bei "-H 0.0.0.0" die Server-Bindung statt des aufgerufenen
// Hosts, und relative Location-Header lehnt die Middleware ab.
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;
}
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
$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 "create database speisekarte;"
& "$PgBin\psql" -h localhost -U postgres -c "drop database if exists canteen_menu;"
& "$PgBin\psql" -h localhost -U postgres -c "create database canteen_menu;"
foreach ($file in @("00_stubs.sql", "10_schema.sql", "15_auth_users.sql", "20_data.sql")) {
& "$PgBin\psql" -h localhost -U postgres -d 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
--
-- Dumped from database version 17.6
-- Dumped by pg_dump version 17.10
@@ -21,9 +22,6 @@ SET row_security = off;
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
--
-- Das "public"-Schema existiert im offiziellen postgres-Image bereits;
-- CREATE SCHEMA würde dort mit ON_ERROR_STOP=1 das komplette Init-Skript
-- abbrechen.
CREATE SCHEMA IF NOT EXISTS public;
@@ -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: -
--
@@ -117,7 +130,9 @@ CREATE TABLE public.dishes (
name text NOT NULL,
price numeric(6,2) DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
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,
role text DEFAULT 'staff'::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
password_auth_enabled boolean DEFAULT true NOT NULL,
CONSTRAINT profiles_role_check CHECK ((role = ANY (ARRAY['admin'::text, 'staff'::text])))
);
@@ -170,6 +186,7 @@ CREATE TABLE public.settings (
colors jsonb DEFAULT '{}'::jsonb NOT NULL,
logo_path text,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
favicon_path text,
CONSTRAINT settings_id_check CHECK ((id = 1))
);
@@ -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)));
--
-- 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: -
--
@@ -413,3 +437,4 @@ CREATE POLICY settings_public_read ON public.settings FOR SELECT TO authenticate
-- PostgreSQL database dump complete
--
+28 -28
View File
@@ -2,7 +2,7 @@
-- PostgreSQL database dump
--
\restrict HfcGijNcMcy9jPlPpFachgl8LfchhPK39kzgfhc84Q6jsOJpHN3jqf6T368FXri
\restrict WmQEI7C1lM8AheyDeBFMpJzZHr1ZhZm5NphQHzcUeS7IAzg73qb94aso6w4qDad
-- Dumped from database version 17.6
-- 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;
COPY public.dishes (id, name, price, created_at, updated_at) 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
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
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
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
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
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
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
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
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
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
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
ea26f18b-ddf3-4899-95e7-5dbd0878d90c Kartoffelcremesuppe 1.50 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
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
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
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
COPY public.dishes (id, name, price, created_at, updated_at, allergens, additives) FROM stdin;
bf612ae7-8cdd-4149-8b21-f651631ccc02 Hackfleischbaguette 3.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00 {} {}
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 {} {}
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 {} {}
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 {} {}
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 {} {}
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 {} {}
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 {} {}
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 {} {}
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 {} {}
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 {} {}
ea26f18b-ddf3-4899-95e7-5dbd0878d90c Kartoffelcremesuppe 1.50 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 {} {}
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 {} {}
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 {} {}
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 {} {}
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}
\.
@@ -123,7 +122,6 @@ ALTER TABLE public.sections ENABLE 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;
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
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
@@ -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
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
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
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
@@ -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
d7b5d414-85c2-495a-9945-2ffc10de32ea 2026-07-24 suppe 2dd85835-5425-4271-9fc0-e5215bbce476 0 2026-07-17 15:17:02.979184+00
7a025e66-44b7-43e7-a51b-5564ab8150b1 2026-07-22 suppe 06df753a-65ed-4bd0-a325-d32c78ec88fa 0 2026-07-17 15:17:02.979184+00
2eb81b52-3c50-4449-bef9-bb12aac9991e 2026-07-13 brotzeit 6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 0 2026-07-17 15:43:22.829239+00
\.
@@ -169,9 +167,11 @@ ALTER TABLE public.menu_entries ENABLE TRIGGER ALL;
ALTER TABLE public.profiles DISABLE TRIGGER ALL;
COPY public.profiles (id, email, full_name, role, created_at) FROM stdin;
cee4160a-ab3a-4109-b706-b9f27d515596 fw@wildeflorian.de Florian Wilde admin 2026-07-17 15:17:33.46696+00
102d405c-b010-41f6-9409-244004a3c567 kantine@example.com Kantinen-Team staff 2026-07-17 15:17:34.233848+00
COPY public.profiles (id, email, full_name, role, created_at, password_auth_enabled) FROM stdin;
cee4160a-ab3a-4109-b706-b9f27d515596 fw@wildeflorian.de Florian Wilde admin 2026-07-17 15:17:33.46696+00 t
6e3ebbd4-a00a-4b08-a799-1a445a3f9eaa test@test.de Hans admin 2026-07-17 16:56:13.331443+00 t
102d405c-b010-41f6-9409-244004a3c567 kantine@example.com Kantinen-Team staff 2026-07-17 15:17:34.233848+00 t
952b21ad-65a0-4a12-aed1-6c86709e0eb2 kantine@kantine.de Kantine staff 2026-07-17 22:02:36.854844+00 t
\.
@@ -183,8 +183,8 @@ ALTER TABLE public.profiles ENABLE TRIGGER ALL;
ALTER TABLE public.settings DISABLE TRIGGER ALL;
COPY public.settings (id, colors, logo_path, updated_at) 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
COPY public.settings (id, colors, logo_path, updated_at, favicon_path) FROM stdin;
1 {"rowBg": "#e3e4f1", "accent": "#ff6900", "addBtn": "#6da54c", "primary": "#003388", "rowBgAlt": "#f4f4fa", "deleteBtn": "#e2371d", "headerText": "#ffffff", "primaryDark": "#0d315c"} logo-1784305413466.webp 2026-07-17 22:40:52.997491+00 \N
\.
@@ -194,5 +194,5 @@ ALTER TABLE public.settings ENABLE TRIGGER ALL;
-- 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;