From b2b23268b21b9fd473aa0f35fe67f6b214d1c3d5 Mon Sep 17 00:00:00 2001 From: Meik Date: Fri, 16 Jan 2026 23:02:36 +0100 Subject: [PATCH] Aktueller Stand --- app/api/categories/route.ts | 43 ++- app/api/events/[id]/route.ts | 14 +- app/api/events/route.ts | 58 +++- app/api/settings/system/route.ts | 46 +-- app/globals.css | 19 ++ app/login/page.tsx | 18 +- app/page.tsx | 14 +- app/settings/page.tsx | 10 + components/AdminPanel.tsx | 125 +++++++- components/AdminSystemSettings.tsx | 40 ++- components/CalendarBoard.tsx | 472 ++++++++++++++++++++--------- components/NavBar.tsx | 26 +- lib/system-settings.ts | 107 +++++++ prisma/schema.prisma | 2 + 14 files changed, 768 insertions(+), 226 deletions(-) create mode 100644 lib/system-settings.ts diff --git a/app/api/categories/route.ts b/app/api/categories/route.ts index aae1aa4..c32ffa7 100644 --- a/app/api/categories/route.ts +++ b/app/api/categories/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { prisma } from "../../../lib/prisma"; import { isAdminSession, requireSession } from "../../../lib/auth-helpers"; +import { getAccessSettings } from "../../../lib/system-settings"; export async function GET() { const { session } = await requireSession(); @@ -26,7 +27,7 @@ export async function POST(request: Request) { } const body = await request.json(); - const { name } = body || {}; + const { name, isPublic } = body || {}; if (!name) { return NextResponse.json({ error: "Name erforderlich." }, { status: 400 }); @@ -37,8 +38,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 }); } + const { publicAccessEnabled } = await getAccessSettings(); const category = await prisma.category.create({ - data: { name } + data: { name, isPublic: publicAccessEnabled && isPublic === true } }); const views = await prisma.userView.findMany({ @@ -68,25 +70,42 @@ export async function PATCH(request: Request) { } const body = await request.json(); - const { id, name } = body || {}; + const { id, name, isPublic } = body || {}; - if (!id || !name) { - return NextResponse.json({ error: "ID und Name erforderlich." }, { status: 400 }); + if (!id) { + return NextResponse.json({ error: "ID erforderlich." }, { status: 400 }); } - const trimmed = String(name).trim(); - if (!trimmed) { - return NextResponse.json({ error: "Name erforderlich." }, { status: 400 }); + const data: { name?: string; isPublic?: boolean } = {}; + + if (name !== undefined) { + const trimmed = String(name).trim(); + if (!trimmed) { + return NextResponse.json({ error: "Name erforderlich." }, { status: 400 }); + } + + const existing = await prisma.category.findUnique({ where: { name: trimmed } }); + if (existing && existing.id !== id) { + return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 }); + } + data.name = trimmed; } - const existing = await prisma.category.findUnique({ where: { name: trimmed } }); - if (existing && existing.id !== id) { - return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 }); + const { publicAccessEnabled } = await getAccessSettings(); + if (publicAccessEnabled && typeof isPublic === "boolean") { + data.isPublic = isPublic; + } + + if (Object.keys(data).length === 0) { + return NextResponse.json( + { error: "Keine Änderungen angegeben." }, + { status: 400 } + ); } const category = await prisma.category.update({ where: { id }, - data: { name: trimmed } + data }); return NextResponse.json(category); diff --git a/app/api/events/[id]/route.ts b/app/api/events/[id]/route.ts index 3f5fd65..6dbefa3 100644 --- a/app/api/events/[id]/route.ts +++ b/app/api/events/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { prisma } from "../../../../lib/prisma"; import { isAdminSession, requireSession } from "../../../../lib/auth-helpers"; +import { getAccessSettings } from "../../../../lib/system-settings"; export async function PATCH(request: Request, context: { params: { id: string } }) { const { session } = await requireSession(); @@ -23,7 +24,8 @@ export async function PATCH(request: Request, context: { params: { id: string } locationLng, startAt, endAt, - categoryId + categoryId, + publicOverride } = body || {}; if (status && ["APPROVED", "REJECTED"].includes(status)) { @@ -44,6 +46,13 @@ export async function PATCH(request: Request, context: { params: { id: string } const startDate = new Date(startAt); const endDate = endAt ? new Date(endAt) : null; + const { publicAccessEnabled } = await getAccessSettings(); + const overrideValue = + publicAccessEnabled && publicOverride !== undefined + ? publicOverride === null || publicOverride === true || publicOverride === false + ? publicOverride + : null + : undefined; const event = await prisma.event.update({ where: { id: context.params.id }, @@ -56,7 +65,8 @@ export async function PATCH(request: Request, context: { params: { id: string } locationLng: locationLng ? Number(locationLng) : null, startAt: startDate, endAt: endDate, - category: { connect: { id: categoryId } } + category: { connect: { id: categoryId } }, + publicOverride: overrideValue } }); diff --git a/app/api/events/route.ts b/app/api/events/route.ts index a2b73d5..8648652 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -1,16 +1,66 @@ import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; import { prisma } from "../../../lib/prisma"; import { isAdminSession, requireSession } from "../../../lib/auth-helpers"; +import { authOptions } from "../../../lib/auth"; +import { getAccessSettings } from "../../../lib/system-settings"; export async function GET(request: Request) { - const { session } = await requireSession(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const session = await getServerSession(authOptions); + if (session?.user?.status && session.user.status !== "ACTIVE") { + return NextResponse.json( + { error: "Account nicht freigeschaltet." }, + { status: 403 } + ); + } + if (session?.user?.emailVerified === false) { + return NextResponse.json( + { error: "E-Mail nicht verifiziert." }, + { status: 403 } + ); } - const { searchParams } = new URL(request.url); const status = searchParams.get("status"); const isAdmin = isAdminSession(session); + const { publicAccessEnabled } = await getAccessSettings(); + + if (!session?.user?.email) { + if (!publicAccessEnabled) { + return NextResponse.json( + { error: "Öffentlicher Zugriff ist deaktiviert." }, + { status: 403 } + ); + } + const events = await prisma.event.findMany({ + where: { + status: "APPROVED", + OR: [ + { publicOverride: true }, + { publicOverride: null, category: { isPublic: true } } + ] + }, + orderBy: { startAt: "asc" }, + select: { + id: true, + title: true, + location: true, + locationPlaceId: true, + locationLat: true, + locationLng: true, + startAt: true, + endAt: true, + status: true, + category: { + select: { + id: true, + name: true + } + } + } + }); + + return NextResponse.json(events); + } const where = isAdmin ? status diff --git a/app/api/settings/system/route.ts b/app/api/settings/system/route.ts index 9c22d04..15339e4 100644 --- a/app/api/settings/system/route.ts +++ b/app/api/settings/system/route.ts @@ -1,29 +1,15 @@ import { NextResponse } from "next/server"; import { prisma } from "../../../../lib/prisma"; import { isSuperAdminSession, requireSession } from "../../../../lib/auth-helpers"; +import { getSystemSettings } from "../../../../lib/system-settings"; export async function GET() { const { session } = await requireSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - - const apiKeySetting = await prisma.setting.findUnique({ - where: { key: "google_places_api_key" } - }); - const providerSetting = await prisma.setting.findUnique({ - where: { key: "geocoding_provider" } - }); - const registrationSetting = await prisma.setting.findUnique({ - where: { key: "registration_enabled" } - }); - - const apiKey = apiKeySetting?.value || ""; - const provider = - providerSetting?.value || (apiKey ? "google" : "osm"); - const registrationEnabled = registrationSetting?.value !== "false"; - - return NextResponse.json({ apiKey, provider, registrationEnabled }); + const settings = await getSystemSettings(); + return NextResponse.json(settings); } export async function POST(request: Request) { @@ -37,7 +23,12 @@ export async function POST(request: Request) { } const body = await request.json(); - const { apiKey, provider, registrationEnabled } = body || {}; + const { + apiKey, + provider, + registrationEnabled, + publicAccessEnabled + } = body || {}; if (!provider || !["google", "osm"].includes(provider)) { return NextResponse.json({ error: "Provider erforderlich." }, { status: 400 }); @@ -68,9 +59,26 @@ export async function POST(request: Request) { create: { key: "registration_enabled", value: registrationValue } }); + const existing = await getSystemSettings(); + const nextPublicAccessEnabled = + typeof publicAccessEnabled === "boolean" + ? publicAccessEnabled + : existing.publicAccessEnabled; + + const publicAccessValue = nextPublicAccessEnabled ? "true" : "false"; + await prisma.setting.upsert({ + where: { key: "public_access_enabled" }, + update: { value: publicAccessValue }, + create: { key: "public_access_enabled", value: publicAccessValue } + }); + await prisma.setting.deleteMany({ + where: { key: { in: ["public_events_enabled", "anonymous_access_enabled"] } } + }); + return NextResponse.json({ apiKey: apiKeySetting.value, provider: providerSetting.value, - registrationEnabled: registrationValue !== "false" + registrationEnabled: registrationValue !== "false", + publicAccessEnabled: nextPublicAccessEnabled }); } diff --git a/app/globals.css b/app/globals.css index 800fbeb..94edf01 100644 --- a/app/globals.css +++ b/app/globals.css @@ -250,6 +250,18 @@ html[data-theme="dark"] .nav-link-active:hover { color: #0f1110; } +.mobile-menu-panel { + background: rgba(255, 255, 255, 0.92); + border-bottom: 1px solid rgba(226, 232, 240, 0.85); + box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12); +} + +html[data-theme="dark"] .mobile-menu-panel { + background: rgba(30, 37, 34, 0.95); + border-bottom-color: rgba(71, 85, 105, 0.35); + box-shadow: 0 18px 36px rgba(0, 0, 0, 0.5); +} + html[data-theme="dark"] .fc .fc-button { border-color: rgba(71, 85, 105, 0.5); @@ -387,6 +399,9 @@ html[data-theme="dark"] .drag-handle:hover { color: #475569; background: #ffffff; cursor: grab; + touch-action: none; + user-select: none; + -webkit-user-select: none; transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; } @@ -436,6 +451,10 @@ html[data-theme="dark"] .drag-handle:hover { } @media (max-width: 768px) { + .calendar-pane { + display: none; + } + .fc .fc-toolbar { flex-direction: column; gap: 0.5rem; diff --git a/app/login/page.tsx b/app/login/page.tsx index 8f140b6..b299717 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -8,10 +8,12 @@ import { useState } from "react"; export default function LoginPage() { const router = useRouter(); const [error, setError] = useState(null); + const [showVerifyLink, setShowVerifyLink] = useState(false); const onSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(null); + setShowVerifyLink(false); const formData = new FormData(event.currentTarget); const email = formData.get("email") as string; const password = formData.get("password") as string; @@ -29,6 +31,7 @@ export default function LoginPage() { } if (result.error === "EMAIL_NOT_VERIFIED") { setError("Bitte bestätige zuerst deine E-Mail."); + setShowVerifyLink(true); return; } if (result.error === "LOCKED") { @@ -44,6 +47,7 @@ export default function LoginPage() { } if (result?.ok) { + setShowVerifyLink(false); router.push("/"); } }; @@ -83,12 +87,14 @@ export default function LoginPage() { Zurücksetzen

-

- E-Mail nicht bestätigt?{" "} - - Link erneut senden - -

+ {showVerifyLink && ( +

+ E-Mail nicht bestätigt?{" "} + + Link erneut senden + +

+ )} ); } diff --git a/app/page.tsx b/app/page.tsx index aa0978c..6c3a0ba 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,21 +2,27 @@ import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; import CalendarBoard from "../components/CalendarBoard"; import { authOptions } from "../lib/auth"; +import { getAccessSettings } from "../lib/system-settings"; export default async function HomePage() { const session = await getServerSession(authOptions); + const { publicAccessEnabled } = await getAccessSettings(); + if (!session?.user && !publicAccessEnabled) { + redirect("/login"); + } + const isBlocked = + session?.user && + (session.user.status !== "ACTIVE" || session.user.emailVerified === false); return (
- {session?.user?.status === "ACTIVE" ? ( - - ) : session?.user ? ( + {isBlocked ? (

Dein Konto wartet auf Freischaltung durch einen Admin.

) : ( - redirect("/login") + )}
); diff --git a/app/settings/page.tsx b/app/settings/page.tsx index a4d4656..5069da0 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -220,6 +220,16 @@ export default function SettingsPage() { setSubscribedCategories(next); }; + if (!data?.user) { + return ( +
+

+ Bitte anmelden, um Einstellungen zu verwalten. +

+
+ ); + } + return (
diff --git a/components/AdminPanel.tsx b/components/AdminPanel.tsx index 380958f..c7aabb6 100644 --- a/components/AdminPanel.tsx +++ b/components/AdminPanel.tsx @@ -14,23 +14,28 @@ type EventItem = { locationLat?: number | null; locationLng?: number | null; description?: string | null; - category?: { id: string; name: string } | null; + publicOverride?: boolean | null; + category?: { id: string; name: string; isPublic?: boolean } | null; createdBy?: { name?: string | null; email?: string | null } | null; }; +type CategoryItem = { + id: string; + name: string; + isPublic: boolean; +}; + export default function AdminPanel() { const [events, setEvents] = useState([]); const [allEvents, setAllEvents] = useState([]); const [error, setError] = useState(null); - const [categories, setCategories] = useState<{ id: string; name: string }[]>( - [] - ); + const [categories, setCategories] = useState([]); const [categoryError, setCategoryError] = useState(null); const [categoryStatus, setCategoryStatus] = useState(null); const [categoryModalOpen, setCategoryModalOpen] = useState(false); const [categoryModalError, setCategoryModalError] = useState(null); const [categoryModalStatus, setCategoryModalStatus] = useState(null); - const [editingCategory, setEditingCategory] = useState<{ id: string; name: string } | null>(null); + const [editingCategory, setEditingCategory] = useState(null); const [editEvent, setEditEvent] = useState(null); const [editStatus, setEditStatus] = useState(null); const [editError, setEditError] = useState(null); @@ -45,6 +50,7 @@ export default function AdminPanel() { const [importCategoryId, setImportCategoryId] = useState(""); const [importStatus, setImportStatus] = useState(null); const [importError, setImportError] = useState(null); + const [publicAccessEnabled, setPublicAccessEnabled] = useState(null); const load = async () => { try { @@ -82,10 +88,22 @@ export default function AdminPanel() { } }; + const loadSystemSettings = async () => { + try { + const response = await fetch("/api/settings/system"); + if (!response.ok) return; + const payload = await response.json(); + setPublicAccessEnabled(payload.publicAccessEnabled !== false); + } catch { + // ignore + } + }; + useEffect(() => { load(); loadCategories(); loadAllEvents(); + loadSystemSettings(); }, []); useEffect(() => { @@ -97,6 +115,7 @@ export default function AdminPanel() { }, [pageSize]); const totalPages = Math.max(1, Math.ceil(allEvents.length / pageSize)); + const showPublicControls = publicAccessEnabled === true; const sortedEvents = [...allEvents].sort((a, b) => { const dir = sortDir === "asc" ? 1 : -1; @@ -160,6 +179,14 @@ export default function AdminPanel() { return date.toISOString(); }; + const parsePublicOverride = (value: FormDataEntryValue | null) => { + if (!value) return null; + const raw = String(value); + if (raw === "public") return true; + if (raw === "private") return false; + return null; + }; + const updateEvent = async (event: React.FormEvent) => { event.preventDefault(); setEditStatus(null); @@ -167,7 +194,7 @@ export default function AdminPanel() { if (!editEvent) return; const formData = new FormData(event.currentTarget); - const payload = { + const payload: Record = { title: formData.get("title"), description: formData.get("description"), location: formData.get("location"), @@ -178,6 +205,11 @@ export default function AdminPanel() { endAt: toIsoString(formData.get("endAt")), categoryId: formData.get("categoryId") }; + if (showPublicControls) { + payload.publicOverride = parsePublicOverride( + formData.get("publicOverride") + ); + } const response = await fetch(`/api/events/${editEvent.id}`, { method: "PATCH", @@ -205,15 +237,21 @@ export default function AdminPanel() { setCategoryStatus(null); const formData = new FormData(event.currentTarget); const rawName = String(formData.get("name") || "").trim(); + const isPublic = formData.get("isPublic") === "on"; if (!rawName) { setCategoryError("Name erforderlich."); return; } + const payload: Record = { name: rawName }; + if (showPublicControls) { + payload.isPublic = isPublic; + } + const response = await fetch("/api/categories", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: rawName }) + body: JSON.stringify(payload) }); if (!response.ok) { @@ -231,7 +269,7 @@ export default function AdminPanel() { setCategoryStatus("Kategorie angelegt."); }; - const openCategoryModal = (category: { id: string; name: string }) => { + const openCategoryModal = (category: CategoryItem) => { setEditingCategory(category); setCategoryModalError(null); setCategoryModalStatus(null); @@ -251,15 +289,24 @@ export default function AdminPanel() { const formData = new FormData(event.currentTarget); const name = String(formData.get("name") || "").trim(); + const isPublic = formData.get("isPublic") === "on"; if (!name) { setCategoryModalError("Name erforderlich."); return; } + const payload: Record = { + id: editingCategory.id, + name + }; + if (showPublicControls) { + payload.isPublic = isPublic; + } + const response = await fetch("/api/categories", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id: editingCategory.id, name }) + body: JSON.stringify(payload) }); if (!response.ok) { @@ -411,6 +458,12 @@ export default function AdminPanel() { placeholder="z.B. Training" className="flex-1 rounded-xl border border-slate-300 px-3 py-2" /> + {showPublicControls && ( + + )} @@ -434,6 +487,11 @@ export default function AdminPanel() { className="category-pill flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-sm text-slate-700" > {category.name} + {showPublicControls && category.isPublic && ( + + Öffentlich + + )} @@ -656,6 +724,45 @@ export default function AdminPanel() { ))} + {showPublicControls && ( +
+

+ Öffentlich +

+
+ + + +
+
+ )}