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 ( +
+
+
+ Verwaltung + +
+ + {profile.full_name || user.email} + +
+ +
+
+
+
+
+ {children} +
+
+ ); +} 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 + Aktuelles Logo + ) : ( +

Kein Logo hinterlegt.

+ )} +
+ + {currentLogo && ( + + )} +
+
+ +
+

Farbgebung

+
+ {Object.entries(COLOR_LABELS).map(([key, label]) => ( + + ))} +
+
+ + +
+
+
+ ); +} 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 && ( +
+

+ {form.id ? "Benutzer bearbeiten" : "Neuer Benutzer"} +

+ {!form.id && ( +
+ + setForm({ ...form, email: e.target.value })} + required + /> +
+ )} +
+ + setForm({ ...form, full_name: e.target.value })} + /> +
+
+ + +
+
+ + setForm({ ...form, password: e.target.value })} + minLength={8} + required={!form.id} + autoComplete="new-password" + /> +
+
+ + +
+
+ )} + +
    + {profiles.map((p) => ( +
  • + + {p.full_name || p.email}{" "} + + – {p.email} – {ROLE_LABELS[p.role]} + + {p.id === me && (du)} + + + {p.id !== me && ( + + )} +
  • + ))} +
+
+ ); +}