From 28c9ec15c89c75bed2d5be6e7cd76e3f8b562f4e Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 18 Jul 2026 10:42:21 +0200 Subject: [PATCH 1/7] v1.5: Selbst gehostete Auth (Passwort + Passkeys), Postgres statt Supabase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fundament: db/migrations/0001_init.sql (users/sessions/passkey_credentials/ webauthn_challenges + Inhaltstabellen ohne RLS), db/migrate.mjs (idempotenter Migrationsrunner), lib/db (pg-Pool + query-Wrapper), lib/auth/password.ts (Node-crypto.scrypt, keine neue Abhängigkeit), lib/auth/session.ts (opake DB-Session-Tokens, Sliding-Refresh in proxy.ts). Passkeys: @simplewebauthn/server + /browser ersetzen Supabase's experimentelles WebAuthn-API (dafür gab es bisher kein eigenes Credential-Schema); RP-Konfiguration kommt jetzt direkt aus APP_URL statt über scripts/configure-passkeys.mjs und die Supabase-Management-API. scripts/bootstrap-admin.mjs legt bei leerer users-Tabelle automatisch einen Admin mit zufälligem Passwort an und hängt die Zugangsdaten an ZUGANGSDATEN.md an (idempotent, via predev/prestart-Hooks aufgerufen). Bugfix im Zuge des Umbaus: "mind. 1 Passkey nötig, um Passwort-Login zu deaktivieren" wurde bisher nur clientseitig geprüft – jetzt zusätzlich serverseitig in PATCH /api/account. E-Mail-Änderung im eigenen Konto läuft jetzt direkt nach Passwort-Bestätigung statt über eine Supabase-Bestätigungsmail (kein SMTP-Server vorhanden). Rest der Anwendung (Gerichte/Essenszeiten/Kennzeichnung/Vorschläge/ Benutzerverwaltung/Branding) nutzt noch den alten Supabase-Client – folgt in weiteren Commits. --- app/api/account/email/route.ts | 50 +++ app/api/account/password/route.ts | 41 ++ app/api/account/route.ts | 50 +++ app/api/auth/login/route.ts | 40 ++ app/api/auth/passkeys/[id]/route.ts | 29 ++ app/api/auth/passkeys/login-options/route.ts | 16 + app/api/auth/passkeys/login-verify/route.ts | 75 ++++ .../auth/passkeys/register-options/route.ts | 45 ++ .../auth/passkeys/register-verify/route.ts | 69 +++ app/api/auth/passkeys/route.ts | 25 ++ app/login/page.tsx | 75 ++-- app/logout/route.ts | 5 +- components/AccountManager.tsx | 117 +++-- components/PasskeyManager.tsx | 52 ++- db/migrate.mjs | 57 +++ db/migrations/0001_init.sql | 156 +++++++ lib/auth/password.ts | 35 ++ lib/auth/session.ts | 110 +++++ lib/auth/webauthn-challenges.ts | 34 ++ lib/auth/webauthn-config.ts | 21 + lib/db/index.ts | 36 ++ lib/db/pool.ts | 17 + lib/i18n/dictionaries.ts | 12 +- lib/origin.ts | 4 +- package-lock.json | 423 ++++++++++++++++++ package.json | 9 +- proxy.ts | 86 ++-- scripts/bootstrap-admin.mjs | 82 ++++ 28 files changed, 1618 insertions(+), 153 deletions(-) create mode 100644 app/api/account/email/route.ts create mode 100644 app/api/account/password/route.ts create mode 100644 app/api/account/route.ts create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/passkeys/[id]/route.ts create mode 100644 app/api/auth/passkeys/login-options/route.ts create mode 100644 app/api/auth/passkeys/login-verify/route.ts create mode 100644 app/api/auth/passkeys/register-options/route.ts create mode 100644 app/api/auth/passkeys/register-verify/route.ts create mode 100644 app/api/auth/passkeys/route.ts create mode 100644 db/migrate.mjs create mode 100644 db/migrations/0001_init.sql create mode 100644 lib/auth/password.ts create mode 100644 lib/auth/session.ts create mode 100644 lib/auth/webauthn-challenges.ts create mode 100644 lib/auth/webauthn-config.ts create mode 100644 lib/db/index.ts create mode 100644 lib/db/pool.ts create mode 100644 scripts/bootstrap-admin.mjs diff --git a/app/api/account/email/route.ts b/app/api/account/email/route.ts new file mode 100644 index 0000000..5b4b8e0 --- /dev/null +++ b/app/api/account/email/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { query, queryOne } from "@/lib/db"; +import { verifyPassword } from "@/lib/auth/password"; +import { requireSession, UnauthorizedError } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +// Ändert die E-Mail-Adresse direkt nach Passwort-Bestätigung – ohne +// Bestätigungsmail, da kein SMTP-Server betrieben wird. +export async function PATCH(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + const session = await requireSession(); + const { email, password } = (await request.json().catch(() => ({}))) as { + email?: string; + password?: string; + }; + if (!email || !password) { + return NextResponse.json({ error: "invalid_input" }, { status: 400 }); + } + + const user = await queryOne<{ password_hash: string }>( + "select password_hash from users where id = $1", + [session.user.id] + ); + if (!user || !verifyPassword(password, user.password_hash)) { + return NextResponse.json({ error: "wrong_password" }, { status: 401 }); + } + + try { + await query("update users set email = $1 where id = $2", [ + email.trim().toLowerCase(), + session.user.id, + ]); + } catch (err) { + if (err instanceof Error && /unique/i.test(err.message)) { + return NextResponse.json({ error: "email_used" }, { status: 409 }); + } + throw err; + } + + return NextResponse.json({ ok: true }); + } catch (err) { + if (err instanceof UnauthorizedError) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + throw err; + } +} diff --git a/app/api/account/password/route.ts b/app/api/account/password/route.ts new file mode 100644 index 0000000..1150984 --- /dev/null +++ b/app/api/account/password/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { query, queryOne } from "@/lib/db"; +import { hashPassword, verifyPassword } from "@/lib/auth/password"; +import { requireSession, UnauthorizedError } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +export async function PATCH(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + const session = await requireSession(); + const { currentPassword, newPassword } = (await request.json().catch(() => ({}))) as { + currentPassword?: string; + newPassword?: string; + }; + if (!currentPassword || !newPassword || newPassword.length < 8) { + return NextResponse.json({ error: "invalid_input" }, { status: 400 }); + } + + const user = await queryOne<{ password_hash: string }>( + "select password_hash from users where id = $1", + [session.user.id] + ); + if (!user || !verifyPassword(currentPassword, user.password_hash)) { + return NextResponse.json({ error: "wrong_password" }, { status: 401 }); + } + + await query("update users set password_hash = $1 where id = $2", [ + hashPassword(newPassword), + session.user.id, + ]); + + return NextResponse.json({ ok: true }); + } catch (err) { + if (err instanceof UnauthorizedError) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + throw err; + } +} diff --git a/app/api/account/route.ts b/app/api/account/route.ts new file mode 100644 index 0000000..f30f0a7 --- /dev/null +++ b/app/api/account/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { query, queryOne } from "@/lib/db"; +import { requireSession, UnauthorizedError } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +// Nimmt bewusst NUR full_name/password_auth_enabled aus dem Body an – +// ersetzt den früheren Postgres-Column-Grant, der self-service-Updates +// auf "role"/"email" verhindert hat. Ein generisches "update the whole +// body"-Endpoint würde diese Absicherung wieder aufheben. +export async function PATCH(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + const session = await requireSession(); + const body = (await request.json().catch(() => ({}))) as { + full_name?: string; + password_auth_enabled?: boolean; + }; + + if (body.password_auth_enabled === false) { + const passkeys = await queryOne<{ count: string }>( + "select count(*)::int as count from passkey_credentials where user_id = $1", + [session.user.id] + ); + if (!passkeys || Number(passkeys.count) === 0) { + return NextResponse.json({ error: "needs_passkey" }, { status: 400 }); + } + } + + if (body.full_name !== undefined) { + const name = body.full_name.trim(); + if (!name) return NextResponse.json({ error: "name_required" }, { status: 400 }); + await query("update users set full_name = $1 where id = $2", [name, session.user.id]); + } + if (body.password_auth_enabled !== undefined) { + await query("update users set password_auth_enabled = $1 where id = $2", [ + body.password_auth_enabled, + session.user.id, + ]); + } + + return NextResponse.json({ ok: true }); + } catch (err) { + if (err instanceof UnauthorizedError) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + throw err; + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..0eb491c --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { verifyPassword } from "@/lib/auth/password"; +import { createSession } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; +import type { Role } from "@/lib/types"; + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + + const { email, password } = (await request.json().catch(() => ({}))) as { + email?: string; + password?: string; + }; + if (!email || !password) { + return NextResponse.json({ error: "invalid_credentials" }, { status: 400 }); + } + + const user = await queryOne<{ + id: string; + password_hash: string; + role: Role; + password_auth_enabled: boolean; + }>( + "select id, password_hash, role, password_auth_enabled from users where email = $1", + [email.trim().toLowerCase()] + ); + + if (!user || !verifyPassword(password, user.password_hash)) { + return NextResponse.json({ error: "invalid_credentials" }, { status: 401 }); + } + if (!user.password_auth_enabled) { + return NextResponse.json({ error: "password_disabled" }, { status: 403 }); + } + + await createSession(user.id); + return NextResponse.json({ role: user.role }); +} diff --git a/app/api/auth/passkeys/[id]/route.ts b/app/api/auth/passkeys/[id]/route.ts new file mode 100644 index 0000000..47a5bf3 --- /dev/null +++ b/app/api/auth/passkeys/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { query } from "@/lib/db"; +import { requireSession, UnauthorizedError } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + const session = await requireSession(); + const { id } = await params; + // user_id-Filter stellt sicher, dass nur eigene Credentials gelöscht + // werden können. + await query("delete from passkey_credentials where id = $1 and user_id = $2", [ + id, + session.user.id, + ]); + return NextResponse.json({ ok: true }); + } catch (err) { + if (err instanceof UnauthorizedError) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + throw err; + } +} diff --git a/app/api/auth/passkeys/login-options/route.ts b/app/api/auth/passkeys/login-options/route.ts new file mode 100644 index 0000000..04a1ac5 --- /dev/null +++ b/app/api/auth/passkeys/login-options/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { generateAuthenticationOptions } from "@simplewebauthn/server"; +import { rpID } from "@/lib/auth/webauthn-config"; +import { storeChallenge } from "@/lib/auth/webauthn-challenges"; + +// Passwortloser "usernameless"-Login: kein allowCredentials, der Browser +// fragt den Nutzer, welchen registrierten Passkey er verwenden möchte. +// user_id ist beim Speichern der Challenge noch unbekannt. +export async function POST() { + const options = await generateAuthenticationOptions({ + rpID: rpID(), + userVerification: "preferred", + }); + await storeChallenge(options.challenge, "authentication", null); + return NextResponse.json(options); +} diff --git a/app/api/auth/passkeys/login-verify/route.ts b/app/api/auth/passkeys/login-verify/route.ts new file mode 100644 index 0000000..4372371 --- /dev/null +++ b/app/api/auth/passkeys/login-verify/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { verifyAuthenticationResponse } from "@simplewebauthn/server"; +import type { + AuthenticationResponseJSON, + AuthenticatorTransportFuture, +} from "@simplewebauthn/server"; +import { query, queryOne } from "@/lib/db"; +import { createSession } from "@/lib/auth/session"; +import { origin, rpID } from "@/lib/auth/webauthn-config"; +import { consumeChallenge } from "@/lib/auth/webauthn-challenges"; +import { isSameOrigin } from "@/lib/origin"; +import type { Role } from "@/lib/types"; + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + + const body = (await request.json()) as { response: AuthenticationResponseJSON }; + const challenge = extractChallengeFromClientData(body.response); + const pending = await consumeChallenge(challenge, "authentication"); + if (!pending) { + return NextResponse.json({ error: "challenge_expired" }, { status: 400 }); + } + + // Passkey-Login ist unabhängig von password_auth_enabled – dieses Flag + // betrifft ausschließlich den Passwort-Login. + const stored = await queryOne<{ + user_id: string; + public_key: Buffer; + counter: string; + transports: AuthenticatorTransportFuture[]; + role: Role; + }>( + `select pc.user_id, pc.public_key, pc.counter, pc.transports, u.role + from passkey_credentials pc join users u on u.id = pc.user_id + where pc.credential_id = $1`, + [body.response.id] + ); + if (!stored) { + return NextResponse.json({ error: "unknown_credential" }, { status: 400 }); + } + + const verification = await verifyAuthenticationResponse({ + response: body.response, + expectedChallenge: challenge, + expectedOrigin: origin(), + expectedRPID: rpID(), + credential: { + id: body.response.id, + publicKey: new Uint8Array(stored.public_key), + counter: Number(stored.counter), + transports: stored.transports, + }, + }); + + if (!verification.verified) { + return NextResponse.json({ error: "verification_failed" }, { status: 400 }); + } + + await query( + "update passkey_credentials set counter = $1, last_used_at = now() where credential_id = $2", + [verification.authenticationInfo.newCounter, body.response.id] + ); + + await createSession(stored.user_id); + return NextResponse.json({ role: stored.role }); +} + +function extractChallengeFromClientData(response: AuthenticationResponseJSON): string { + const json = JSON.parse( + Buffer.from(response.response.clientDataJSON, "base64url").toString("utf8") + ) as { challenge: string }; + return json.challenge; +} diff --git a/app/api/auth/passkeys/register-options/route.ts b/app/api/auth/passkeys/register-options/route.ts new file mode 100644 index 0000000..c5d0986 --- /dev/null +++ b/app/api/auth/passkeys/register-options/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { generateRegistrationOptions } from "@simplewebauthn/server"; +import type { AuthenticatorTransportFuture } from "@simplewebauthn/server"; +import { query } from "@/lib/db"; +import { requireSession, UnauthorizedError } from "@/lib/auth/session"; +import { rpID, rpName } from "@/lib/auth/webauthn-config"; +import { storeChallenge } from "@/lib/auth/webauthn-challenges"; + +export async function POST() { + try { + const session = await requireSession(); + + const existing = await query<{ + credential_id: string; + transports: AuthenticatorTransportFuture[]; + }>("select credential_id, transports from passkey_credentials where user_id = $1", [ + session.user.id, + ]); + + const options = await generateRegistrationOptions({ + rpName: rpName(), + rpID: rpID(), + userID: new TextEncoder().encode(session.user.id), + userName: session.user.email, + userDisplayName: session.user.full_name || session.user.email, + attestationType: "none", + excludeCredentials: existing.map((c) => ({ + id: c.credential_id, + transports: c.transports, + })), + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + }); + + await storeChallenge(options.challenge, "registration", session.user.id); + return NextResponse.json(options); + } catch (err) { + if (err instanceof UnauthorizedError) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + throw err; + } +} diff --git a/app/api/auth/passkeys/register-verify/route.ts b/app/api/auth/passkeys/register-verify/route.ts new file mode 100644 index 0000000..a65b2e1 --- /dev/null +++ b/app/api/auth/passkeys/register-verify/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { verifyRegistrationResponse } from "@simplewebauthn/server"; +import type { RegistrationResponseJSON } from "@simplewebauthn/server"; +import { query } from "@/lib/db"; +import { requireSession, UnauthorizedError } from "@/lib/auth/session"; +import { origin, rpID } from "@/lib/auth/webauthn-config"; +import { consumeChallenge } from "@/lib/auth/webauthn-challenges"; +import { isSameOrigin } from "@/lib/origin"; + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + const session = await requireSession(); + const body = (await request.json()) as { + response: RegistrationResponseJSON; + name?: string; + }; + + const challenge = extractChallengeFromClientData(body.response); + const pending = await consumeChallenge(challenge, "registration"); + if (!pending || pending.userId !== session.user.id) { + return NextResponse.json({ error: "challenge_expired" }, { status: 400 }); + } + + const verification = await verifyRegistrationResponse({ + response: body.response, + expectedChallenge: challenge, + expectedOrigin: origin(), + expectedRPID: rpID(), + }); + + if (!verification.verified || !verification.registrationInfo) { + return NextResponse.json({ error: "verification_failed" }, { status: 400 }); + } + + const { credential } = verification.registrationInfo; + await query( + `insert into passkey_credentials (user_id, credential_id, public_key, counter, transports, name) + values ($1, $2, $3, $4, $5, $6)`, + [ + session.user.id, + credential.id, + Buffer.from(credential.publicKey), + credential.counter, + credential.transports ?? [], + body.name ?? null, + ] + ); + + return NextResponse.json({ ok: true }); + } catch (err) { + if (err instanceof UnauthorizedError) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + throw err; + } +} + +// clientDataJSON ist base64url-kodiertes JSON mit dem Feld "challenge" – +// darüber wird die zugehörige, serverseitig gespeicherte Challenge +// gefunden, ohne sie zusätzlich im Request-Body mitschicken zu müssen. +function extractChallengeFromClientData(response: RegistrationResponseJSON): string { + const json = JSON.parse( + Buffer.from(response.response.clientDataJSON, "base64url").toString("utf8") + ) as { challenge: string }; + return json.challenge; +} diff --git a/app/api/auth/passkeys/route.ts b/app/api/auth/passkeys/route.ts new file mode 100644 index 0000000..a133305 --- /dev/null +++ b/app/api/auth/passkeys/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { query } from "@/lib/db"; +import { requireSession, UnauthorizedError } from "@/lib/auth/session"; + +export async function GET() { + try { + const session = await requireSession(); + const rows = await query<{ + id: string; + name: string | null; + created_at: string; + last_used_at: string | null; + }>( + `select id, name, created_at, last_used_at from passkey_credentials + where user_id = $1 order by created_at`, + [session.user.id] + ); + return NextResponse.json(rows); + } catch (err) { + if (err instanceof UnauthorizedError) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + throw err; + } +} diff --git a/app/login/page.tsx b/app/login/page.tsx index f84e75e..1ebb374 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -3,67 +3,48 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { createClient } from "@/lib/supabase/client"; +import { startAuthentication } from "@simplewebauthn/browser"; import { formatMessage } from "@/lib/i18n/shared"; import { useDict } from "@/components/i18n/LocaleProvider"; import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; import type { Dict } from "@/lib/i18n/shared"; -function authErrorMessage(dict: Dict, message: string): string { - if (/invalid login credentials/i.test(message)) - return dict.login.errorInvalidCredentials; - if (/email not confirmed/i.test(message)) - return dict.login.errorEmailNotConfirmed; - if (/cancelled|aborted|NotAllowedError/i.test(message)) +function authErrorMessage(dict: Dict, code: string): string { + if (code === "invalid_credentials") return dict.login.errorInvalidCredentials; + if (code === "password_disabled") return dict.login.errorPasswordDisabled; + if (/cancelled|aborted|NotAllowedError/i.test(code)) return dict.login.errorPasskeyCancelled; - return formatMessage(dict.login.errorGeneric, { message }); + return formatMessage(dict.login.errorGeneric, { message: code }); +} + +function redirectPath(role: string): string { + return role === "admin" ? "/admin" : "/intern"; } export default function LoginPage() { const router = useRouter(); - const supabase = createClient(); const dict = useDict(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - async function redirectByRole(userId: string) { - const { data: profile } = await supabase - .from("profiles") - .select("role") - .eq("id", userId) - .single(); - router.replace(profile?.role === "admin" ? "/admin" : "/intern"); - router.refresh(); - } - async function signInWithPassword(e: React.FormEvent) { e.preventDefault(); setError(null); setBusy(true); - const { data, error } = await supabase.auth.signInWithPassword({ - email, - password, + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), }); - if (error || !data.user) { - setError(authErrorMessage(dict, error?.message ?? "Unbekannter Fehler")); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(authErrorMessage(dict, data.error ?? "unknown")); setBusy(false); return; } - - const { data: profile } = await supabase - .from("profiles") - .select("role, password_auth_enabled") - .eq("id", data.user.id) - .single(); - if (profile?.password_auth_enabled === false) { - await supabase.auth.signOut(); - setError(dict.login.errorPasswordDisabled); - setBusy(false); - return; - } - router.replace(profile?.role === "admin" ? "/admin" : "/intern"); + router.replace(redirectPath(data.role)); router.refresh(); } @@ -71,15 +52,23 @@ export default function LoginPage() { setError(null); setBusy(true); try { - const { data, error } = await supabase.auth.signInWithPasskey(); - if (error || !data?.user) { - setError( - authErrorMessage(dict, error?.message ?? "Passkey-Anmeldung fehlgeschlagen") - ); + const optionsRes = await fetch("/api/auth/passkeys/login-options", { method: "POST" }); + const options = await optionsRes.json(); + const response = await startAuthentication({ optionsJSON: options }); + + const verifyRes = await fetch("/api/auth/passkeys/login-verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ response }), + }); + const data = await verifyRes.json().catch(() => ({})); + if (!verifyRes.ok) { + setError(authErrorMessage(dict, data.error ?? "unknown")); setBusy(false); return; } - await redirectByRole(data.user.id); + router.replace(redirectPath(data.role)); + router.refresh(); } catch (err) { setError(authErrorMessage(dict, err instanceof Error ? err.message : String(err))); setBusy(false); diff --git a/app/logout/route.ts b/app/logout/route.ts index b4aa56f..3e09089 100644 --- a/app/logout/route.ts +++ b/app/logout/route.ts @@ -1,13 +1,12 @@ import { NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/server"; +import { destroySession } from "@/lib/auth/session"; import { isSameOrigin } from "@/lib/origin"; export async function POST(request: Request) { if (!isSameOrigin(request)) { return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); } - const supabase = await createClient(); - await supabase.auth.signOut(); + await destroySession(); // Relative Weiterleitung: bei einer absoluten URL aus request.url landet // sonst die Server-Bindung (0.0.0.0) statt des aufgerufenen Hosts im // Location-Header. diff --git a/components/AccountManager.tsx b/components/AccountManager.tsx index 752d500..72b5818 100644 --- a/components/AccountManager.tsx +++ b/components/AccountManager.tsx @@ -1,20 +1,20 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { createClient } from "@/lib/supabase/client"; import { formatMessage } from "@/lib/i18n/shared"; -import type { Dict } from "@/lib/i18n/shared"; import PasskeyManager from "@/components/PasskeyManager"; import { useDict } from "@/components/i18n/LocaleProvider"; -function authErrorMessage(dict: Dict, message: string): string { - if (/invalid login credentials/i.test(message)) - return dict.account.wrongCurrentPassword; - if (/should be at least/i.test(message)) return dict.account.passwordTooShort; - if (/email.*already|already.*registered/i.test(message)) - return dict.account.emailAlreadyUsed; - return message; +function errorMessage( + dict: ReturnType, + code: string, + fallback: string +): string { + if (code === "wrong_password") return dict.account.wrongCurrentPassword; + if (code === "email_used") return dict.account.emailAlreadyUsed; + if (code === "needs_passkey") return dict.account.passwordAuthNeedsPasskey; + return fallback; } export default function AccountManager({ @@ -28,7 +28,6 @@ export default function AccountManager({ initialEmail: string; initialPasswordAuthEnabled: boolean; }) { - const supabase = useMemo(() => createClient(), []); const router = useRouter(); const dict = useDict(); const roleLabel = role === "admin" ? dict.common.roleAdmin : dict.common.roleStaff; @@ -41,6 +40,7 @@ export default function AccountManager({ // E-Mail-Adresse const [email, setEmail] = useState(initialEmail); + const [emailPassword, setEmailPassword] = useState(""); const [emailError, setEmailError] = useState(null); const [emailInfo, setEmailInfo] = useState(null); const [emailBusy, setEmailBusy] = useState(false); @@ -63,10 +63,10 @@ export default function AccountManager({ const [authBusy, setAuthBusy] = useState(false); useEffect(() => { - supabase.auth.passkey - .list() - .then(({ data }) => setPasskeyCount((data ?? []).length)); - }, [supabase]); + fetch("/api/auth/passkeys") + .then((res) => (res.ok ? res.json() : [])) + .then((data) => setPasskeyCount(data.length)); + }, []); async function saveName(e: React.FormEvent) { e.preventDefault(); @@ -78,13 +78,19 @@ export default function AccountManager({ 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(formatMessage(dict.account.saveError, { message: error.message })); - else { + const res = await fetch("/api/account", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ full_name: name }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setNameError( + formatMessage(dict.account.saveError, { + message: errorMessage(dict, data.error, data.error ?? String(res.status)), + }) + ); + } else { setNameInfo(dict.account.nameSaved); router.refresh(); } @@ -98,15 +104,22 @@ export default function AccountManager({ const next = email.trim(); if (!next || next === initialEmail) return; setEmailBusy(true); - const { error } = await supabase.auth.updateUser({ email: next }); - if (error) { + const res = await fetch("/api/account/email", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: next, password: emailPassword }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); setEmailError( formatMessage(dict.account.changeError, { - message: authErrorMessage(dict, error.message), + message: errorMessage(dict, data.error, data.error ?? String(res.status)), }) ); } else { - setEmailInfo(dict.account.emailSent); + setEmailInfo(dict.account.emailChanged); + setEmailPassword(""); + router.refresh(); } setEmailBusy(false); } @@ -125,22 +138,16 @@ export default function AccountManager({ } setPwBusy(true); - // Zur Verifizierung: aktuelles Passwort gegenprüfen, bevor geändert wird. - const { error: verifyError } = await supabase.auth.signInWithPassword({ - email: initialEmail, - password: currentPassword, + const res = await fetch("/api/account/password", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentPassword, newPassword }), }); - if (verifyError) { - setPwError(authErrorMessage(dict, verifyError.message)); - setPwBusy(false); - return; - } - - const { error } = await supabase.auth.updateUser({ password: newPassword }); - if (error) { + if (!res.ok) { + const data = await res.json().catch(() => ({})); setPwError( formatMessage(dict.account.changeError, { - message: authErrorMessage(dict, error.message), + message: errorMessage(dict, data.error, data.error ?? String(res.status)), }) ); } else { @@ -163,12 +170,18 @@ export default function AccountManager({ if (!next && !window.confirm(dict.account.confirmDisablePasswordAuth)) return; setAuthBusy(true); - const { error } = await supabase - .from("profiles") - .update({ password_auth_enabled: next }) - .eq("id", (await supabase.auth.getUser()).data.user?.id ?? ""); - if (error) { - setAuthError(formatMessage(dict.account.saveError, { message: error.message })); + const res = await fetch("/api/account", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password_auth_enabled: next }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setAuthError( + formatMessage(dict.account.saveError, { + message: errorMessage(dict, data.error, data.error ?? String(res.status)), + }) + ); } else { setPasswordAuthEnabled(next); setAuthInfo( @@ -232,6 +245,20 @@ export default function AccountManager({ required /> +
+ + setEmailPassword(e.target.value)} + autoComplete="current-password" + required + /> +
{emailError && (

{emailError} diff --git a/components/PasskeyManager.tsx b/components/PasskeyManager.tsx index f745de8..d74b7af 100644 --- a/components/PasskeyManager.tsx +++ b/components/PasskeyManager.tsx @@ -1,19 +1,18 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; +import { useCallback, useEffect, useState } from "react"; +import { startRegistration } from "@simplewebauthn/browser"; import { formatMessage } from "@/lib/i18n/shared"; import { useDict } from "@/components/i18n/LocaleProvider"; interface PasskeyItem { id: string; - friendly_name?: string; + name: string | null; created_at: string; - last_used_at?: string; + last_used_at: string | null; } export default function PasskeyManager() { - const supabase = useMemo(() => createClient(), []); const dict = useDict(); const [passkeys, setPasskeys] = useState([]); const [error, setError] = useState(null); @@ -21,11 +20,13 @@ export default function PasskeyManager() { const [busy, setBusy] = useState(false); const load = useCallback(async () => { - const { data, error } = await supabase.auth.passkey.list(); - if (error) - setError(formatMessage(dict.passkeys.loadError, { message: error.message })); - else setPasskeys((data ?? []) as PasskeyItem[]); - }, [supabase, dict]); + const res = await fetch("/api/auth/passkeys"); + if (!res.ok) { + setError(formatMessage(dict.passkeys.loadError, { message: String(res.status) })); + return; + } + setPasskeys(await res.json()); + }, [dict]); useEffect(() => { load(); @@ -36,13 +37,22 @@ export default function PasskeyManager() { setInfo(null); setBusy(true); try { - const { error } = await supabase.auth.registerPasskey(); - if (error) { - setError(formatMessage(dict.passkeys.registerError, { message: error.message })); - } else { - setInfo(dict.passkeys.registered); - await load(); + const optionsRes = await fetch("/api/auth/passkeys/register-options", { method: "POST" }); + if (!optionsRes.ok) throw new Error(String(optionsRes.status)); + const options = await optionsRes.json(); + const response = await startRegistration({ optionsJSON: options }); + + const verifyRes = await fetch("/api/auth/passkeys/register-verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ response }), + }); + if (!verifyRes.ok) { + const data = await verifyRes.json().catch(() => ({})); + throw new Error(data.error ?? String(verifyRes.status)); } + setInfo(dict.passkeys.registered); + await load(); } catch (err) { setError( /NotAllowedError|abort/i.test(String(err)) @@ -57,9 +67,9 @@ export default function PasskeyManager() { async function remove(pk: PasskeyItem) { if (!window.confirm(dict.passkeys.confirmDelete)) return; - const { error } = await supabase.auth.passkey.delete({ passkeyId: pk.id }); - if (error) - setError(formatMessage(dict.passkeys.deleteError, { message: error.message })); + const res = await fetch(`/api/auth/passkeys/${pk.id}`, { method: "DELETE" }); + if (!res.ok) + setError(formatMessage(dict.passkeys.deleteError, { message: String(res.status) })); await load(); } @@ -88,9 +98,7 @@ export default function PasskeyManager() { {passkeys.map((pk) => (

  • - - {pk.friendly_name || "Passkey"} - {" "} + {pk.name || "Passkey"}{" "} {formatMessage(dict.passkeys.createdOn, { date: new Date(pk.created_at).toLocaleDateString(dict.dateLocale), diff --git a/db/migrate.mjs b/db/migrate.mjs new file mode 100644 index 0000000..fb6c92b --- /dev/null +++ b/db/migrate.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// Wendet alle noch nicht angewendeten Dateien aus db/migrations/*.sql auf +// die per DATABASE_URL erreichbare Postgres-Datenbank an. Idempotent – +// kann bei jedem App-Start gefahrlos erneut laufen (Docker-Entrypoint, +// npm predev/prestart). + +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import pg from "pg"; + +const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), "migrations"); + +async function main() { + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + + try { + await client.query(` + create table if not exists schema_migrations ( + filename text primary key, + applied_at timestamptz not null default now() + ) + `); + + const { rows } = await client.query("select filename from schema_migrations"); + const applied = new Set(rows.map((r) => r.filename)); + + const files = readdirSync(migrationsDir) + .filter((f) => f.endsWith(".sql")) + .sort(); + + for (const file of files) { + if (applied.has(file)) continue; + const sql = readFileSync(join(migrationsDir, file), "utf8"); + console.log(`db:migrate – wende ${file} an…`); + await client.query("begin"); + try { + await client.query(sql); + await client.query("insert into schema_migrations (filename) values ($1)", [file]); + await client.query("commit"); + } catch (err) { + await client.query("rollback"); + throw new Error(`Migration ${file} fehlgeschlagen: ${err.message}`, { cause: err }); + } + } + + console.log("db:migrate – Datenbank ist aktuell."); + } finally { + await client.end(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/db/migrations/0001_init.sql b/db/migrations/0001_init.sql new file mode 100644 index 0000000..d300752 --- /dev/null +++ b/db/migrations/0001_init.sql @@ -0,0 +1,156 @@ +-- ============================================================= +-- Canteen Menu – Initiales Schema (selbst gehostet, ohne Supabase) +-- Rollen: admin (Benutzerverwaltung, Branding) / staff (Kantine) +-- Öffentliche Speisekarte: lesbar ohne Login +-- ============================================================= + +-- ---------- Benutzer & Authentifizierung ---------- +create table users ( + id uuid primary key default gen_random_uuid(), + email text not null unique, + password_hash text not null, + full_name text not null default '', + role text not null default 'staff' check (role in ('admin', 'staff')), + password_auth_enabled boolean not null default true, + created_at timestamptz not null default now() +); + +-- Angemeldete Sitzungen. Es wird nur der Hash des Tokens gespeichert – +-- der Rohwert im Cookie ist damit auch bei einem DB-Zugriff allein nicht +-- verwendbar. +create table sessions ( + token_hash text primary key, + user_id uuid not null references users (id) on delete cascade, + created_at timestamptz not null default now(), + expires_at timestamptz not null, + last_seen_at timestamptz not null default now() +); +create index sessions_user_id_idx on sessions (user_id); + +-- WebAuthn-Credentials (ersetzt Supabase's internes, für die App bisher +-- unsichtbares Passkey-Schema). +create table passkey_credentials ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references users (id) on delete cascade, + credential_id text not null unique, + public_key bytea not null, + counter bigint not null default 0, + transports text[] not null default '{}', + name text, + created_at timestamptz not null default now(), + last_used_at timestamptz +); +create index passkey_credentials_user_id_idx on passkey_credentials (user_id); + +-- Kurzlebige Challenges für laufende WebAuthn-Zeremonien (Registrierung +-- oder Login). user_id ist beim Login-Flow anfangs null (Discoverable- +-- Credential-Ansatz: die Identität steht erst nach der Antwort fest). +create table webauthn_challenges ( + id uuid primary key default gen_random_uuid(), + user_id uuid references users (id) on delete cascade, + challenge text not null, + type text not null check (type in ('registration', 'authentication')), + expires_at timestamptz not null, + created_at timestamptz not null default now() +); + +-- ---------- Essenszeiten der Speisekarte ---------- +create table sections ( + key text primary key default (gen_random_uuid())::text, + label text not null, + label_en text, + label_pl text, + time_range text not null, + sort int not null +); + +-- ---------- Allergene & Zusatzstoffe ---------- +create table allergens ( + code text primary key, + name text not null, + name_en text, + name_pl text, + sort int not null default 0 +); + +create table additives ( + code text primary key, + name text not null, + name_en text, + name_pl text, + sort int not null default 0 +); + +-- ---------- Gerichte ---------- +create table dishes ( + id uuid primary key default gen_random_uuid(), + name text not null, + name_en text, + name_pl text, + price numeric(6, 2) not null default 0, + allergens text[] not null default '{}', + additives text[] not null default '{}', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create or replace function touch_updated_at() +returns trigger +language plpgsql +as $$ +begin + new.updated_at := now(); + return new; +end; +$$; + +create trigger dishes_touch_updated_at + before update on dishes + for each row execute function touch_updated_at(); + +-- ---------- Zuweisung Gericht -> Tag/Essenszeit ---------- +create table menu_entries ( + id uuid primary key default gen_random_uuid(), + menu_date date not null, + section_key text not null references sections (key) on delete cascade, + dish_id uuid not null references dishes (id) on delete cascade, + position int not null default 0, + created_at timestamptz not null default now(), + unique (menu_date, section_key, dish_id) +); +create index menu_entries_date_idx on menu_entries (menu_date); + +-- ---------- Einstellungen (Farben, Logo, Favicon) – genau eine Zeile ---------- +create table settings ( + id int primary key check (id = 1), + colors jsonb not null default '{}'::jsonb, + logo_path text, + favicon_path text, + updated_at timestamptz not null default now() +); + +insert into settings (id, colors) values ( + 1, + '{ + "primary": "#003388", + "primaryDark": "#0d315c", + "accent": "#ff6900", + "headerText": "#ffffff", + "rowBg": "#e3e4f1", + "rowBgAlt": "#f4f4fa", + "addBtn": "#6da54c", + "deleteBtn": "#e2371d" + }'::jsonb +); + +create trigger settings_touch_updated_at + before update on settings + for each row execute function touch_updated_at(); + +-- ---------- Rezeptvorschläge ---------- +create table dish_suggestions ( + id uuid primary key default gen_random_uuid(), + name text not null, + description text, + created_at timestamptz not null default now() +); diff --git a/lib/auth/password.ts b/lib/auth/password.ts new file mode 100644 index 0000000..a7dc8b3 --- /dev/null +++ b/lib/auth/password.ts @@ -0,0 +1,35 @@ +import "server-only"; +import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; + +// scrypt-Parameter fest verankert statt "der aktuelle Node-Default" zu +// nutzen, damit ein künftiges Node-Upgrade die Hash-Stärke nicht +// stillschweigend ändert. Speicherbedarf = 128 * N * r = 16 MiB. +const N = 16384; +const r = 8; +const p = 1; +const KEY_LEN = 64; +const MAX_MEM = 32 * 1024 * 1024; + +// Selbstbeschreibendes Format "scrypt$N$r$p$salt$hash" (hex), damit die +// Parameter später erhöht werden können, ohne bestehende Hashes zu +// invalidieren. +export function hashPassword(plain: string): string { + const salt = randomBytes(16); + const hash = scryptSync(plain, salt, KEY_LEN, { N, r, p, maxmem: MAX_MEM }); + return `scrypt$${N}$${r}$${p}$${salt.toString("hex")}$${hash.toString("hex")}`; +} + +export function verifyPassword(plain: string, stored: string): boolean { + const parts = stored.split("$"); + if (parts.length !== 6 || parts[0] !== "scrypt") return false; + const [, nStr, rStr, pStr, saltHex, hashHex] = parts; + const salt = Buffer.from(saltHex, "hex"); + const expected = Buffer.from(hashHex, "hex"); + const actual = scryptSync(plain, salt, expected.length, { + N: Number(nStr), + r: Number(rStr), + p: Number(pStr), + maxmem: MAX_MEM, + }); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} diff --git a/lib/auth/session.ts b/lib/auth/session.ts new file mode 100644 index 0000000..d5aa963 --- /dev/null +++ b/lib/auth/session.ts @@ -0,0 +1,110 @@ +import "server-only"; +import { createHash, randomBytes } from "node:crypto"; +import { cookies } from "next/headers"; +import { query, queryOne } from "@/lib/db"; +import type { Role } from "@/lib/types"; + +export const SESSION_COOKIE = "session"; +const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage +const REFRESH_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; // unter 7 Tage Restlaufzeit verlängern + +export interface SessionUser { + id: string; + email: string; + full_name: string; + role: Role; + password_auth_enabled: boolean; +} + +export interface Session { + user: SessionUser; + expiresAt: Date; +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +function isSecureCookie(): boolean { + return (process.env.APP_URL ?? "").startsWith("https://"); +} + +/** Nur lesend – sicher in Server Components, Route Handlern und Server Actions. */ +export async function getSession(): Promise { + const token = (await cookies()).get(SESSION_COOKIE)?.value; + if (!token) return null; + + const row = await queryOne<{ + expires_at: Date; + id: string; + email: string; + full_name: string; + role: Role; + password_auth_enabled: boolean; + }>( + `select s.expires_at, u.id, u.email, u.full_name, u.role, u.password_auth_enabled + from sessions s join users u on u.id = s.user_id + where s.token_hash = $1 and s.expires_at > now()`, + [hashToken(token)] + ); + if (!row) return null; + + return { + expiresAt: row.expires_at, + user: { + id: row.id, + email: row.email, + full_name: row.full_name, + role: row.role, + password_auth_enabled: row.password_auth_enabled, + }, + }; +} + +export class UnauthorizedError extends Error {} +export class ForbiddenError extends Error {} + +export async function requireSession(): Promise { + const session = await getSession(); + if (!session) throw new UnauthorizedError(); + return session; +} + +export async function requireRole(roles: Role[]): Promise { + const session = await requireSession(); + if (!roles.includes(session.user.role)) throw new ForbiddenError(); + return session; +} + +/** Legt eine neue Sitzung an und setzt das Session-Cookie. Nur in Route + * Handlern/Server Actions aufrufbar (dort ist cookies().set() erlaubt). */ +export async function createSession(userId: string): Promise { + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS); + + await query( + "insert into sessions (token_hash, user_id, expires_at) values ($1, $2, $3)", + [hashToken(token), userId, expiresAt] + ); + + (await cookies()).set(SESSION_COOKIE, token, { + httpOnly: true, + secure: isSecureCookie(), + sameSite: "lax", + path: "/", + expires: expiresAt, + }); +} + +/** Löscht die Sitzung serverseitig (nicht nur das Cookie), damit ein + * abgefangener Token nicht mehr verwendbar ist. */ +export async function destroySession(): Promise { + const store = await cookies(); + const token = store.get(SESSION_COOKIE)?.value; + if (token) { + await query("delete from sessions where token_hash = $1", [hashToken(token)]); + } + store.delete(SESSION_COOKIE); +} + +export { REFRESH_THRESHOLD_MS, hashToken, isSecureCookie }; diff --git a/lib/auth/webauthn-challenges.ts b/lib/auth/webauthn-challenges.ts new file mode 100644 index 0000000..1899677 --- /dev/null +++ b/lib/auth/webauthn-challenges.ts @@ -0,0 +1,34 @@ +import "server-only"; +import { query, queryOne } from "@/lib/db"; + +const CHALLENGE_TTL_MS = 5 * 60 * 1000; + +export type ChallengeType = "registration" | "authentication"; + +export async function storeChallenge( + challenge: string, + type: ChallengeType, + userId: string | null +): Promise { + // Kein Cron nötig: abgelaufene Zeilen bei jeder neuen Challenge + // gleich mit aufräumen. + await query("delete from webauthn_challenges where expires_at < now()"); + await query( + "insert into webauthn_challenges (user_id, challenge, type, expires_at) values ($1, $2, $3, $4)", + [userId, challenge, type, new Date(Date.now() + CHALLENGE_TTL_MS)] + ); +} + +/** Einmalig verwendbar: die Zeile wird beim Abruf sofort gelöscht. */ +export async function consumeChallenge( + challenge: string, + type: ChallengeType +): Promise<{ userId: string | null } | null> { + const row = await queryOne<{ user_id: string | null }>( + `delete from webauthn_challenges + where challenge = $1 and type = $2 and expires_at > now() + returning user_id`, + [challenge, type] + ); + return row ? { userId: row.user_id } : null; +} diff --git a/lib/auth/webauthn-config.ts b/lib/auth/webauthn-config.ts new file mode 100644 index 0000000..c8fb7c7 --- /dev/null +++ b/lib/auth/webauthn-config.ts @@ -0,0 +1,21 @@ +import "server-only"; + +// RP-Konfiguration wird direkt aus APP_URL/SITE_TITLE abgeleitet – kein +// externer Konfigurationsschritt mehr nötig (ersetzt das bisherige +// scripts/configure-passkeys.mjs, das die Supabase-Management-API +// aufrief). +function appUrl(): URL { + return new URL(process.env.APP_URL ?? "http://localhost:3000"); +} + +export function rpID(): string { + return appUrl().hostname; +} + +export function origin(): string { + return appUrl().origin; +} + +export function rpName(): string { + return process.env.SITE_TITLE?.trim() || "Canteen Menu"; +} diff --git a/lib/db/index.ts b/lib/db/index.ts new file mode 100644 index 0000000..4b0cbce --- /dev/null +++ b/lib/db/index.ts @@ -0,0 +1,36 @@ +import "server-only"; +import type { PoolClient, QueryResultRow } from "pg"; +import { pool } from "./pool"; + +export async function query( + text: string, + params?: unknown[] +): Promise { + const result = await pool.query(text, params); + return result.rows; +} + +export async function queryOne( + text: string, + params?: unknown[] +): Promise { + const rows = await query(text, params); + return rows[0] ?? null; +} + +export async function withTransaction( + fn: (client: PoolClient) => Promise +): Promise { + const client = await pool.connect(); + try { + await client.query("begin"); + const result = await fn(client); + await client.query("commit"); + return result; + } catch (err) { + await client.query("rollback"); + throw err; + } finally { + client.release(); + } +} diff --git a/lib/db/pool.ts b/lib/db/pool.ts new file mode 100644 index 0000000..b9fdeb8 --- /dev/null +++ b/lib/db/pool.ts @@ -0,0 +1,17 @@ +import "server-only"; +import { Pool } from "pg"; + +// Next.js' Dev-Server lädt Module bei Hot-Reloads neu; ohne den +// globalThis-Cache würde bei jedem Speichern ein neuer Pool (und damit +// neue offene Verbindungen) entstehen. +declare global { + var __pgPool: Pool | undefined; +} + +export const pool = + globalThis.__pgPool ?? + new Pool({ connectionString: process.env.DATABASE_URL }); + +if (process.env.NODE_ENV !== "production") { + globalThis.__pgPool = pool; +} diff --git a/lib/i18n/dictionaries.ts b/lib/i18n/dictionaries.ts index 27f5792..93037b1 100644 --- a/lib/i18n/dictionaries.ts +++ b/lib/i18n/dictionaries.ts @@ -47,7 +47,6 @@ const de = { passkeyButton: "Mit Passkey anmelden", backToMenu: "← Zur Speisekarte", errorInvalidCredentials: "E-Mail-Adresse oder Passwort ist falsch.", - errorEmailNotConfirmed: "Die E-Mail-Adresse wurde noch nicht bestätigt.", errorPasskeyCancelled: "Passkey-Anmeldung wurde abgebrochen.", errorGeneric: "Anmeldung fehlgeschlagen: {message}", errorPasswordDisabled: @@ -205,8 +204,7 @@ const de = { nameRequired: "Bitte einen Namen angeben.", emailTitle: "E-Mail-Adresse", changeEmail: "E-Mail-Adresse ändern", - emailSent: - "Bestätigungs-E-Mails wurden verschickt. Die neue Adresse wird erst nach Bestätigung des Links aktiv.", + emailChanged: "E-Mail-Adresse geändert.", passwordTitle: "Passwort ändern", currentPassword: "Aktuelles Passwort (zur Verifizierung)", newPassword: "Neues Passwort", @@ -303,7 +301,6 @@ const en: Dict = { passkeyButton: "Sign in with passkey", backToMenu: "← Back to menu", errorInvalidCredentials: "Email address or password is incorrect.", - errorEmailNotConfirmed: "This email address has not been confirmed yet.", errorPasskeyCancelled: "Passkey sign-in was cancelled.", errorGeneric: "Sign-in failed: {message}", errorPasswordDisabled: @@ -461,8 +458,7 @@ const en: Dict = { nameRequired: "Please enter a name.", emailTitle: "Email address", changeEmail: "Change email address", - emailSent: - "Confirmation emails have been sent. The new address becomes active only after confirming the link.", + emailChanged: "Email address changed.", passwordTitle: "Change password", currentPassword: "Current password (for verification)", newPassword: "New password", @@ -557,7 +553,6 @@ const pl: Dict = { passkeyButton: "Zaloguj się kluczem dostępu", backToMenu: "← Powrót do jadłospisu", errorInvalidCredentials: "Nieprawidłowy adres e-mail lub hasło.", - errorEmailNotConfirmed: "Adres e-mail nie został jeszcze potwierdzony.", errorPasskeyCancelled: "Logowanie kluczem dostępu zostało anulowane.", errorGeneric: "Logowanie nie powiodło się: {message}", errorPasswordDisabled: @@ -715,8 +710,7 @@ const pl: Dict = { nameRequired: "Podaj nazwę.", emailTitle: "Adres e-mail", changeEmail: "Zmień adres e-mail", - emailSent: - "Wysłano e-maile potwierdzające. Nowy adres zacznie obowiązywać dopiero po potwierdzeniu linku.", + emailChanged: "Adres e-mail zmieniony.", passwordTitle: "Zmień hasło", currentPassword: "Obecne hasło (do weryfikacji)", newPassword: "Nowe hasło", diff --git a/lib/origin.ts b/lib/origin.ts index 510eed8..0ecd2a8 100644 --- a/lib/origin.ts +++ b/lib/origin.ts @@ -1,8 +1,8 @@ // CSRF-Schutz (Defense-in-Depth) für schreibende Route-Handler: // Browser senden bei Cross-Site-POSTs immer einen Origin-Header – // stimmt er nicht mit dem Host überein, wird die Anfrage abgelehnt. -// (Erste Verteidigungslinie sind bereits die SameSite=Lax-Session- -// Cookies von @supabase/ssr sowie der JSON-Content-Type.) +// (Erste Verteidigungslinie ist bereits das SameSite=Lax-Session-Cookie, +// siehe lib/auth/session.ts, sowie der JSON-Content-Type.) export function isSameOrigin(request: Request): boolean { const origin = request.headers.get("origin"); // Kein Origin-Header: kein Browser-Cross-Site-Kontext (z. B. curl); diff --git a/package-lock.json b/package-lock.json index be4a23a..3bce378 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "canteen-menu", "version": "1.4.0", "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", "@supabase/ssr": "^0.12.3", "@supabase/supabase-js": "^2.110.7", "next": "16.2.10", + "pg": "^8.22.0", "react": "19.2.4", "react-dom": "19.2.4", "server-only": "^0.0.1" @@ -18,6 +21,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -456,6 +460,12 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1086,6 +1096,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1309,6 +1325,174 @@ "node": ">=12.4.0" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz", + "integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1316,6 +1500,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", + "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@supabase/auth-js": { "version": "2.110.7", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.7.tgz", @@ -1746,6 +1955,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -2648,6 +2869,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -5661,6 +5896,95 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5719,6 +6043,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5751,6 +6114,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5800,6 +6181,12 @@ "dev": true, "license": "MIT" }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6215,6 +6602,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -6549,6 +6945,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6898,6 +7312,15 @@ "node": ">=0.10.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 4f01dae..3ecfd81 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,21 @@ "version": "1.4.0", "private": true, "scripts": { + "predev": "node db/migrate.mjs && node scripts/bootstrap-admin.mjs", "dev": "next dev -H 0.0.0.0", "build": "next build", + "prestart": "node db/migrate.mjs && node scripts/bootstrap-admin.mjs", "start": "next start -H 0.0.0.0", - "lint": "eslint" + "lint": "eslint", + "db:migrate": "node db/migrate.mjs" }, "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", "@supabase/ssr": "^0.12.3", "@supabase/supabase-js": "^2.110.7", "next": "16.2.10", + "pg": "^8.22.0", "react": "19.2.4", "react-dom": "19.2.4", "server-only": "^0.0.1" @@ -19,6 +25,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/proxy.ts b/proxy.ts index 734d90f..c53002c 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,36 +1,36 @@ -import { createServerClient } from "@supabase/ssr"; +import { createHash } from "node:crypto"; import { NextResponse, type NextRequest } from "next/server"; +import { Pool } from "pg"; // Session-Refresh + Zugriffsschutz für die internen Bereiche. +// Next 16 führt Proxy standardmäßig im Node.js-Runtime aus, direkter +// pg-Zugriff ist hier also möglich (kein Edge-Workaround nötig). +// +// Der Cookie-Name ist hier bewusst dupliziert statt aus lib/auth/session +// importiert: dieses Modul zieht next/headers (cookies()) nach, das +// außerhalb von Server Components/Route Handlern/Server Actions nicht +// nutzbar ist – Proxy läuft in einem eigenen Ausführungskontext. +const SESSION_COOKIE = "session"; + +declare global { + var __pgPoolProxy: Pool | undefined; +} +const pool = + globalThis.__pgPoolProxy ?? + new Pool({ connectionString: process.env.DATABASE_URL }); +if (process.env.NODE_ENV !== "production") globalThis.__pgPoolProxy = pool; + +const REFRESH_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; +const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + export async function proxy(request: NextRequest) { - let response = NextResponse.next({ request }); + const token = request.cookies.get(SESSION_COOKIE)?.value; - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return request.cookies.getAll(); - }, - setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value }) => - request.cookies.set(name, value) - ); - response = NextResponse.next({ request }); - cookiesToSet.forEach(({ name, value, options }) => - response.cookies.set(name, value, options) - ); - }, - }, - } - ); - - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { + function redirectToLogin() { // Absolute URL aus dem Host-Header des Browsers bauen: request.nextUrl // trägt bei "-H 0.0.0.0" die Server-Bindung statt des aufgerufenen // Hosts, und relative Location-Header lehnt die Middleware ab. @@ -41,6 +41,36 @@ export async function proxy(request: NextRequest) { return NextResponse.redirect(`${proto}://${host}/login`, 303); } + if (!token) return redirectToLogin(); + + const tokenHash = hashToken(token); + const { rows } = await pool.query<{ expires_at: Date }>( + "select expires_at from sessions where token_hash = $1 and expires_at > now()", + [tokenHash] + ); + const session = rows[0]; + if (!session) return redirectToLogin(); + + const response = NextResponse.next(); + + // Sliding-Refresh: bei weniger als 7 Tagen Restlaufzeit Sitzung und + // Cookie verlängern, statt bei jedem Request zu schreiben. + const msRemaining = session.expires_at.getTime() - Date.now(); + if (msRemaining < REFRESH_THRESHOLD_MS) { + const newExpiry = new Date(Date.now() + SESSION_LIFETIME_MS); + await pool.query("update sessions set expires_at = $1, last_seen_at = now() where token_hash = $2", [ + newExpiry, + tokenHash, + ]); + response.cookies.set(SESSION_COOKIE, token, { + httpOnly: true, + secure: (process.env.APP_URL ?? "").startsWith("https://"), + sameSite: "lax", + path: "/", + expires: newExpiry, + }); + } + return response; } diff --git a/scripts/bootstrap-admin.mjs b/scripts/bootstrap-admin.mjs new file mode 100644 index 0000000..a0ffdc7 --- /dev/null +++ b/scripts/bootstrap-admin.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// Legt beim allerersten Start (leere users-Tabelle) genau einen +// Admin-Benutzer mit zufällig generiertem Passwort an und hängt die +// Zugangsdaten an ZUGANGSDATEN.md an. Idempotent: bei bereits +// vorhandenem Benutzer passiert nichts. +// +// Eigenständiges Skript statt Import aus lib/auth/password.ts, damit +// dieses Vor-Build-Skript nicht an Next's Build-Output gekoppelt ist. +// Die Hash-Logik ist bewusst identisch zur kanonischen Implementierung +// in lib/auth/password.ts gehalten – Änderungen dort auch hier nachziehen. + +import { appendFileSync, existsSync } from "node:fs"; +import { randomBytes, scryptSync } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import pg from "pg"; + +const N = 16384; +const r = 8; +const p = 1; +const KEY_LEN = 64; +const MAX_MEM = 32 * 1024 * 1024; + +function hashPassword(plain) { + const salt = randomBytes(16); + const hash = scryptSync(plain, salt, KEY_LEN, { N, r, p, maxmem: MAX_MEM }); + return `scrypt$${N}$${r}$${p}$${salt.toString("hex")}$${hash.toString("hex")}`; +} + +function defaultAdminEmail() { + if (process.env.ADMIN_EMAIL) return process.env.ADMIN_EMAIL; + try { + const host = new URL(process.env.APP_URL ?? "http://localhost").hostname; + return `admin@${host}`; + } catch { + return "admin@localhost"; + } +} + +async function main() { + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + + try { + const { rows } = await client.query("select count(*)::int as count from users"); + if (rows[0].count > 0) { + console.log("bootstrap-admin – Admin bereits vorhanden, überspringe."); + return; + } + + const email = defaultAdminEmail(); + const password = randomBytes(18).toString("base64url"); + const passwordHash = hashPassword(password); + + await client.query( + `insert into users (email, password_hash, full_name, role, password_auth_enabled) + values ($1, $2, '', 'admin', true)`, + [email, passwordHash] + ); + + const zugangsdatenPath = join( + dirname(dirname(fileURLToPath(import.meta.url))), + "ZUGANGSDATEN.md" + ); + const entry = `\n## Admin-Zugang (automatisch angelegt, ${new Date().toISOString().slice(0, 10)})\n\n- E-Mail: \`${email}\`\n- Passwort: \`${password}\`\n\nBitte nach dem ersten Login ändern.\n`; + if (existsSync(zugangsdatenPath)) { + appendFileSync(zugangsdatenPath, entry); + } else { + console.warn( + `bootstrap-admin – ${zugangsdatenPath} existiert nicht, Zugangsdaten werden nur geloggt:` + ); + } + console.log(`bootstrap-admin – Admin angelegt: ${email} (Passwort siehe ZUGANGSDATEN.md)`); + } finally { + await client.end(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From a9a9cf7ba4ea1344faec002d9c872586d95aae58 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 18 Jul 2026 11:01:52 +0200 Subject: [PATCH 2/7] v1.5: Alle Manager-Komponenten auf eigene API-Routen umgestellt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/admin/layout.tsx | 19 +- app/admin/page.tsx | 6 +- app/api/additives/[code]/route.ts | 47 ++ app/api/additives/route.ts | 27 + app/api/admin/branding/route.ts | 76 +++ app/api/admin/users/route.ts | 138 +++-- app/api/allergens/[code]/route.ts | 47 ++ app/api/allergens/route.ts | 27 + app/api/dish-suggestions/[id]/accept/route.ts | 37 ++ app/api/dish-suggestions/[id]/route.ts | 23 + app/api/dish-suggestions/route.ts | 45 ++ app/api/dishes/[id]/route.ts | 57 ++ app/api/dishes/bulk-delete/route.ts | 23 + app/api/dishes/route.ts | 37 ++ app/api/menu-entries/[id]/route.ts | 23 + app/api/menu-entries/route.ts | 41 ++ app/api/sections/[key]/route.ts | 62 +++ app/api/sections/route.ts | 39 ++ app/api/settings/route.ts | 25 + app/api/translate/route.ts | 41 +- app/branding/[filename]/route.ts | 32 ++ app/globals.css | 2 +- app/intern/layout.tsx | 20 +- app/konto/page.tsx | 28 +- app/layout.tsx | 17 +- app/page.tsx | 36 +- components/CodeListManager.tsx | 48 +- components/DesignEditor.tsx | 80 ++- components/DishManager.tsx | 60 ++- components/SectionManager.tsx | 71 ++- components/SuggestionForm.tsx | 31 +- components/SuggestionReview.tsx | 45 +- components/UserManager.tsx | 26 +- components/WeekEditor.tsx | 56 +- lib/auth/session.ts | 21 + lib/branding.ts | 48 ++ lib/db/code-lists.ts | 41 ++ lib/db/menu-entries.ts | 48 ++ lib/db/pool.ts | 13 +- lib/db/settings.ts | 14 + lib/i18n/dictionaries.ts | 6 +- lib/supabase/admin.ts | 14 - lib/supabase/client.ts | 15 - lib/supabase/server.ts | 30 -- lib/types.ts | 4 +- scripts/configure-passkeys.mjs | 47 -- scripts/db-apply.mjs | 32 -- scripts/sync-local-db.ps1 | 47 -- supabase/local/00_stubs.sql | 58 -- supabase/local/10_schema.sql | 502 ------------------ supabase/local/15_auth_users.sql | 6 - supabase/local/20_data.sql | 212 -------- supabase/migrations/0001_init.sql | 217 -------- supabase/migrations/0002_seed.sql | 114 ---- .../migrations/0003_account_self_service.sql | 36 -- .../0004_dish_labels_login_toggle_favicon.sql | 32 -- .../migrations/0005_i18n_and_suggestions.sql | 68 --- .../migrations/0006_variable_sections.sql | 25 - 58 files changed, 1184 insertions(+), 1858 deletions(-) create mode 100644 app/api/additives/[code]/route.ts create mode 100644 app/api/additives/route.ts create mode 100644 app/api/admin/branding/route.ts create mode 100644 app/api/allergens/[code]/route.ts create mode 100644 app/api/allergens/route.ts create mode 100644 app/api/dish-suggestions/[id]/accept/route.ts create mode 100644 app/api/dish-suggestions/[id]/route.ts create mode 100644 app/api/dish-suggestions/route.ts create mode 100644 app/api/dishes/[id]/route.ts create mode 100644 app/api/dishes/bulk-delete/route.ts create mode 100644 app/api/dishes/route.ts create mode 100644 app/api/menu-entries/[id]/route.ts create mode 100644 app/api/menu-entries/route.ts create mode 100644 app/api/sections/[key]/route.ts create mode 100644 app/api/sections/route.ts create mode 100644 app/api/settings/route.ts create mode 100644 app/branding/[filename]/route.ts create mode 100644 lib/branding.ts create mode 100644 lib/db/code-lists.ts create mode 100644 lib/db/menu-entries.ts create mode 100644 lib/db/settings.ts delete mode 100644 lib/supabase/admin.ts delete mode 100644 lib/supabase/client.ts delete mode 100644 lib/supabase/server.ts delete mode 100644 scripts/configure-passkeys.mjs delete mode 100644 scripts/db-apply.mjs delete mode 100644 scripts/sync-local-db.ps1 delete mode 100644 supabase/local/00_stubs.sql delete mode 100644 supabase/local/10_schema.sql delete mode 100644 supabase/local/15_auth_users.sql delete mode 100644 supabase/local/20_data.sql delete mode 100644 supabase/migrations/0001_init.sql delete mode 100644 supabase/migrations/0002_seed.sql delete mode 100644 supabase/migrations/0003_account_self_service.sql delete mode 100644 supabase/migrations/0004_dish_labels_login_toggle_favicon.sql delete mode 100644 supabase/migrations/0005_i18n_and_suggestions.sql delete mode 100644 supabase/migrations/0006_variable_sections.sql diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 19b61ea..47bc290 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; +import { getSession } from "@/lib/auth/session"; import AppHeader from "@/components/AppHeader"; export default async function AdminLayout({ @@ -7,24 +7,15 @@ export default async function AdminLayout({ }: { 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"); + const session = await getSession(); + if (!session) redirect("/login"); + if (session.user.role !== "admin") redirect("/intern"); return (
    {children} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 3f65034..c1052ba 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,5 +1,7 @@ +import { getSession } from "@/lib/auth/session"; import UserManager from "@/components/UserManager"; -export default function AdminPage() { - return ; +export default async function AdminPage() { + const session = await getSession(); + return ; } diff --git a/app/api/additives/[code]/route.ts b/app/api/additives/[code]/route.ts new file mode 100644 index 0000000..7b49ac9 --- /dev/null +++ b/app/api/additives/[code]/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { deleteCode, updateCode } from "@/lib/db/code-lists"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ code: string }> } +) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const { code } = await params; + const body = await request.json(); + const item = await updateCode("additives", code, body); + if (!item) return NextResponse.json({ error: "not_found" }, { status: 404 }); + return NextResponse.json(item); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + if (err instanceof Error && /duplicate key/i.test(err.message)) { + return NextResponse.json({ error: "duplicate_code" }, { status: 409 }); + } + throw err; + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ code: string }> } +) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const { code } = await params; + await deleteCode("additives", code); + return NextResponse.json({ ok: true }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/additives/route.ts b/app/api/additives/route.ts new file mode 100644 index 0000000..80d5f25 --- /dev/null +++ b/app/api/additives/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { createCode, listCodes } from "@/lib/db/code-lists"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +export async function GET() { + return NextResponse.json(await listCodes("additives")); +} + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const body = await request.json(); + const item = await createCode("additives", body); + return NextResponse.json(item, { status: 201 }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + if (err instanceof Error && /duplicate key/i.test(err.message)) { + return NextResponse.json({ error: "duplicate_code" }, { status: 409 }); + } + throw err; + } +} diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts new file mode 100644 index 0000000..ed7afb0 --- /dev/null +++ b/app/api/admin/branding/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { queryOne, query } from "@/lib/db"; +import { removeBrandingFile, saveBrandingFile } from "@/lib/branding"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +type AssetKind = "logo" | "favicon"; +const ASSET_COLUMN: Record = { + logo: "logo_path", + favicon: "favicon_path", +}; + +function isAssetKind(value: unknown): value is AssetKind { + return value === "logo" || value === "favicon"; +} + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["admin"]); + const form = await request.formData(); + const kind = form.get("kind"); + const file = form.get("file"); + if (!isAssetKind(kind) || !(file instanceof File)) { + return NextResponse.json({ error: "invalid_input" }, { status: 400 }); + } + if (!file.type.startsWith("image/")) { + return NextResponse.json({ error: "invalid_image" }, { status: 400 }); + } + + const old = await queryOne<{ path: string | null }>( + `select ${ASSET_COLUMN[kind]} as path from settings where id = 1` + ); + + const ext = file.name.split(".").pop()?.toLowerCase() ?? "png"; + const filename = `${kind}-${Date.now()}.${ext}`; + const buffer = Buffer.from(await file.arrayBuffer()); + await saveBrandingFile(filename, buffer); + + await query(`update settings set ${ASSET_COLUMN[kind]} = $1 where id = 1`, [filename]); + if (old?.path) await removeBrandingFile(old.path); + + return NextResponse.json({ path: filename }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} + +export async function DELETE(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["admin"]); + const { kind } = (await request.json()) as { kind?: unknown }; + if (!isAssetKind(kind)) { + return NextResponse.json({ error: "invalid_input" }, { status: 400 }); + } + + const old = await queryOne<{ path: string | null }>( + `select ${ASSET_COLUMN[kind]} as path from settings where id = 1` + ); + await query(`update settings set ${ASSET_COLUMN[kind]} = null where id = 1`); + if (old?.path) await removeBrandingFile(old.path); + + return NextResponse.json({ ok: true }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index dbd820f..56064db 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -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( + "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 = {}; - 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 }); } diff --git a/app/api/allergens/[code]/route.ts b/app/api/allergens/[code]/route.ts new file mode 100644 index 0000000..96fd547 --- /dev/null +++ b/app/api/allergens/[code]/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { deleteCode, updateCode } from "@/lib/db/code-lists"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ code: string }> } +) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const { code } = await params; + const body = await request.json(); + const item = await updateCode("allergens", code, body); + if (!item) return NextResponse.json({ error: "not_found" }, { status: 404 }); + return NextResponse.json(item); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + if (err instanceof Error && /duplicate key/i.test(err.message)) { + return NextResponse.json({ error: "duplicate_code" }, { status: 409 }); + } + throw err; + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ code: string }> } +) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const { code } = await params; + await deleteCode("allergens", code); + return NextResponse.json({ ok: true }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/allergens/route.ts b/app/api/allergens/route.ts new file mode 100644 index 0000000..0b563d5 --- /dev/null +++ b/app/api/allergens/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { createCode, listCodes } from "@/lib/db/code-lists"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +export async function GET() { + return NextResponse.json(await listCodes("allergens")); +} + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const body = await request.json(); + const item = await createCode("allergens", body); + return NextResponse.json(item, { status: 201 }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + if (err instanceof Error && /duplicate key/i.test(err.message)) { + return NextResponse.json({ error: "duplicate_code" }, { status: 409 }); + } + throw err; + } +} diff --git a/app/api/dish-suggestions/[id]/accept/route.ts b/app/api/dish-suggestions/[id]/accept/route.ts new file mode 100644 index 0000000..e0e250c --- /dev/null +++ b/app/api/dish-suggestions/[id]/accept/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { withTransaction } from "@/lib/db"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const { id } = await params; + + const created = await withTransaction(async (client) => { + const suggestion = await client.query<{ name: string }>( + "select name from dish_suggestions where id = $1", + [id] + ); + if (suggestion.rows.length === 0) return null; + await client.query("insert into dishes (name, price) values ($1, 0)", [ + suggestion.rows[0].name, + ]); + await client.query("delete from dish_suggestions where id = $1", [id]); + return true; + }); + + if (!created) return NextResponse.json({ error: "not_found" }, { status: 404 }); + return NextResponse.json({ ok: true }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/dish-suggestions/[id]/route.ts b/app/api/dish-suggestions/[id]/route.ts new file mode 100644 index 0000000..b81aaf6 --- /dev/null +++ b/app/api/dish-suggestions/[id]/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { query } from "@/lib/db"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const { id } = await params; + await query("delete from dish_suggestions where id = $1", [id]); + return NextResponse.json({ ok: true }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/dish-suggestions/route.ts b/app/api/dish-suggestions/route.ts new file mode 100644 index 0000000..344617a --- /dev/null +++ b/app/api/dish-suggestions/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { query, queryOne } from "@/lib/db"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; +import type { DishSuggestion } from "@/lib/types"; + +export async function GET() { + try { + await requireRole(["staff", "admin"]); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } + const items = await query( + "select * from dish_suggestions order by created_at desc" + ); + return NextResponse.json(items); +} + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + const body = (await request.json().catch(() => ({}))) as { + name?: string; + description?: string; + }; + const name = body.name?.trim(); + if (!name) return NextResponse.json({ error: "name_required" }, { status: 400 }); + + // Serverseitig statt clientseitig (wie zuvor über den anon-Postgrest- + // Zugriff): es gibt keinen Browser→Postgres-Pfad mehr. + const existing = await queryOne<{ id: string }>( + "select id from dishes where name ilike $1 limit 1", + [name] + ); + if (existing) return NextResponse.json({ error: "duplicate" }, { status: 409 }); + + await query("insert into dish_suggestions (name, description) values ($1, $2)", [ + name, + body.description?.trim() || null, + ]); + return NextResponse.json({ ok: true }, { status: 201 }); +} diff --git a/app/api/dishes/[id]/route.ts b/app/api/dishes/[id]/route.ts new file mode 100644 index 0000000..1a6f59d --- /dev/null +++ b/app/api/dishes/[id]/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { query, queryOne } from "@/lib/db"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; +import type { Dish } from "@/lib/types"; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const { id } = await params; + const body = (await request.json()) as { + name: string; + name_en: string | null; + name_pl: string | null; + price: number; + allergens: string[]; + additives: string[]; + }; + const dish = await queryOne( + `update dishes set name = $1, name_en = $2, name_pl = $3, price = $4, + allergens = $5, additives = $6 + where id = $7 returning *`, + [body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives, id] + ); + if (!dish) return NextResponse.json({ error: "not_found" }, { status: 404 }); + return NextResponse.json(dish); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const { id } = await params; + await query("delete from dishes where id = $1", [id]); + return NextResponse.json({ ok: true }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/dishes/bulk-delete/route.ts b/app/api/dishes/bulk-delete/route.ts new file mode 100644 index 0000000..c794a25 --- /dev/null +++ b/app/api/dishes/bulk-delete/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { query } from "@/lib/db"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const { ids } = (await request.json()) as { ids: string[] }; + if (!Array.isArray(ids) || ids.length === 0) { + return NextResponse.json({ error: "invalid_input" }, { status: 400 }); + } + await query("delete from dishes where id = any($1::uuid[])", [ids]); + return NextResponse.json({ ok: true }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/dishes/route.ts b/app/api/dishes/route.ts new file mode 100644 index 0000000..caf0c7c --- /dev/null +++ b/app/api/dishes/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { query, queryOne } from "@/lib/db"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; +import type { Dish } from "@/lib/types"; + +export async function GET() { + const dishes = await query("select * from dishes order by name"); + return NextResponse.json(dishes); +} + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const body = (await request.json()) as { + name: string; + name_en: string | null; + name_pl: string | null; + price: number; + allergens: string[]; + additives: string[]; + }; + const dish = await queryOne( + `insert into dishes (name, name_en, name_pl, price, allergens, additives) + values ($1, $2, $3, $4, $5, $6) returning *`, + [body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives] + ); + return NextResponse.json(dish, { status: 201 }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/menu-entries/[id]/route.ts b/app/api/menu-entries/[id]/route.ts new file mode 100644 index 0000000..7ff2d4e --- /dev/null +++ b/app/api/menu-entries/[id]/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { query } from "@/lib/db"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const { id } = await params; + await query("delete from menu_entries where id = $1", [id]); + return NextResponse.json({ ok: true }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/menu-entries/route.ts b/app/api/menu-entries/route.ts new file mode 100644 index 0000000..db1c34f --- /dev/null +++ b/app/api/menu-entries/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { queryOne } from "@/lib/db"; +import { loadMenuEntries } from "@/lib/db/menu-entries"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const from = searchParams.get("from"); + const to = searchParams.get("to"); + if (!from || !to) { + return NextResponse.json({ error: "invalid_input" }, { status: 400 }); + } + return NextResponse.json(await loadMenuEntries(from, to)); +} + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const body = (await request.json()) as { + menu_date: string; + section_key: string; + dish_id: string; + }; + const entry = await queryOne<{ id: string }>( + "insert into menu_entries (menu_date, section_key, dish_id) values ($1, $2, $3) returning id", + [body.menu_date, body.section_key, body.dish_id] + ); + return NextResponse.json(entry, { status: 201 }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + if (err instanceof Error && /duplicate key/i.test(err.message)) { + return NextResponse.json({ error: "duplicate_entry" }, { status: 409 }); + } + throw err; + } +} diff --git a/app/api/sections/[key]/route.ts b/app/api/sections/[key]/route.ts new file mode 100644 index 0000000..d0e0ab2 --- /dev/null +++ b/app/api/sections/[key]/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { query, queryOne } from "@/lib/db"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; +import type { Section } from "@/lib/types"; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ key: string }> } +) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const { key } = await params; + const body = (await request.json()) as Partial< + Pick + >; + + const fields: string[] = []; + const values: unknown[] = []; + for (const col of ["label", "label_en", "label_pl", "time_range", "sort"] as const) { + if (body[col] !== undefined) { + values.push(body[col]); + fields.push(`${col} = $${values.length}`); + } + } + if (fields.length === 0) return NextResponse.json({ error: "empty_update" }, { status: 400 }); + values.push(key); + + const section = await queryOne
    ( + `update sections set ${fields.join(", ")} where key = $${values.length} returning *`, + values + ); + if (!section) return NextResponse.json({ error: "not_found" }, { status: 404 }); + return NextResponse.json(section); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ key: string }> } +) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const { key } = await params; + await query("delete from sections where key = $1", [key]); + return NextResponse.json({ ok: true }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/sections/route.ts b/app/api/sections/route.ts new file mode 100644 index 0000000..87763c4 --- /dev/null +++ b/app/api/sections/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { query, queryOne } from "@/lib/db"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; +import type { Section } from "@/lib/types"; + +export async function GET() { + const sections = await query
    ("select * from sections order by sort"); + return NextResponse.json(sections); +} + +export async function POST(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["staff", "admin"]); + const body = (await request.json()) as { + label: string; + label_en: string | null; + label_pl: string | null; + time_range: string; + }; + + const maxRow = await queryOne<{ sort: number }>( + "select sort from sections order by sort desc limit 1" + ); + const section = await queryOne
    ( + `insert into sections (label, label_en, label_pl, time_range, sort) + values ($1, $2, $3, $4, $5) returning *`, + [body.label, body.label_en, body.label_pl, body.time_range, (maxRow?.sort ?? 0) + 1] + ); + return NextResponse.json(section, { status: 201 }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..c3ddb9b --- /dev/null +++ b/app/api/settings/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { query } from "@/lib/db"; +import { getSettings } from "@/lib/db/settings"; +import { authErrorResponse, requireRole } from "@/lib/auth/session"; +import { isSameOrigin } from "@/lib/origin"; + +export async function GET() { + return NextResponse.json(await getSettings()); +} + +export async function PATCH(request: Request) { + if (!isSameOrigin(request)) { + return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); + } + try { + await requireRole(["admin"]); + const { colors } = (await request.json()) as { colors: Record }; + await query("update settings set colors = $1 where id = 1", [JSON.stringify(colors)]); + return NextResponse.json({ ok: true }); + } catch (err) { + const res = authErrorResponse(err); + if (res) return res; + throw err; + } +} diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts index f0c916c..455c53d 100644 --- a/app/api/translate/route.ts +++ b/app/api/translate/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { createClient } from "@/lib/supabase/server"; -import { createAdminClient } from "@/lib/supabase/admin"; +import { query } from "@/lib/db"; +import { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session"; import { isSameOrigin } from "@/lib/origin"; // Übersetzt einen deutschen Text nach Englisch/Polnisch via DeepL und @@ -43,20 +43,16 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 }); } - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); - - const admin = createAdminClient(); - const { data: profile } = await admin - .from("profiles") - .select("role") - .eq("id", user.id) - .single(); - if (!profile || !["staff", "admin"].includes(profile.role)) { - return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); + try { + await requireRole(["staff", "admin"]); + } catch (err) { + if (err instanceof UnauthorizedError) { + return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + } + if (err instanceof ForbiddenError) { + return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); + } + throw err; } const body = await request.json().catch(() => null); @@ -83,11 +79,14 @@ export async function POST(request: Request) { if (!en || !pl) throw new Error("Keine Übersetzung erhalten."); if (persist) { - const { error } = await admin - .from(table) - .update({ [`${config.textColumn}_en`]: en, [`${config.textColumn}_pl`]: pl }) - .eq(config.idColumn, id); - if (error) throw new Error(error.message); + // table/idColumn/textColumn kommen ausschließlich aus dem oben + // geprüften ALLOWED_TABLES-Whitelist, nie aus Nutzereingaben – + // sicher für die String-Interpolation in der Query. + await query( + `update ${table} set ${config.textColumn}_en = $1, ${config.textColumn}_pl = $2 + where ${config.idColumn} = $3`, + [en, pl, id] + ); } return NextResponse.json({ configured: true, en, pl }); diff --git a/app/branding/[filename]/route.ts b/app/branding/[filename]/route.ts new file mode 100644 index 0000000..9b0ca80 --- /dev/null +++ b/app/branding/[filename]/route.ts @@ -0,0 +1,32 @@ +import { readBrandingFile } from "@/lib/branding"; + +const CONTENT_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + webp: "image/webp", + svg: "image/svg+xml", + ico: "image/x-icon", + gif: "image/gif", +}; + +export async function GET( + request: Request, + { params }: { params: Promise<{ filename: string }> } +) { + const { filename } = await params; + const data = await readBrandingFile(filename); + if (!data) return new Response("Not found", { status: 404 }); + + const ext = filename.split(".").pop()?.toLowerCase() ?? ""; + const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; + + return new Response(new Uint8Array(data), { + headers: { + "Content-Type": contentType, + // Dateinamen tragen bereits einen Date.now()-Suffix, ein neuer + // Upload bekommt also automatisch einen neuen Namen. + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +} diff --git a/app/globals.css b/app/globals.css index f378cda..1abad04 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,7 +1,7 @@ @import "tailwindcss"; /* Farb-Variablen: Defaults entsprechen r-hofmann.de; werden zur Laufzeit - aus den Einstellungen (Supabase settings.colors) überschrieben. */ + aus den Einstellungen (settings.colors) überschrieben. */ :root { --c-primary: #003388; --c-primary-dark: #0d315c; diff --git a/app/intern/layout.tsx b/app/intern/layout.tsx index 79bfa9d..718028f 100644 --- a/app/intern/layout.tsx +++ b/app/intern/layout.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; +import { getSession } from "@/lib/auth/session"; import AppHeader from "@/components/AppHeader"; export default async function InternLayout({ @@ -7,24 +7,14 @@ export default async function InternLayout({ }: { 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 || !["staff", "admin"].includes(profile.role)) redirect("/"); + const session = await getSession(); + if (!session) redirect("/login"); return (
    {children} diff --git a/app/konto/page.tsx b/app/konto/page.tsx index 75fb402..1420eba 100644 --- a/app/konto/page.tsx +++ b/app/konto/page.tsx @@ -1,34 +1,24 @@ import { redirect } from "next/navigation"; -import { createClient } from "@/lib/supabase/server"; +import { getSession } from "@/lib/auth/session"; import AppHeader from "@/components/AppHeader"; 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, password_auth_enabled") - .eq("id", user.id) - .single(); - if (!profile || !["staff", "admin"].includes(profile.role)) redirect("/"); + const session = await getSession(); + if (!session) redirect("/login"); return (
    diff --git a/app/layout.tsx b/app/layout.tsx index 868f023..171e108 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { Geist } from "next/font/google"; import "./globals.css"; -import { createClient } from "@/lib/supabase/server"; +import { getSettings } from "@/lib/db/settings"; import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types"; import { getDictionary, getLocale } from "@/lib/i18n"; import { LocaleProvider } from "@/components/i18n/LocaleProvider"; @@ -16,12 +16,7 @@ const geistSans = Geist({ }); export async function generateMetadata(): Promise { - const supabase = await createClient(); - const { data: settings } = await supabase - .from("settings") - .select("favicon_path") - .eq("id", 1) - .maybeSingle(); + const settings = await getSettings(); return { title: SITE_TITLE, @@ -38,16 +33,12 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const supabase = await createClient(); - const [{ data: settings }, locale] = await Promise.all([ - supabase.from("settings").select("colors").eq("id", 1).maybeSingle(), - getLocale(), - ]); + const [settings, locale] = await Promise.all([getSettings(), getLocale()]); const dict = getDictionary(locale); const colors: Record = { ...DEFAULT_COLORS, - ...((settings?.colors as Record) ?? {}), + ...(settings?.colors ?? {}), }; const style = Object.fromEntries( Object.entries(COLOR_VARS).map(([key, cssVar]) => [cssVar, colors[key]]) diff --git a/app/page.tsx b/app/page.tsx index b326795..1948bc4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,7 @@ import Link from "next/link"; -import { createClient } from "@/lib/supabase/server"; +import { query } from "@/lib/db"; +import { loadMenuEntries } from "@/lib/db/menu-entries"; +import { getSettings } from "@/lib/db/settings"; import { addDays, dishCodes, @@ -11,7 +13,7 @@ import { } from "@/lib/menu"; import { getDictionary, getLocale } from "@/lib/i18n"; import { localized, logoUrl, SITE_TITLE } from "@/lib/types"; -import type { CodeItem, MenuEntry, Section } from "@/lib/types"; +import type { CodeItem, Section } from "@/lib/types"; import PublicMobileMenu from "@/components/PublicMobileMenu"; import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; @@ -31,29 +33,15 @@ export default async function Home({ iso: addDays(monday, i), })); - const supabase = await createClient(); - const [sectionsRes, entriesRes, allergensRes, additivesRes, settingsRes] = - await Promise.all([ - supabase.from("sections").select("*").order("sort"), - supabase - .from("menu_entries") - .select( - "id, menu_date, section_key, position, dish:dishes(id, name, name_en, name_pl, price, allergens, additives)" - ) - .gte("menu_date", monday) - .lte("menu_date", friday) - .order("position") - .order("created_at"), - supabase.from("allergens").select("*").order("sort"), - supabase.from("additives").select("*").order("sort"), - supabase.from("settings").select("logo_path").eq("id", 1).maybeSingle(), - ]); + const [sections, entries, allergens, additives, settings] = await Promise.all([ + query
    ("select * from sections order by sort"), + loadMenuEntries(monday, friday), + query("select * from allergens order by sort"), + query("select * from additives order by sort"), + getSettings(), + ]); - const sections = (sectionsRes.data ?? []) as Section[]; - const entries = (entriesRes.data ?? []) as unknown as MenuEntry[]; - const allergens = (allergensRes.data ?? []) as CodeItem[]; - const additives = (additivesRes.data ?? []) as CodeItem[]; - const logo = logoUrl(settingsRes.data?.logo_path); + const logo = logoUrl(settings?.logo_path); return (
    diff --git a/components/CodeListManager.tsx b/components/CodeListManager.tsx index 1a677dd..0529ad4 100644 --- a/components/CodeListManager.tsx +++ b/components/CodeListManager.tsx @@ -3,8 +3,7 @@ // Pflege der globalen Allergen-/Zusatzstofflisten, die unterhalb // der öffentlichen Speisekarte angezeigt werden. -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; +import { useCallback, useEffect, useState } from "react"; import { formatMessage } from "@/lib/i18n/shared"; import { localized } from "@/lib/types"; import type { CodeItem } from "@/lib/types"; @@ -17,7 +16,6 @@ export default function CodeListManager({ table: "allergens" | "additives"; translateEnabled: boolean; }) { - const supabase = useMemo(() => createClient(), []); const dict = useDict(); const locale = useLocale(); const title = @@ -41,10 +39,10 @@ export default function CodeListManager({ ); const load = useCallback(async () => { - const { data, error } = await supabase.from(table).select("*").order("sort"); - if (error) setError(formatMessage(dict.codeList.loadError, { message: error.message })); - else setItems(data as CodeItem[]); - }, [supabase, table, dict]); + const res = await fetch(`/api/${table}`); + if (!res.ok) setError(formatMessage(dict.codeList.loadError, { message: String(res.status) })); + else setItems(await res.json()); + }, [table, dict]); useEffect(() => { load(); @@ -64,23 +62,23 @@ export default function CodeListManager({ name_pl: editing.namePl.trim() || null, }; - let dbError; - if (editing.original) { - ({ error: dbError } = await supabase - .from(table) - .update(payload) - .eq("code", editing.original)); - } else { - const maxSort = items.reduce((m, i) => Math.max(m, i.sort), 0); - ({ error: dbError } = await supabase - .from(table) - .insert({ ...payload, sort: maxSort + 1 })); - } - if (dbError) { + const res = editing.original + ? await fetch(`/api/${table}/${editing.original}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + : await fetch(`/api/${table}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); setError( - /duplicate key/i.test(dbError.message) + data.error === "duplicate_code" ? formatMessage(dict.codeList.duplicateCode, { code }) - : formatMessage(dict.codeList.saveError, { message: dbError.message }) + : formatMessage(dict.codeList.saveError, { message: data.error ?? String(res.status) }) ); return; } @@ -95,9 +93,9 @@ export default function CodeListManager({ ) ) return; - const { error } = await supabase.from(table).delete().eq("code", item.code); - if (error) - setError(formatMessage(dict.codeList.deleteError, { message: error.message })); + const res = await fetch(`/api/${table}/${item.code}`, { method: "DELETE" }); + if (!res.ok) + setError(formatMessage(dict.codeList.deleteError, { message: String(res.status) })); await load(); } diff --git a/components/DesignEditor.tsx b/components/DesignEditor.tsx index 11b83c5..8c71063 100644 --- a/components/DesignEditor.tsx +++ b/components/DesignEditor.tsx @@ -1,21 +1,14 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { createClient } from "@/lib/supabase/client"; import { formatMessage } from "@/lib/i18n/shared"; import { DEFAULT_COLORS, faviconUrl, logoUrl } from "@/lib/types"; import { useDict } from "@/components/i18n/LocaleProvider"; type AssetKind = "logo" | "favicon"; -const ASSET_COLUMN: Record = { - logo: "logo_path", - favicon: "favicon_path", -}; - export default function DesignEditor() { - const supabase = useMemo(() => createClient(), []); const router = useRouter(); const dict = useDict(); const colorLabels: Record = { @@ -36,19 +29,16 @@ export default function DesignEditor() { const [busy, setBusy] = useState(false); const load = useCallback(async () => { - const { data, error } = await supabase - .from("settings") - .select("colors, logo_path, favicon_path") - .eq("id", 1) - .maybeSingle(); - if (error) { - setError(formatMessage(dict.designEditor.loadError, { message: error.message })); + const res = await fetch("/api/settings"); + if (!res.ok) { + setError(formatMessage(dict.designEditor.loadError, { message: String(res.status) })); return; } + const data = await res.json(); setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) }); setLogoPath(data?.logo_path ?? null); setFaviconPath(data?.favicon_path ?? null); - }, [supabase, dict]); + }, [dict]); useEffect(() => { load(); @@ -58,12 +48,13 @@ export default function DesignEditor() { setBusy(true); setError(null); setInfo(null); - const { error } = await supabase - .from("settings") - .update({ colors }) - .eq("id", 1); - if (error) - setError(formatMessage(dict.designEditor.saveError, { message: error.message })); + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ colors }), + }); + if (!res.ok) + setError(formatMessage(dict.designEditor.saveError, { message: String(res.status) })); else { setInfo(dict.designEditor.colorsSaved); router.refresh(); @@ -87,28 +78,21 @@ export default function DesignEditor() { setError(null); setInfo(null); - const oldPath = kind === "logo" ? logoPath : faviconPath; - const ext = file.name.split(".").pop()?.toLowerCase() ?? "png"; - const path = `${kind}-${Date.now()}.${ext}`; - const { error: uploadError } = await supabase.storage - .from("branding") - .upload(path, file, { contentType: file.type }); - if (uploadError) { - setError(formatMessage(dict.designEditor.uploadError, { message: uploadError.message })); + const formData = new FormData(); + formData.append("kind", kind); + formData.append("file", file); + const res = await fetch("/api/admin/branding", { method: "POST", body: formData }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError( + formatMessage(dict.designEditor.uploadError, { + message: data.error ?? String(res.status), + }) + ); setBusy(false); return; } - - const { error: settingsError } = await supabase - .from("settings") - .update({ [ASSET_COLUMN[kind]]: path }) - .eq("id", 1); - if (settingsError) { - setError(formatMessage(dict.designEditor.saveError, { message: settingsError.message })); - setBusy(false); - return; - } - if (oldPath) await supabase.storage.from("branding").remove([oldPath]); + const { path } = await res.json(); if (kind === "logo") setLogoPath(path); else setFaviconPath(path); @@ -130,14 +114,14 @@ export default function DesignEditor() { return; setBusy(true); setError(null); - const { error } = await supabase - .from("settings") - .update({ [ASSET_COLUMN[kind]]: null }) - .eq("id", 1); - if (error) { - setError(formatMessage(dict.designEditor.saveError, { message: error.message })); + const res = await fetch("/api/admin/branding", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind }), + }); + if (!res.ok) { + setError(formatMessage(dict.designEditor.saveError, { message: String(res.status) })); } else { - await supabase.storage.from("branding").remove([path]); if (kind === "logo") setLogoPath(null); else setFaviconPath(null); setInfo(kind === "logo" ? dict.designEditor.logoRemoved : dict.designEditor.faviconRemoved); diff --git a/components/DishManager.tsx b/components/DishManager.tsx index cd69b7f..820297a 100644 --- a/components/DishManager.tsx +++ b/components/DishManager.tsx @@ -1,7 +1,6 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; import { dishCodes, formatPrice } from "@/lib/menu"; import { formatMessage } from "@/lib/i18n/shared"; import { localized } from "@/lib/types"; @@ -34,7 +33,6 @@ export default function DishManager({ }: { translateEnabled: boolean; }) { - const supabase = useMemo(() => createClient(), []); const dict = useDict(); const locale = useLocale(); const [dishes, setDishes] = useState([]); @@ -58,20 +56,19 @@ export default function DishManager({ const load = useCallback(async () => { const [dishesRes, allergensRes, additivesRes] = await Promise.all([ - supabase.from("dishes").select("*").order("name"), - supabase.from("allergens").select("*").order("sort"), - supabase.from("additives").select("*").order("sort"), + fetch("/api/dishes"), + fetch("/api/allergens"), + fetch("/api/additives"), ]); - const problem = dishesRes.error ?? allergensRes.error ?? additivesRes.error; - if (problem) - setError(formatMessage(dict.dishManager.loadError, { message: problem.message })); - else { - setDishes(dishesRes.data as Dish[]); - setAllergens(allergensRes.data as CodeItem[]); - setAdditives(additivesRes.data as CodeItem[]); + if (!dishesRes.ok || !allergensRes.ok || !additivesRes.ok) { + setError(formatMessage(dict.dishManager.loadError, { message: "load failed" })); + } else { + setDishes(await dishesRes.json()); + setAllergens(await allergensRes.json()); + setAdditives(await additivesRes.json()); } setLoading(false); - }, [supabase, dict]); + }, [dict]); useEffect(() => { load(); @@ -94,11 +91,19 @@ export default function DishManager({ allergens: form.allergens, additives: form.additives, }; - const { error } = form.id - ? await supabase.from("dishes").update(payload).eq("id", form.id) - : await supabase.from("dishes").insert(payload); - if (error) { - setError(formatMessage(dict.dishManager.saveError, { message: error.message })); + const res = form.id + ? await fetch(`/api/dishes/${form.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + : await fetch("/api/dishes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + setError(formatMessage(dict.dishManager.saveError, { message: String(res.status) })); return; } setForm(null); @@ -112,9 +117,9 @@ export default function DishManager({ ) ) return; - const { error } = await supabase.from("dishes").delete().eq("id", dish.id); - if (error) - setError(formatMessage(dict.dishManager.deleteError, { message: error.message })); + const res = await fetch(`/api/dishes/${dish.id}`, { method: "DELETE" }); + if (!res.ok) + setError(formatMessage(dict.dishManager.deleteError, { message: String(res.status) })); setSelected((prev) => { const next = new Set(prev); next.delete(dish.id); @@ -156,12 +161,13 @@ export default function DishManager({ return; setBulkBusy(true); setError(null); - const { error } = await supabase - .from("dishes") - .delete() - .in("id", Array.from(selected)); - if (error) - setError(formatMessage(dict.dishManager.deleteError, { message: error.message })); + const res = await fetch("/api/dishes/bulk-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: Array.from(selected) }), + }); + if (!res.ok) + setError(formatMessage(dict.dishManager.deleteError, { message: String(res.status) })); setSelected(new Set()); setBulkBusy(false); await load(); diff --git a/components/SectionManager.tsx b/components/SectionManager.tsx index 2c714e5..6ae91ac 100644 --- a/components/SectionManager.tsx +++ b/components/SectionManager.tsx @@ -3,8 +3,7 @@ // Verwaltung der Essenszeiten (Brotzeit, Mittagessen, …): // anlegen, umbenennen, Uhrzeit ändern, löschen, Reihenfolge anpassen. -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; +import { useCallback, useEffect, useState } from "react"; import { formatMessage } from "@/lib/i18n/shared"; import { localized } from "@/lib/types"; import type { Section } from "@/lib/types"; @@ -34,7 +33,6 @@ function splitTimeRange(range: string): { from: string; to: string } { } export default function SectionManager() { - const supabase = useMemo(() => createClient(), []); const dict = useDict(); const locale = useLocale(); const [sections, setSections] = useState([]); @@ -44,12 +42,12 @@ export default function SectionManager() { const [busy, setBusy] = useState(false); const load = useCallback(async () => { - const { data, error } = await supabase.from("sections").select("*").order("sort"); - if (error) - setError(formatMessage(dict.sectionManager.loadError, { message: error.message })); - else setSections(data as Section[]); + const res = await fetch("/api/sections"); + if (!res.ok) + setError(formatMessage(dict.sectionManager.loadError, { message: String(res.status) })); + else setSections(await res.json()); setLoading(false); - }, [supabase, dict]); + }, [dict]); useEffect(() => { load(); @@ -68,27 +66,19 @@ export default function SectionManager() { time_range: `${form.timeFrom} - ${form.timeTo}`, }; - let dbError; - if (form.key) { - ({ error: dbError } = await supabase - .from("sections") - .update(payload) - .eq("key", form.key)); - } else { - // Frisch aus der DB lesen statt den lokalen State zu nutzen, damit - // ein veralteter Snapshot nicht zu doppelten sort-Werten führt. - const { data: maxRow } = await supabase - .from("sections") - .select("sort") - .order("sort", { ascending: false }) - .limit(1) - .maybeSingle(); - ({ error: dbError } = await supabase - .from("sections") - .insert({ ...payload, sort: (maxRow?.sort ?? 0) + 1 })); - } - if (dbError) { - setError(formatMessage(dict.sectionManager.saveError, { message: dbError.message })); + const res = form.key + ? await fetch(`/api/sections/${form.key}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + : await fetch("/api/sections", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + setError(formatMessage(dict.sectionManager.saveError, { message: String(res.status) })); return; } setForm(null); @@ -102,9 +92,9 @@ export default function SectionManager() { ) ) return; - const { error } = await supabase.from("sections").delete().eq("key", section.key); - if (error) - setError(formatMessage(dict.sectionManager.deleteError, { message: error.message })); + const res = await fetch(`/api/sections/${section.key}`, { method: "DELETE" }); + if (!res.ok) + setError(formatMessage(dict.sectionManager.deleteError, { message: String(res.status) })); await load(); } @@ -115,12 +105,19 @@ export default function SectionManager() { setBusy(true); setError(null); const [r1, r2] = await Promise.all([ - supabase.from("sections").update({ sort: neighbor.sort }).eq("key", section.key), - supabase.from("sections").update({ sort: section.sort }).eq("key", neighbor.key), + fetch(`/api/sections/${section.key}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sort: neighbor.sort }), + }), + fetch(`/api/sections/${neighbor.key}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sort: section.sort }), + }), ]); - const dbError = r1.error ?? r2.error; - if (dbError) - setError(formatMessage(dict.sectionManager.saveError, { message: dbError.message })); + if (!r1.ok || !r2.ok) + setError(formatMessage(dict.sectionManager.saveError, { message: "move failed" })); setBusy(false); await load(); } diff --git a/components/SuggestionForm.tsx b/components/SuggestionForm.tsx index 390f0b3..34783a9 100644 --- a/components/SuggestionForm.tsx +++ b/components/SuggestionForm.tsx @@ -2,13 +2,11 @@ import { useState } from "react"; import Link from "next/link"; -import { createClient } from "@/lib/supabase/client"; import { formatMessage } from "@/lib/i18n/shared"; import { useDict } from "@/components/i18n/LocaleProvider"; import LanguageSwitcher from "@/components/i18n/LanguageSwitcher"; export default function SuggestionForm() { - const supabase = createClient(); const dict = useDict(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -34,23 +32,20 @@ export default function SuggestionForm() { } setBusy(true); - const { data: existing } = await supabase - .from("dishes") - .select("id") - .ilike("name", trimmedName) - .limit(1); - if (existing && existing.length > 0) { - setError(dict.suggestions.duplicate); - setBusy(false); - return; - } - - const { error: insertError } = await supabase.from("dish_suggestions").insert({ - name: trimmedName, - description: description.trim() || null, + const res = await fetch("/api/dish-suggestions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: trimmedName, description: description.trim() || null }), }); - if (insertError) { - setError(formatMessage(dict.suggestions.genericError, { message: insertError.message })); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError( + data.error === "duplicate" + ? dict.suggestions.duplicate + : formatMessage(dict.suggestions.genericError, { + message: data.error ?? String(res.status), + }) + ); setBusy(false); return; } diff --git a/components/SuggestionReview.tsx b/components/SuggestionReview.tsx index 17fa909..b036279 100644 --- a/components/SuggestionReview.tsx +++ b/components/SuggestionReview.tsx @@ -1,13 +1,11 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; +import { useCallback, useEffect, useState } from "react"; import { formatMessage } from "@/lib/i18n/shared"; import { useDict } from "@/components/i18n/LocaleProvider"; import type { DishSuggestion } from "@/lib/types"; export default function SuggestionReview() { - const supabase = useMemo(() => createClient(), []); const dict = useDict(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); @@ -16,15 +14,12 @@ export default function SuggestionReview() { const [busyId, setBusyId] = useState(null); const load = useCallback(async () => { - const { data, error } = await supabase - .from("dish_suggestions") - .select("*") - .order("created_at", { ascending: false }); - if (error) - setError(formatMessage(dict.suggestions.loadError, { message: error.message })); - else setItems(data as DishSuggestion[]); + const res = await fetch("/api/dish-suggestions"); + if (!res.ok) + setError(formatMessage(dict.suggestions.loadError, { message: String(res.status) })); + else setItems(await res.json()); setLoading(false); - }, [supabase, dict]); + }, [dict]); useEffect(() => { load(); @@ -34,25 +29,9 @@ export default function SuggestionReview() { setBusyId(item.id); setError(null); setInfo(null); - const { error: insertError } = await supabase.from("dishes").insert({ - name: item.name, - price: 0, - }); - if (insertError) { - setError( - formatMessage(dict.suggestions.actionError, { message: insertError.message }) - ); - setBusyId(null); - return; - } - const { error: deleteError } = await supabase - .from("dish_suggestions") - .delete() - .eq("id", item.id); - if (deleteError) { - setError( - formatMessage(dict.suggestions.actionError, { message: deleteError.message }) - ); + const res = await fetch(`/api/dish-suggestions/${item.id}/accept`, { method: "POST" }); + if (!res.ok) { + setError(formatMessage(dict.suggestions.actionError, { message: String(res.status) })); setBusyId(null); return; } @@ -67,9 +46,9 @@ export default function SuggestionReview() { setBusyId(item.id); setError(null); setInfo(null); - const { error } = await supabase.from("dish_suggestions").delete().eq("id", item.id); - if (error) { - setError(formatMessage(dict.suggestions.actionError, { message: error.message })); + const res = await fetch(`/api/dish-suggestions/${item.id}`, { method: "DELETE" }); + if (!res.ok) { + setError(formatMessage(dict.suggestions.actionError, { message: String(res.status) })); } else { setInfo(dict.suggestions.rejected); } diff --git a/components/UserManager.tsx b/components/UserManager.tsx index deeaf86..aac87b6 100644 --- a/components/UserManager.tsx +++ b/components/UserManager.tsx @@ -1,7 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { createClient } from "@/lib/supabase/client"; +import { useCallback, useEffect, useState } from "react"; import { formatMessage } from "@/lib/i18n/shared"; import type { Profile, Role } from "@/lib/types"; import { useDict } from "@/components/i18n/LocaleProvider"; @@ -24,30 +23,23 @@ const EMPTY_FORM: UserForm = { passwordRepeat: "", }; -export default function UserManager() { - const supabase = useMemo(() => createClient(), []); +export default function UserManager({ meId }: { meId: string }) { const dict = useDict(); const roleLabels: Record = { admin: dict.common.roleAdmin, staff: dict.common.roleStaff, }; 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(formatMessage(dict.userManager.loadError, { message: profileError.message })); - else setProfiles(profileData as Profile[]); - setMe(userData.user?.id ?? null); - }, [supabase, dict]); + const res = await fetch("/api/admin/users"); + if (!res.ok) + setError(formatMessage(dict.userManager.loadError, { message: String(res.status) })); + else setProfiles(await res.json()); + }, [dict]); useEffect(() => { load(); @@ -235,7 +227,7 @@ export default function UserManager() { – {p.email} – {roleLabels[p.role]} - {p.id === me && {dict.common.you}} + {p.id === meId && {dict.common.you}} - {p.id !== me && ( + {p.id !== meId && ( +
    + ) : ( + <> + setDishSearch(e.target.value)} + /> +
      + {filteredDishes.map((dish) => ( +
    • + +
    • + ))} + {filteredDishes.length === 0 && ( +
    • + {formatMessage(dict.dishManager.noResults, { + query: dishSearch, + })} +
    • + )} +
    + + )}
    @@ -205,10 +258,7 @@ export default function WeekEditor({ monday }: { monday: string }) { diff --git a/db/seed-demo.mjs b/db/seed-demo.mjs new file mode 100644 index 0000000..3c1a072 --- /dev/null +++ b/db/seed-demo.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +// Spielt db/seed-demo.sql ein: ersetzt Essenszeiten/Gerichte/Allergene/ +// Zusatzstoffe/Wochenplan durch den Beispieldatensatz. Nur für lokale +// Entwicklung/Demos gedacht, läuft nicht automatisch beim Start. + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import pg from "pg"; + +const sql = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "seed-demo.sql"), + "utf8" +); + +async function main() { + const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + try { + await client.query(sql); + console.log("db:seed-demo – Beispieldaten eingespielt."); + } finally { + await client.end(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/db/seed-demo.sql b/db/seed-demo.sql new file mode 100644 index 0000000..bddcf6d --- /dev/null +++ b/db/seed-demo.sql @@ -0,0 +1,130 @@ +-- ============================================================= +-- Beispieldaten für die lokale Entwicklung/Demo (NICHT Teil der +-- automatischen Migrationen – frische Deployments starten bewusst +-- leer, s. db/migrations/0001_init.sql). Manuell anwendbar über +-- "npm run db:seed-demo". +-- +-- Ersetzt vorhandene Essenszeiten/Gerichte/Allergene/Zusatzstoffe/ +-- Wochenplan-Einträge durch den ursprünglichen Beispieldatensatz. +-- ============================================================= + +delete from menu_entries; +delete from dish_suggestions; +delete from dishes; +delete from sections; +delete from allergens; +delete from additives; + +-- ---------- Essenszeiten ---------- +insert into sections (key, label, label_en, label_pl, time_range, sort) values + ('brotzeit', 'Brotzeit', 'Breakfast', 'Śniadanie', '09:00 - 10:00', 1), + ('mittagessen', 'Mittagessen', 'Lunch', 'Obiad', '11:30 - 13:30', 2), + ('mittagessen_veg', 'Mittagessen vegetarisch', 'Lunch (vegetarian)', 'Obiad (wegetariański)', '11:30 - 13:30', 3), + ('suppe', 'Suppe', 'Soup', 'Zupa', '11:30 - 13:30', 4); + +-- ---------- Allergene (EU-Kennzeichnung, Buchstaben) ---------- +insert into allergens (code, name, name_en, name_pl, sort) values + ('A', 'Glutenhaltiges Getreide', 'Cereals containing gluten', 'Zboża zawierające gluten', 1), + ('B', 'Krebstiere', 'Crustaceans', 'Skorupiaki', 2), + ('C', 'Eier', 'Eggs', 'Jaja', 3), + ('D', 'Fisch', 'Fish', 'Ryby', 4), + ('E', 'Erdnüsse', 'Peanuts', 'Orzeszki ziemne', 5), + ('F', 'Sojabohnen', 'Soybeans', 'Soja', 6), + ('G', 'Milch (einschließlich Laktose)', 'Milk (including lactose)', 'Mleko (w tym laktoza)', 7), + ('H', 'Schalenfrüchte (Nüsse)', 'Tree nuts', 'Orzechy', 8), + ('I', 'Sellerie', 'Celery', 'Seler', 9), + ('J', 'Senf', 'Mustard', 'Gorczyca', 10), + ('K', 'Sesamsamen', 'Sesame seeds', 'Nasiona sezamu', 11), + ('L', 'Schwefeldioxid und Sulfite', 'Sulphur dioxide and sulphites', 'Dwutlenek siarki i siarczyny', 12), + ('M', 'Lupinen', 'Lupin', 'Łubin', 13), + ('N', 'Weichtiere', 'Molluscs', 'Mięczaki', 14); + +-- ---------- Zusatzstoffe (Ziffern) ---------- +insert into additives (code, name, name_en, name_pl, sort) values + ('1', 'mit Farbstoff', 'with colouring', 'z barwnikiem', 1), + ('2', 'mit Konservierungsstoff', 'with preservative', 'z konserwantem', 2), + ('3', 'mit Antioxidationsmittel', 'with antioxidant', 'z przeciwutleniaczem', 3), + ('4', 'mit Geschmacksverstärker', 'with flavour enhancer', 'ze wzmacniaczem smaku', 4), + ('5', 'geschwefelt', 'sulphured', 'siarkowany', 5), + ('6', 'geschwärzt', 'blackened', 'barwiony na czarno', 6), + ('7', 'gewachst', 'waxed', 'woskowany', 7), + ('8', 'mit Phosphat', 'with phosphate', 'z fosforanem', 8), + ('9', 'mit Süßungsmittel(n)', 'with sweetener(s)', 'ze słodzikiem/słodzikami', 9), + ('10', 'enthält eine Phenylalaninquelle', 'contains a source of phenylalanine', 'zawiera źródło fenyloalaniny', 10), + ('11', 'koffeinhaltig', 'caffeinated', 'zawiera kofeinę', 11); + +-- ---------- Testgerichte ---------- +insert into dishes (name, price) values + -- Brotzeit + ('1 Stk. Rindskäsewurst mit Brötchen', 2.30), + ('Hackfleischbaguette', 3.20), + ('Knusperhähnchen Wrap', 3.70), + ('Leberkäse im Brötchen', 2.30), + ('Chili con Carne mit Brötchen', 3.90), + -- Mittagessen + ('Schnitzel Baguette', 3.50), + ('Penne mit Grünen Spargel und Garnelenspieße', 5.90), + ('Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip', 5.90), + ('Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle', 5.90), + -- Mittagessen vegetarisch + ('Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten', 5.30), + ('Spaghetti mit fruchtiger Tomatensauce und Parmesan', 5.30), + ('Gebackener Feta mit griechischen Bauernsalat und Baguette', 5.30), + ('Gemüse-Curry mit Basmatireis', 4.90), + -- Suppen + ('Kartoffelcremesuppe', 1.50), + ('Gulaschsuppe', 2.50), + ('Tomatensuppe mit Croutons', 1.80), + ('Kürbiscremesuppe', 1.80), + ('Linseneintopf mit Würstchen', 2.20); + +-- ---------- Beispielwoche 13.–17. Juli 2026 ---------- +with d as (select id, name from dishes) +insert into menu_entries (menu_date, section_key, dish_id) +select x.menu_date::date, x.section_key, d.id +from (values + ('2026-07-13', 'brotzeit', '1 Stk. Rindskäsewurst mit Brötchen'), + ('2026-07-14', 'brotzeit', 'Hackfleischbaguette'), + ('2026-07-15', 'brotzeit', 'Knusperhähnchen Wrap'), + ('2026-07-16', 'brotzeit', 'Leberkäse im Brötchen'), + ('2026-07-17', 'brotzeit', 'Chili con Carne mit Brötchen'), + ('2026-07-13', 'mittagessen', 'Schnitzel Baguette'), + ('2026-07-14', 'mittagessen', 'Penne mit Grünen Spargel und Garnelenspieße'), + ('2026-07-15', 'mittagessen', 'Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip'), + ('2026-07-16', 'mittagessen', 'Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle'), + ('2026-07-14', 'mittagessen_veg', 'Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten'), + ('2026-07-15', 'mittagessen_veg', 'Spaghetti mit fruchtiger Tomatensauce und Parmesan'), + ('2026-07-16', 'mittagessen_veg', 'Gebackener Feta mit griechischen Bauernsalat und Baguette'), + ('2026-07-13', 'suppe', 'Kartoffelcremesuppe'), + ('2026-07-14', 'suppe', 'Gulaschsuppe'), + ('2026-07-15', 'suppe', 'Tomatensuppe mit Croutons'), + ('2026-07-16', 'suppe', 'Kürbiscremesuppe'), + ('2026-07-17', 'suppe', 'Linseneintopf mit Würstchen') +) as x (menu_date, section_key, name) +join d on d.name = x.name; + +-- ---------- Beispielwoche 20.–24. Juli 2026 ("Nächste Woche") ---------- +with d as (select id, name from dishes) +insert into menu_entries (menu_date, section_key, dish_id) +select x.menu_date::date, x.section_key, d.id +from (values + ('2026-07-20', 'brotzeit', 'Leberkäse im Brötchen'), + ('2026-07-21', 'brotzeit', 'Knusperhähnchen Wrap'), + ('2026-07-22', 'brotzeit', '1 Stk. Rindskäsewurst mit Brötchen'), + ('2026-07-23', 'brotzeit', 'Hackfleischbaguette'), + ('2026-07-24', 'brotzeit', 'Chili con Carne mit Brötchen'), + ('2026-07-20', 'mittagessen', 'Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip'), + ('2026-07-21', 'mittagessen', 'Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle'), + ('2026-07-22', 'mittagessen', 'Schnitzel Baguette'), + ('2026-07-23', 'mittagessen', 'Penne mit Grünen Spargel und Garnelenspieße'), + ('2026-07-20', 'mittagessen_veg', 'Gemüse-Curry mit Basmatireis'), + ('2026-07-21', 'mittagessen_veg', 'Spaghetti mit fruchtiger Tomatensauce und Parmesan'), + ('2026-07-22', 'mittagessen_veg', 'Gebackener Feta mit griechischen Bauernsalat und Baguette'), + ('2026-07-23', 'mittagessen_veg', 'Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten'), + ('2026-07-20', 'suppe', 'Gulaschsuppe'), + ('2026-07-21', 'suppe', 'Kartoffelcremesuppe'), + ('2026-07-22', 'suppe', 'Linseneintopf mit Würstchen'), + ('2026-07-23', 'suppe', 'Tomatensuppe mit Croutons'), + ('2026-07-24', 'suppe', 'Kürbiscremesuppe') +) as x (menu_date, section_key, name) +join d on d.name = x.name; diff --git a/lib/i18n/dictionaries.ts b/lib/i18n/dictionaries.ts index d5fd1ec..d8c81a9 100644 --- a/lib/i18n/dictionaries.ts +++ b/lib/i18n/dictionaries.ts @@ -68,7 +68,7 @@ const de = { weekEditor: { title: "Wochenplan", loading: "Lade Wochenplan…", - selectDish: "– Gericht wählen –", + changeDish: "Anderes Gericht wählen", addMeal: "Mahlzeit hinzufügen", newDishesHint: "Neue Gerichte legst du unter {link} an.", newDishesLinkLabel: "Gerichte", @@ -322,7 +322,7 @@ const en: Dict = { weekEditor: { title: "Weekly plan", loading: "Loading weekly plan…", - selectDish: "– Select dish –", + changeDish: "Choose a different dish", addMeal: "Add meal", newDishesHint: "Add new dishes under {link}.", newDishesLinkLabel: "Dishes", @@ -574,7 +574,7 @@ const pl: Dict = { weekEditor: { title: "Plan tygodnia", loading: "Wczytywanie planu tygodnia…", - selectDish: "– Wybierz danie –", + changeDish: "Wybierz inne danie", addMeal: "Dodaj posiłek", newDishesHint: "Nowe dania możesz dodać w zakładce {link}.", newDishesLinkLabel: "Dania", diff --git a/package.json b/package.json index 4fce37d..99e1cee 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "prestart": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs", "start": "next start -H 0.0.0.0", "lint": "eslint", - "db:migrate": "node --env-file-if-exists=.env.local db/migrate.mjs" + "db:migrate": "node --env-file-if-exists=.env.local db/migrate.mjs", + "db:seed-demo": "node --env-file-if-exists=.env.local db/seed-demo.mjs" }, "dependencies": { "@simplewebauthn/browser": "^13.3.0", From 0958abaac6c2df300b10a2a7aae8be0359a31deb Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 18 Jul 2026 11:58:07 +0200 Subject: [PATCH 6/7] =?UTF-8?q?Sicherheit:=20SVG-Upload=20f=C3=BCr=20Logo/?= =?UTF-8?q?Favicon=20nicht=20mehr=20erlaubt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Branding-Upload akzeptierte bisher jeden Dateityp, der clientseitig mit "image/" beginnt, inkl. SVG. Anders als beim vorherigen Supabase- Storage-Bucket (andere Origin) wird die Datei jetzt auf der eigenen Origin unter /branding/[filename] ausgeliefert – eine SVG mit eingebettetem