Compare commits

...
3 Commits
Author SHA1 Message Date
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
10 changed files with 389 additions and 25 deletions
+2 -2
View File
@@ -40,9 +40,9 @@ export default async function AdminLayout({
</Link> </Link>
</nav> </nav>
<div className="ml-auto flex items-center gap-3 text-sm"> <div className="ml-auto flex items-center gap-3 text-sm">
<span className="text-white/70"> <Link href="/konto" className="text-white/70 hover:underline">
{profile.full_name || user.email} {profile.full_name || user.email}
</span> </Link>
<form action="/logout" method="post"> <form action="/logout" method="post">
<button type="submit" className="btn btn-del"> <button type="submit" className="btn btn-del">
Abmelden Abmelden
-5
View File
@@ -1,5 +0,0 @@
import PasskeyManager from "@/components/PasskeyManager";
export default function KontoPage() {
return <PasskeyManager />;
}
+2 -5
View File
@@ -35,9 +35,6 @@ export default async function InternLayout({
<Link href="/intern/kennzeichnung" className="hover:underline"> <Link href="/intern/kennzeichnung" className="hover:underline">
Allergene &amp; Zusatzstoffe Allergene &amp; Zusatzstoffe
</Link> </Link>
<Link href="/intern/konto" className="hover:underline">
Passkeys
</Link>
{profile.role === "admin" && ( {profile.role === "admin" && (
<Link href="/admin" className="hover:underline"> <Link href="/admin" className="hover:underline">
Verwaltung Verwaltung
@@ -48,9 +45,9 @@ export default async function InternLayout({
</Link> </Link>
</nav> </nav>
<div className="ml-auto flex items-center gap-3 text-sm"> <div className="ml-auto flex items-center gap-3 text-sm">
<span className="text-white/70"> <Link href="/konto" className="text-white/70 hover:underline">
{profile.full_name || user.email} {profile.full_name || user.email}
</span> </Link>
<form action="/logout" method="post"> <form action="/logout" method="post">
<button type="submit" className="btn btn-del"> <button type="submit" className="btn btn-del">
Abmelden Abmelden
+44
View File
@@ -0,0 +1,44 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { createClient } from "@/lib/supabase/server";
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">
<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">Mein Konto</span>
<nav className="ml-auto flex flex-wrap gap-x-4 gap-y-1 text-sm">
<Link
href={profile.role === "admin" ? "/admin" : "/intern"}
className="hover:underline"
>
← Zurück
</Link>
</nav>
</div>
</header>
<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>
);
}
+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>
);
}
+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>
); );
} }
+10
View File
@@ -9,8 +9,14 @@
# #
# 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 "speisekarte" –
# ohne diese Angabe leitet Compose ihn aus dem Ordnernamen ab und wirft
# dabei nicht-alphanumerische Zeichen (das "ß" in "Speißekarte") weg.
# ============================================================= # =============================================================
name: speisekarte
services: services:
# ---------- Next.js-App (Produktions-Build) ---------- # ---------- Next.js-App (Produktions-Build) ----------
app: app:
@@ -19,6 +25,8 @@ 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: speisekarte-app
container_name: speisekarte-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}
@@ -32,6 +40,7 @@ services:
# automatisch in alphabetischer Reihenfolge eingespielt. # automatisch in alphabetischer Reihenfolge eingespielt.
db: db:
image: postgres:17-alpine image: postgres:17-alpine
container_name: speisekarte-db
environment: environment:
POSTGRES_DB: speisekarte POSTGRES_DB: speisekarte
POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD:-speisekarte} POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD:-speisekarte}
@@ -47,6 +56,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: speisekarte-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"
+1 -1
View File
@@ -41,5 +41,5 @@ export async function proxy(request: NextRequest) {
} }
export const config = { export const config = {
matcher: ["/intern/:path*", "/admin/:path*"], matcher: ["/intern/:path*", "/admin/:path*", "/konto/:path*"],
}; };
+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();