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
This commit is contained in:
2026-07-17 19:15:09 +02:00
parent 902725acae
commit 7d08e6fd81
8 changed files with 357 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>
); );
} }
+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*"],
}; };
@@ -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();