diff --git a/app/admin/design/page.tsx b/app/admin/design/page.tsx
new file mode 100644
index 0000000..f80019e
--- /dev/null
+++ b/app/admin/design/page.tsx
@@ -0,0 +1,5 @@
+import DesignEditor from "@/components/DesignEditor";
+
+export default function DesignPage() {
+ return ;
+}
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
new file mode 100644
index 0000000..b4ca7c5
--- /dev/null
+++ b/app/admin/layout.tsx
@@ -0,0 +1,59 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { createClient } from "@/lib/supabase/server";
+
+export default async function AdminLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ 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?.role !== "admin") redirect("/intern");
+
+ return (
+
+ );
+}
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
new file mode 100644
index 0000000..3f65034
--- /dev/null
+++ b/app/admin/page.tsx
@@ -0,0 +1,5 @@
+import UserManager from "@/components/UserManager";
+
+export default function AdminPage() {
+ return ;
+}
diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts
new file mode 100644
index 0000000..90e903f
--- /dev/null
+++ b/app/api/admin/users/route.ts
@@ -0,0 +1,117 @@
+import { NextResponse } from "next/server";
+import { createClient } from "@/lib/supabase/server";
+import { createAdminClient } from "@/lib/supabase/admin";
+
+// Benutzerverwaltung (nur Admin). Läuft serverseitig mit dem
+// Service-Role-Key, da das Anlegen/Löschen von Auth-Benutzern
+// nicht über den anon key möglich ist.
+
+async function requireAdmin() {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return null;
+
+ const admin = createAdminClient();
+ const { data: profile } = await admin
+ .from("profiles")
+ .select("role")
+ .eq("id", user.id)
+ .single();
+ if (profile?.role !== "admin") return null;
+ return { user, admin };
+}
+
+export async function POST(request: Request) {
+ const ctx = await requireAdmin();
+ if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
+
+ const body = await request.json().catch(() => null);
+ const { email, password, full_name, role } = body ?? {};
+ if (!email || !password || !["admin", "staff"].includes(role)) {
+ return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
+ }
+ if (String(password).length < 8) {
+ return NextResponse.json(
+ { error: "Das Passwort muss mindestens 8 Zeichen lang sein." },
+ { status: 400 }
+ );
+ }
+
+ const { data, error } = await ctx.admin.auth.admin.createUser({
+ email,
+ password,
+ email_confirm: true,
+ user_metadata: { full_name: full_name ?? "", role },
+ });
+ if (error) {
+ return NextResponse.json(
+ {
+ error: /already been registered/i.test(error.message)
+ ? "Diese E-Mail-Adresse ist bereits registriert."
+ : error.message,
+ },
+ { status: 400 }
+ );
+ }
+ return NextResponse.json({ id: data.user?.id });
+}
+
+export async function PATCH(request: Request) {
+ const ctx = await requireAdmin();
+ if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
+
+ const body = await request.json().catch(() => null);
+ const { id, full_name, role, password } = body ?? {};
+ if (!id || (role && !["admin", "staff"].includes(role))) {
+ return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
+ }
+ // Der Admin darf sich nicht selbst die Admin-Rolle entziehen,
+ // sonst sperrt er sich aus der Verwaltung aus.
+ if (id === ctx.user.id && role === "staff") {
+ return NextResponse.json(
+ { error: "Du kannst dir nicht selbst die Admin-Rolle entziehen." },
+ { status: 400 }
+ );
+ }
+
+ if (password) {
+ if (String(password).length < 8) {
+ return NextResponse.json(
+ { error: "Das Passwort muss mindestens 8 Zeichen lang sein." },
+ { status: 400 }
+ );
+ }
+ const { error } = await ctx.admin.auth.admin.updateUserById(id, { password });
+ if (error) return NextResponse.json({ error: error.message }, { status: 400 });
+ }
+
+ const updates: Record = {};
+ if (typeof full_name === "string") updates.full_name = full_name;
+ if (role) updates.role = role;
+ if (Object.keys(updates).length > 0) {
+ const { error } = await ctx.admin.from("profiles").update(updates).eq("id", id);
+ if (error) return NextResponse.json({ error: error.message }, { status: 400 });
+ }
+ return NextResponse.json({ ok: true });
+}
+
+export async function DELETE(request: Request) {
+ const ctx = await requireAdmin();
+ if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
+
+ const body = await request.json().catch(() => null);
+ const { id } = body ?? {};
+ if (!id) return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
+ if (id === ctx.user.id) {
+ return NextResponse.json(
+ { error: "Du kannst dich nicht selbst löschen." },
+ { status: 400 }
+ );
+ }
+
+ const { error } = await ctx.admin.auth.admin.deleteUser(id);
+ if (error) return NextResponse.json({ error: error.message }, { status: 400 });
+ return NextResponse.json({ ok: true });
+}
diff --git a/components/DesignEditor.tsx b/components/DesignEditor.tsx
new file mode 100644
index 0000000..b72e0f3
--- /dev/null
+++ b/components/DesignEditor.tsx
@@ -0,0 +1,207 @@
+"use client";
+
+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";
+
+export default function DesignEditor() {
+ const supabase = useMemo(() => createClient(), []);
+ const router = useRouter();
+ const [colors, setColors] = useState>(DEFAULT_COLORS);
+ const [logoPath, setLogoPath] = useState(null);
+ const [error, setError] = useState(null);
+ const [info, setInfo] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const load = useCallback(async () => {
+ const { data, error } = await supabase
+ .from("settings")
+ .select("colors, logo_path")
+ .eq("id", 1)
+ .maybeSingle();
+ if (error) {
+ setError(`Laden fehlgeschlagen: ${error.message}`);
+ return;
+ }
+ setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) });
+ setLogoPath(data?.logo_path ?? null);
+ }, [supabase]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ async function saveColors() {
+ setBusy(true);
+ setError(null);
+ setInfo(null);
+ const { error } = await supabase
+ .from("settings")
+ .update({ colors })
+ .eq("id", 1);
+ if (error) setError(`Speichern fehlgeschlagen: ${error.message}`);
+ else {
+ setInfo("Farben gespeichert.");
+ router.refresh();
+ }
+ setBusy(false);
+ }
+
+ async function resetColors() {
+ setColors(DEFAULT_COLORS);
+ }
+
+ async function uploadLogo(e: React.ChangeEvent) {
+ 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).");
+ return;
+ }
+ setBusy(true);
+ setError(null);
+ setInfo(null);
+
+ const ext = file.name.split(".").pop()?.toLowerCase() ?? "png";
+ const path = `logo-${Date.now()}.${ext}`;
+ const { error: uploadError } = await supabase.storage
+ .from("branding")
+ .upload(path, file, { contentType: file.type });
+ if (uploadError) {
+ setError(`Upload fehlgeschlagen: ${uploadError.message}`);
+ setBusy(false);
+ return;
+ }
+
+ const oldPath = logoPath;
+ const { error: settingsError } = await supabase
+ .from("settings")
+ .update({ logo_path: 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.");
+ router.refresh();
+ setBusy(false);
+ }
+
+ async function removeLogo() {
+ if (!logoPath) return;
+ if (!window.confirm("Logo wirklich entfernen?")) return;
+ setBusy(true);
+ setError(null);
+ const { error } = await supabase
+ .from("settings")
+ .update({ logo_path: null })
+ .eq("id", 1);
+ if (error) {
+ setError(`Speichern fehlgeschlagen: ${error.message}`);
+ } else {
+ await supabase.storage.from("branding").remove([logoPath]);
+ setLogoPath(null);
+ setInfo("Logo entfernt.");
+ router.refresh();
+ }
+ setBusy(false);
+ }
+
+ const currentLogo = logoUrl(logoPath);
+
+ return (
+
+
Design
+
+ {error && (
+
+ {error}
+
+ )}
+ {info &&
{info}
}
+
+
+ Logo
+
+ Das Logo wird zentriert über der Speisekarte angezeigt.
+
+ {currentLogo ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+ Kein Logo hinterlegt.
+ )}
+
+
+ {currentLogo && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/components/UserManager.tsx b/components/UserManager.tsx
new file mode 100644
index 0000000..7e31861
--- /dev/null
+++ b/components/UserManager.tsx
@@ -0,0 +1,243 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { createClient } from "@/lib/supabase/client";
+import type { Profile, Role } from "@/lib/types";
+
+interface UserForm {
+ id: string | null; // null = neuer Benutzer
+ email: string;
+ full_name: string;
+ role: Role;
+ password: string; // bei Bearbeitung optional (leer = unverändert)
+}
+
+const EMPTY_FORM: UserForm = {
+ id: null,
+ email: "",
+ full_name: "",
+ role: "staff",
+ password: "",
+};
+
+const ROLE_LABELS: Record = {
+ admin: "Administrator",
+ staff: "Kantinenmitarbeiter",
+};
+
+export default function UserManager() {
+ const supabase = useMemo(() => createClient(), []);
+ const [profiles, setProfiles] = useState([]);
+ const [me, setMe] = useState(null);
+ const [form, setForm] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ const load = useCallback(async () => {
+ const [{ data: profileData, error: profileError }, { data: userData }] =
+ await Promise.all([
+ supabase.from("profiles").select("*").order("created_at"),
+ supabase.auth.getUser(),
+ ]);
+ if (profileError) setError(`Laden fehlgeschlagen: ${profileError.message}`);
+ else setProfiles(profileData as Profile[]);
+ setMe(userData.user?.id ?? null);
+ }, [supabase]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ async function callApi(method: string, body: unknown): Promise {
+ setBusy(true);
+ setError(null);
+ try {
+ const res = await fetch("/api/admin/users", {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => null);
+ setError(data?.error ?? `Fehler (${res.status})`);
+ return false;
+ }
+ return true;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ return false;
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function save(e: React.FormEvent) {
+ e.preventDefault();
+ if (!form) return;
+ const ok = form.id
+ ? await callApi("PATCH", {
+ id: form.id,
+ full_name: form.full_name,
+ role: form.role,
+ password: form.password || undefined,
+ })
+ : await callApi("POST", {
+ email: form.email,
+ full_name: form.full_name,
+ role: form.role,
+ password: form.password,
+ });
+ if (ok) {
+ setForm(null);
+ await load();
+ }
+ }
+
+ async function remove(profile: Profile) {
+ if (
+ !window.confirm(
+ `Benutzer „${profile.email}“ wirklich löschen? Der Zugang wird dauerhaft entfernt.`
+ )
+ )
+ return;
+ if (await callApi("DELETE", { id: profile.id })) await load();
+ }
+
+ return (
+
+
+
+ Benutzer
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {form && (
+
+ )}
+
+
+ {profiles.map((p) => (
+ -
+
+ {p.full_name || p.email}{" "}
+
+ – {p.email} – {ROLE_LABELS[p.role]}
+
+ {p.id === me && (du)}
+
+
+ {p.id !== me && (
+
+ )}
+
+ ))}
+
+
+ );
+}