Compare commits

..
10 Commits
Author SHA1 Message Date
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
22 changed files with 742 additions and 149 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
+7
View File
@@ -9,3 +9,10 @@ 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
+21 -9
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
@@ -53,7 +53,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 +66,7 @@ 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 |
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 +82,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 +128,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>
-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>
+35
View File
@@ -0,0 +1,35 @@
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")
.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 ?? ""}
/>
</main>
</div>
);
}
+5 -1
View File
@@ -3,6 +3,7 @@ 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 } from "@/lib/types";
import packageJson from "@/package.json";
// Farben/Logo sollen Änderungen sofort widerspiegeln. // Farben/Logo sollen Änderungen sofort widerspiegeln.
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -13,7 +14,7 @@ const geistSans = Geist({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Speisekarte", title: "Canteen Menu",
description: "Speisekarte der Betriebskantine", description: "Speisekarte der Betriebskantine",
}; };
@@ -41,6 +42,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>
); );
+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" },
});
} }
+262
View File
@@ -0,0 +1,262 @@
"use client";
import { 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,
}: {
role: string;
initialFullName: string;
initialEmail: string;
}) {
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);
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);
}
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>
<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>
);
}
+105 -1
View File
@@ -19,6 +19,15 @@ export default function DishManager() {
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 { data, error } = await supabase
@@ -64,9 +73,58 @@ 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();
}
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">
@@ -137,9 +195,49 @@ 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) => (
<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> <span className="text-gray-500">– {formatPrice(dish.price)}</span>
@@ -171,7 +269,13 @@ export default function DishManager() {
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>
); );
} }
+19 -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,18 @@ 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}
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 +43,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 +59,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 +67,9 @@ 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}
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
+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;
} }
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "speisekarte", "name": "canteen-menu",
"version": "0.1.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "speisekarte", "name": "canteen-menu",
"version": "0.1.0", "version": "1.1.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.1.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*"],
}; };
+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."
+22
View File
@@ -68,6 +68,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: -
-- --
@@ -376,6 +391,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_name; Type: POLICY; Schema: public; Owner: -
--
CREATE POLICY profiles_update_own_name ON public.profiles FOR UPDATE TO authenticated USING ((id = ( SELECT auth.uid() AS uid))) WITH CHECK ((id = ( SELECT auth.uid() AS uid)));
-- --
-- Name: sections; Type: ROW SECURITY; Schema: public; Owner: - -- Name: sections; Type: ROW SECURITY; Schema: public; Owner: -
-- --
@@ -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();