diff --git a/lib/menu.ts b/lib/menu.ts new file mode 100644 index 0000000..6f6edeb --- /dev/null +++ b/lib/menu.ts @@ -0,0 +1,49 @@ +// Datums- und Formatierungshelfer für den Wochenplan (Mo–Fr). +// Alle Berechnungen laufen über UTC, damit Server und Browser +// unabhängig von der Zeitzone dieselbe Woche ermitteln. + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +function toDate(iso: string): Date { + return new Date(`${iso}T00:00:00Z`); +} + +export function toISO(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** Montag der Woche, in der `iso` liegt (bzw. der aktuellen Woche). */ +export function mondayOf(iso?: string | null): string { + const base = + iso && ISO_DATE.test(iso) + ? toDate(iso) + : new Date(); + const day = (base.getUTCDay() + 6) % 7; // Montag = 0 + const monday = new Date( + Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate() - day) + ); + return toISO(monday); +} + +export function addDays(iso: string, days: number): string { + const d = toDate(iso); + d.setUTCDate(d.getUTCDate() + days); + return toISO(d); +} + +export const WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"]; + +/** z. B. "13. Juli 2026" */ +export function formatDateLong(iso: string): string { + return toDate(iso).toLocaleDateString("de-DE", { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", + }); +} + +/** z. B. "3,50€" */ +export function formatPrice(price: number | string): string { + return Number(price).toFixed(2).replace(".", ",") + "€"; +} diff --git a/lib/supabase/admin.ts b/lib/supabase/admin.ts new file mode 100644 index 0000000..33f43f7 --- /dev/null +++ b/lib/supabase/admin.ts @@ -0,0 +1,14 @@ +import "server-only"; +import { createClient as createSupabaseClient } from "@supabase/supabase-js"; + +// Service-Role-Client: umgeht RLS. Ausschließlich serverseitig verwenden +// (Benutzerverwaltung durch den Admin). +export function createAdminClient() { + return createSupabaseClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY!, + { + auth: { persistSession: false, autoRefreshToken: false }, + } + ); +} diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts new file mode 100644 index 0000000..31e3fe1 --- /dev/null +++ b/lib/supabase/client.ts @@ -0,0 +1,15 @@ +import { createBrowserClient } from "@supabase/ssr"; + +// Browser-Client (Client Components). Passkey-Support ist bei Supabase +// derzeit als experimentelles Feature markiert und muss aktiviert werden. +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + auth: { + experimental: { passkey: true }, + }, + } + ); +} diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts new file mode 100644 index 0000000..eab1b8d --- /dev/null +++ b/lib/supabase/server.ts @@ -0,0 +1,30 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +// Server-Client (Server Components, Route Handler, Server Actions). +// Muss pro Request neu erzeugt werden. +export async function createClient() { + const cookieStore = await cookies(); + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + } catch { + // In Server Components dürfen keine Cookies gesetzt werden – + // das Session-Refresh übernimmt proxy.ts. + } + }, + }, + } + ); +} diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..da2943e --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,75 @@ +export type Role = "admin" | "staff"; + +export interface Profile { + id: string; + email: string; + full_name: string; + role: Role; + created_at: string; +} + +export interface Section { + key: string; + label: string; + time_range: string; + sort: number; +} + +export interface Dish { + id: string; + name: string; + price: number; +} + +export interface MenuEntry { + id: string; + menu_date: string; + section_key: string; + position: number; + dish: Dish; +} + +export interface CodeItem { + code: string; + name: string; + sort: number; +} + +// Farb-Defaults (r-hofmann.de: Blau #003388, Navy #0d315c, Orange #ff6900) +export const DEFAULT_COLORS: Record = { + primary: "#003388", + primaryDark: "#0d315c", + accent: "#ff6900", + headerText: "#ffffff", + rowBg: "#e3e4f1", + rowBgAlt: "#f4f4fa", + addBtn: "#6da54c", + deleteBtn: "#e2371d", +}; + +export const COLOR_VARS: Record = { + primary: "--c-primary", + primaryDark: "--c-primary-dark", + accent: "--c-accent", + headerText: "--c-header-text", + rowBg: "--c-row-bg", + rowBgAlt: "--c-row-bg-alt", + addBtn: "--c-add", + deleteBtn: "--c-del", +}; + +export const COLOR_LABELS: Record = { + primary: "Primärfarbe (Tabellenkopf)", + primaryDark: "Primärfarbe dunkel (Kopfleiste intern)", + accent: "Akzentfarbe", + headerText: "Schriftfarbe im Tabellenkopf", + rowBg: "Zellen-Hintergrund", + rowBgAlt: "Zellen-Hintergrund (alternierend)", + addBtn: "Button „Hinzufügen“", + deleteBtn: "Button „Löschen“", +}; + +export function logoUrl(path: string | null | undefined): string | null { + if (!path) return null; + return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/branding/${path}`; +} diff --git a/package-lock.json b/package-lock.json index f945562..dee072c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,12 @@ "name": "speisekarte", "version": "0.1.0", "dependencies": { + "@supabase/ssr": "^0.12.3", + "@supabase/supabase-js": "^2.110.7", "next": "16.2.10", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "server-only": "^0.0.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1313,6 +1316,102 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.7.tgz", + "integrity": "sha512-M5Bpl4hCv6kHcOO/xM06Dyfg1mYLHljMkp1plhzG9IRZPc3czvyMsSN1XpL5+GKisOKM3lSN59zhpcm6sMVXfA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.7.tgz", + "integrity": "sha512-megYmexlYEoR/0qlsr4Snh9wtzAodO7MAri3NMevZrXzNvQRKlvmTcSBoKGLQEPDakgDZMqbMdf9DwoZz6qfoA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.7.tgz", + "integrity": "sha512-ban6YV0djhVaqVYezlOARKLIuOBSvLLhyQVZjA2nxPrtswhxHCl1+gI4giFgI9ATQAaMNbUZb4JXiuL5lEA/5g==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.7.tgz", + "integrity": "sha512-AMtZjyFA2gsmjuxopPNS/sRznLQHG0Ht5x+ytTPTOh3vAcOTUlVRLx7gW4/CONNnbb3PKOkE+HmM35HOSbmomQ==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.3.tgz", + "integrity": "sha512-qWXJ/dI7CiYDKyTgIPqJ4Qkh8y5edh2LdF/nkN48z47mmEVzAO2OE+YErYeQ/UemVfonn/F4kFz+lhLqoVMdpw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.110.5" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.7.tgz", + "integrity": "sha512-2tcDE8cjEDy1uKxKavBpKQod1JdMV1jDXQag48TCa+kycmJOltc0yVabC0BUlhOwAl6WykXU2aOsH3ELMtZrmQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.7.tgz", + "integrity": "sha512-AnfO3A230Shy6RMO7cya3Wl1OcXnABJrzH8vP+fY7/RFjhzcchB7DjKkkTIAntlwekD+GkSFzEvt2tC+D4Fp8w==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.110.7", + "@supabase/functions-js": "2.110.7", + "@supabase/postgrest-js": "2.110.7", + "@supabase/realtime-js": "2.110.7", + "@supabase/storage-js": "2.110.7" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2816,6 +2915,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4103,6 +4215,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5873,6 +5994,12 @@ "semver": "bin/semver.js" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index d3d223f..f75855c 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,12 @@ "lint": "eslint" }, "dependencies": { + "@supabase/ssr": "^0.12.3", + "@supabase/supabase-js": "^2.110.7", "next": "16.2.10", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "server-only": "^0.0.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..db7aaff --- /dev/null +++ b/proxy.ts @@ -0,0 +1,45 @@ +import { createServerClient } from "@supabase/ssr"; +import { NextResponse, type NextRequest } from "next/server"; + +// Session-Refresh + Zugriffsschutz für die internen Bereiche. +export async function proxy(request: NextRequest) { + let response = NextResponse.next({ request }); + + 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) { + const url = request.nextUrl.clone(); + url.pathname = "/login"; + url.search = ""; + return NextResponse.redirect(url); + } + + return response; +} + +export const config = { + matcher: ["/intern/:path*", "/admin/:path*"], +};