v1.5: Alle Manager-Komponenten auf eigene API-Routen umgestellt

Neue interne Route Handler unter app/api/ (dishes inkl. Bulk-Delete,
sections, allergens, additives, menu-entries, dish-suggestions,
settings, admin/branding) ersetzen den direkten Browser→Postgrest-
Zugriff der bisherigen Supabase-Anbindung. Jede Route prüft Autorisierung
selbst (lib/auth/session.ts requireRole) statt sich auf Postgres-RLS zu
verlassen – ersetzt 1:1 die bisherigen Policies (öffentlich lesbar /
staff+admin schreiben / nur admin für Einstellungen & Benutzer).

Rezeptvorschlag-Dublettencheck (SuggestionForm) läuft jetzt zwingend
serverseitig im POST-Handler, da es keinen Browser→Postgres-Pfad mehr
gibt. "Passkey annehmen" (SuggestionReview) läuft transaktional
(Gericht anlegen + Vorschlag löschen in einem Schritt).

Logo/Favicon: neuer lokaler Dateispeicher (lib/branding.ts, Docker-
Volume) statt Supabase-Storage-Bucket, ausgeliefert über
app/branding/[filename]. logoUrl()/faviconUrl() liefern jetzt einfache
relative Pfade statt einer fest verdrahteten Supabase-Storage-URL.

DeepL-Route (api/translate) und Benutzerverwaltung (api/admin/users)
laufen jetzt gegen die users-Tabelle statt Supabase Auth.

Alle "use client"-Manager-Komponenten (DishManager, WeekEditor,
SectionManager, CodeListManager, SuggestionForm/Review, UserManager,
DesignEditor) rufen ihre jeweiligen /api/-Routen per fetch() statt den
Supabase-Browser-Client zu nutzen.
This commit is contained in:
2026-07-18 11:01:52 +02:00
parent 28c9ec15c8
commit a9a9cf7ba4
58 changed files with 1184 additions and 1858 deletions
+86 -52
View File
@@ -1,33 +1,48 @@
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { createAdminClient } from "@/lib/supabase/admin";
import { query, queryOne } from "@/lib/db";
import { hashPassword } from "@/lib/auth/password";
import { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
import type { Profile } from "@/lib/types";
// 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.
// Benutzerverwaltung (nur Admin).
async function requireAdmin(request: Request) {
if (!isSameOrigin(request)) return null;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return null;
if (!isSameOrigin(request)) throw new ForbiddenError();
return requireRole(["admin"]);
}
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 };
function errorResponse(err: unknown) {
if (err instanceof UnauthorizedError || err instanceof ForbiddenError) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
return null;
}
export async function GET(request: Request) {
try {
await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const profiles = await query<Profile>(
"select id, email, full_name, role, password_auth_enabled, created_at from users order by created_at"
);
return NextResponse.json(profiles);
}
export async function POST(request: Request) {
const ctx = await requireAdmin(request);
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
let session;
try {
session = await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
void session;
const body = await request.json().catch(() => null);
const { email, password, full_name, role } = body ?? {};
@@ -41,28 +56,33 @@ export async function POST(request: Request) {
);
}
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 }
try {
const user = await queryOne<{ id: string }>(
`insert into users (email, password_hash, full_name, role)
values ($1, $2, $3, $4) returning id`,
[String(email).trim().toLowerCase(), hashPassword(password), full_name ?? "", role]
);
return NextResponse.json({ id: user?.id });
} catch (err) {
if (err instanceof Error && /unique/i.test(err.message)) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse ist bereits registriert." },
{ status: 400 }
);
}
throw err;
}
return NextResponse.json({ id: data.user?.id });
}
export async function PATCH(request: Request) {
const ctx = await requireAdmin(request);
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
let session;
try {
session = await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
const body = await request.json().catch(() => null);
const { id, full_name, role, password } = body ?? {};
@@ -71,7 +91,7 @@ export async function PATCH(request: Request) {
}
// 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") {
if (id === session.user.id && role === "staff") {
return NextResponse.json(
{ error: "Du kannst dir nicht selbst die Admin-Rolle entziehen." },
{ status: 400 }
@@ -85,35 +105,49 @@ export async function PATCH(request: Request) {
{ status: 400 }
);
}
const { error } = await ctx.admin.auth.admin.updateUserById(id, { password });
if (error) return NextResponse.json({ error: error.message }, { status: 400 });
await query("update users set password_hash = $1 where id = $2", [
hashPassword(password),
id,
]);
}
const updates: Record<string, string> = {};
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 });
const fields: string[] = [];
const values: unknown[] = [];
if (typeof full_name === "string") {
values.push(full_name);
fields.push(`full_name = $${values.length}`);
}
if (role) {
values.push(role);
fields.push(`role = $${values.length}`);
}
if (fields.length > 0) {
values.push(id);
await query(`update users set ${fields.join(", ")} where id = $${values.length}`, values);
}
return NextResponse.json({ ok: true });
}
export async function DELETE(request: Request) {
const ctx = await requireAdmin(request);
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
let session;
try {
session = await requireAdmin(request);
} catch (err) {
const res = errorResponse(err);
if (res) return res;
throw err;
}
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) {
if (id === session.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 });
await query("delete from users where id = $1", [id]);
return NextResponse.json({ ok: true });
}