diff --git a/.dockerignore b/.dockerignore index 8d3936f..d911c52 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,5 @@ node_modules .git *.log .env +prisma/data +fullcalendar-*.tgz diff --git a/.env.example b/.env.example index 11686d3..2f4db7f 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,9 @@ SMTP_PASS="password" SMTP_SECURE="false" SMTP_FROM="Vereinskalender " NOMINATIM_USER_AGENT="vereinskalender/1.0 (mailto:admin@example.com)" +RATE_LIMIT_WINDOW_MINUTES="15" +RATE_LIMIT_LOGIN="10" +RATE_LIMIT_REGISTER="5" +RATE_LIMIT_PASSWORD_RESET="3" +RATE_LIMIT_VERIFY_EMAIL="3" +RATE_LIMIT_ICAL_IMPORT="5" diff --git a/Dockerfile b/Dockerfile index 3d4e776..29212bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,7 @@ WORKDIR /app RUN apk add --no-cache openssl RUN npm install -g npm@11.7.0 ENV NPM_CONFIG_UPDATE_NOTIFIER=false +ENV NEXT_TELEMETRY_DISABLED=1 FROM base AS deps COPY package.json package-lock.json* ./ @@ -11,10 +12,13 @@ RUN --mount=type=cache,target=/root/.npm \ FROM base AS builder COPY --from=deps /app/node_modules ./node_modules +COPY prisma ./prisma +RUN --mount=type=cache,target=/root/.npm npx prisma generate COPY . . RUN --mount=type=cache,target=/root/.npm \ + --mount=type=cache,target=/app/.next/cache \ if [ ! -d node_modules/@fullcalendar/core ]; then npm install; fi -RUN npm run build +RUN --mount=type=cache,target=/app/.next/cache npx next build FROM base AS runner ENV NODE_ENV=production diff --git a/README.md b/README.md index 46b1ad4..59ca058 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ State-of-the-art Kalenderapp für Vereine mit Admin-Freigaben, persönlichen Kal - Backend: Next.js Route Handlers, Prisma ORM, SQLite - Auth: NextAuth (Credentials + Prisma Adapter) - Export: iCal via `ical-generator` +- Import: iCal via `node-ical` ## Projektstruktur - `app/` - Routen, Layouts und Seiten @@ -52,6 +53,12 @@ SMTP_PASS="password" SMTP_SECURE="false" SMTP_FROM="Vereinskalender " NOMINATIM_USER_AGENT="vereinskalender/1.0 (mailto:admin@example.com)" +RATE_LIMIT_WINDOW_MINUTES="15" +RATE_LIMIT_LOGIN="10" +RATE_LIMIT_REGISTER="5" +RATE_LIMIT_PASSWORD_RESET="3" +RATE_LIMIT_VERIFY_EMAIL="3" +RATE_LIMIT_ICAL_IMPORT="5" ``` ## Admin-Setup @@ -84,6 +91,7 @@ Admin-Konten werden sofort freigeschaltet, normale Mitglieder bleiben auf `PENDI - `DELETE /api/views/:id/items` - Termin entfernen - `GET /api/views/default` - Standardansicht laden/erstellen - `GET /api/ical/:token` - iCal Feed der Ansicht +- `POST /api/ical/import` - iCal-Datei importieren (Admin/Superadmin) - `POST /api/views/default/rotate` - iCal-Link erneuern - `POST /api/views/:id/categories` - Kategorie abonnieren - `DELETE /api/views/:id/categories` - Kategorie-Abo entfernen @@ -92,16 +100,22 @@ Admin-Konten werden sofort freigeschaltet, normale Mitglieder bleiben auf `PENDI - `POST /api/password-reset/confirm` - Passwort setzen (mit Token) - `POST /api/verify-email/request` - Verifizierungslink senden - `POST /api/verify-email/confirm` - E-Mail verifizieren -- `GET /api/settings/google-places` - Ortsanbieter + API Key abrufen (Login) -- `POST /api/settings/google-places` - Ortsanbieter/Key speichern (Superadmin) +- `GET /api/settings/system` - Ortsanbieter + API Key abrufen (Login) +- `POST /api/settings/system` - Ortsanbieter/Key speichern (Superadmin) - `GET /api/places/autocomplete` - Places Autocomplete (Login) - `GET /api/places/details` - Places Details (Login) - `GET /api/places/reverse` - Reverse-Geocoding (Login) +- `GET /api/settings/app-name` - App-Name abrufen +- `POST /api/settings/app-name` - App-Name setzen (Superadmin) ## iCal-Abonnement Unter `/settings` wird die iCal-URL angezeigt. Diese kann in Kalender-Apps (iOS/Android) abonniert werden. +## iCal-Import + +Admins und Superadmins können `.ics` Dateien im Adminbereich hochladen. Termine werden importiert und direkt freigegeben. `GEO:lat;lng` wird unterstützt, um Karten direkt anzuzeigen. + ## Standardansicht - Jeder Benutzer hat eine Standardansicht (`/api/views/default`). @@ -116,6 +130,10 @@ Unter `/settings` können Nutzer ihre E-Mail oder ihr Passwort ändern und den i Superadmins sehen unter `/admin` zusätzlich die System-Einstellungen und können den Ortsanbieter (Google oder OpenStreetMap/Nominatim) konfigurieren. Außerdem kann die öffentliche Registrierung deaktiviert werden. +Der App-Name kann ebenfalls dort gepflegt werden und wird in der Navigation sowie im iCal-Export verwendet. + +Für iCal wird standardmäßig ein Rückblick von 14 Tagen angewendet (plus alle zukünftigen Termine). Jeder Benutzer kann den Rückblick in den Einstellungen anpassen; der Wert wird als URL-Parameter `pastDays` genutzt. + ## Orte & Karten - Der Ort wird per Google Places oder OpenStreetMap (Nominatim) vorgeschlagen und mit `placeId` sowie Koordinaten gespeichert. @@ -154,6 +172,20 @@ Wichtig für persistente Logins und Daten: - Die SQLite-DB liegt im Host-Verzeichnis `/opt/docker/vereinskalender/app-data` und wird nach `/app/prisma/data` gemountet. Sie bleibt über Rebuilds erhalten. - `docker compose down -v` löscht keine Bind-Mount-Daten, aber ein Entfernen von `/opt/docker/vereinskalender/app-data` löscht alles. +## Rate Limiting + +Passwort-Reset, E-Mail-Verifizierung, Registrierung, Login und iCal-Import sind DB-basiert rate-limited. Nach dem Hinzufügen neuer Limits Prisma-Migration ausführen (`npm run prisma:migrate` bzw. `npm run prisma:deploy` im Container). + +Optional zusätzlich per Nginx: + +```nginx +limit_req_zone $binary_remote_addr zone=authlimit:10m rate=5r/m; + +location = /api/password-reset/request { limit_req zone=authlimit burst=5 nodelay; proxy_pass http://app:3000; } +location = /api/verify-email/request { limit_req zone=authlimit burst=5 nodelay; proxy_pass http://app:3000; } +location = /api/register { limit_req zone=authlimit burst=5 nodelay; proxy_pass http://app:3000; } +``` + ## Schnellere Builds (Best Practices) - `package-lock.json` committen und im Dockerfile `npm ci` nutzen (bereits vorbereitet). - BuildKit-Cache nutzen (im Dockerfile aktiv, benötigt Docker BuildKit). diff --git a/app/api/events/route.ts b/app/api/events/route.ts index 8c39f26..a2b73d5 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -26,7 +26,9 @@ export async function GET(request: Request) { const events = await prisma.event.findMany({ where, orderBy: { startAt: "asc" }, - include: { category: true } + include: isAdmin + ? { category: true, createdBy: { select: { name: true, email: true } } } + : { category: true } }); return NextResponse.json(events); diff --git a/app/api/ical/[token]/[filename]/route.ts b/app/api/ical/[token]/[filename]/route.ts new file mode 100644 index 0000000..ea9d6d1 --- /dev/null +++ b/app/api/ical/[token]/[filename]/route.ts @@ -0,0 +1,8 @@ +import { getIcalResponse } from "../../../../../lib/ical-export"; + +export async function GET( + request: Request, + context: { params: { token: string; filename: string } } +) { + return getIcalResponse(request, context.params.token); +} diff --git a/app/api/ical/[token]/route.ts b/app/api/ical/[token]/route.ts index e5ee980..42bf425 100644 --- a/app/api/ical/[token]/route.ts +++ b/app/api/ical/[token]/route.ts @@ -1,66 +1,8 @@ -import ical from "ical-generator"; -import { NextResponse } from "next/server"; -import { prisma } from "../../../../lib/prisma"; +import { getIcalResponse } from "../../../../lib/ical-export"; export async function GET( - _request: Request, + request: Request, context: { params: { token: string } } ) { - const view = await prisma.userView.findUnique({ - where: { token: context.params.token }, - include: { - items: { include: { event: true } }, - categories: true, - exclusions: true, - user: true - } - }); - - if (!view) { - return NextResponse.json({ error: "Not found" }, { status: 404 }); - } - - const calendar = ical({ - name: `Vereinskalender - ${view.name}`, - timezone: "Europe/Berlin" - }); - - const excludedIds = new Set(view.exclusions.map((item) => item.eventId)); - const explicitEvents = view.items - .map((item) => item.event) - .filter((event) => event.status === "APPROVED"); - - const categoryIds = view.categories.map((item) => item.categoryId); - const categoryEvents = - categoryIds.length > 0 - ? await prisma.event.findMany({ - where: { categoryId: { in: categoryIds }, status: "APPROVED" } - }) - : []; - - const combined = [...explicitEvents, ...categoryEvents].filter( - (event, index, all) => - all.findIndex((item) => item.id === event.id) === index && - !excludedIds.has(event.id) - ); - - combined.forEach((event) => { - const start = event.startAt; - const end = - event.endAt || new Date(event.startAt.getTime() + 3 * 60 * 60 * 1000); - calendar.createEvent({ - id: event.id, - summary: event.title, - description: event.description || undefined, - location: event.location || undefined, - start, - end - }); - }); - - return new NextResponse(calendar.toString(), { - headers: { - "Content-Type": "text/calendar; charset=utf-8" - } - }); + return getIcalResponse(request, context.params.token); } diff --git a/app/api/ical/import/route.ts b/app/api/ical/import/route.ts index ce15f10..61a7087 100644 --- a/app/api/ical/import/route.ts +++ b/app/api/ical/import/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from "next/server"; import { parseICS } from "node-ical"; import { isAdminSession, requireSession } from "../../../../lib/auth-helpers"; import { prisma } from "../../../../lib/prisma"; +import { checkRateLimit, getRateLimitConfig } from "../../../../lib/rate-limit"; +import { getClientIp } from "../../../../lib/request"; const MAX_FILE_SIZE = 5 * 1024 * 1024; @@ -11,6 +13,32 @@ const asText = (value: unknown) => { return String(value).trim(); }; +const parseGeo = (value: unknown) => { + if (!value) return null; + if (typeof value === "string") { + const cleaned = value.trim(); + if (!cleaned) return null; + const parts = cleaned.split(/[;,]/).map((part) => part.trim()); + if (parts.length >= 2) { + const lat = Number(parts[0]); + const lng = Number(parts[1]); + if (!Number.isNaN(lat) && !Number.isNaN(lng)) { + return { lat, lng }; + } + } + return null; + } + if (typeof value === "object") { + const record = value as Record; + const lat = Number(record.lat ?? record.latitude); + const lng = Number(record.lon ?? record.lng ?? record.longitude); + if (!Number.isNaN(lat) && !Number.isNaN(lng)) { + return { lat, lng }; + } + } + return null; +}; + export async function POST(request: Request) { const { session } = await requireSession(); if (!session) { @@ -20,6 +48,22 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Nur für Admins." }, { status: 403 }); } + const ip = getClientIp(request); + const email = session.user?.email || "unknown"; + const rateKey = `icalimport:${email}:${ip}`; + const rateConfig = getRateLimitConfig("RATE_LIMIT_ICAL_IMPORT", 5); + const rate = await checkRateLimit({ + key: rateKey, + limit: rateConfig.limit, + windowMs: rateConfig.windowMs + }); + if (!rate.ok) { + return NextResponse.json( + { error: "Zu viele Importe. Bitte später erneut versuchen." }, + { status: 429 } + ); + } + const formData = await request.formData(); const file = formData.get("file"); const categoryId = asText(formData.get("categoryId")); @@ -103,6 +147,7 @@ export async function POST(request: Request) { : new Date(start.getTime() + 3 * 60 * 60 * 1000); const location = asText(entry.location) || null; const description = asText(entry.description) || null; + const geo = parseGeo(entry.geo); const existing = await prisma.event.findFirst({ where: { @@ -123,6 +168,8 @@ export async function POST(request: Request) { title, description, location, + locationLat: geo ? geo.lat : null, + locationLng: geo ? geo.lng : null, startAt: start, endAt: end, status: "APPROVED", diff --git a/app/api/password-reset/request/route.ts b/app/api/password-reset/request/route.ts index 62255ae..542ec75 100644 --- a/app/api/password-reset/request/route.ts +++ b/app/api/password-reset/request/route.ts @@ -2,6 +2,8 @@ import { randomUUID } from "crypto"; import { NextResponse } from "next/server"; import { prisma } from "../../../../lib/prisma"; import { sendMail } from "../../../../lib/mailer"; +import { checkRateLimit, getRateLimitConfig } from "../../../../lib/rate-limit"; +import { getClientIp } from "../../../../lib/request"; export async function POST(request: Request) { const body = await request.json(); @@ -11,9 +13,37 @@ export async function POST(request: Request) { return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 }); } - const user = await prisma.user.findUnique({ where: { email } }); + const normalizedEmail = String(email).trim().toLowerCase(); + const ip = getClientIp(request); + const rateKey = `pwreset:${normalizedEmail}:${ip}`; + const rateConfig = getRateLimitConfig("RATE_LIMIT_PASSWORD_RESET", 3); + const rate = await checkRateLimit({ + key: rateKey, + limit: rateConfig.limit, + windowMs: rateConfig.windowMs + }); + if (!rate.ok) { + return NextResponse.json( + { error: "Zu viele Anfragen. Bitte später erneut versuchen." }, + { status: 429 } + ); + } + + const user = await prisma.user.findUnique({ where: { email: normalizedEmail } }); if (user) { + const existingToken = await prisma.passwordResetToken.findFirst({ + where: { + userId: user.id, + createdAt: { gt: new Date(Date.now() - 15 * 60 * 1000) } + }, + orderBy: { createdAt: "desc" } + }); + + if (existingToken) { + return NextResponse.json({ ok: true }); + } + await prisma.passwordResetToken.deleteMany({ where: { userId: user.id } }); const token = randomUUID(); @@ -31,7 +61,7 @@ export async function POST(request: Request) { const resetUrl = `${baseUrl}/reset/confirm?token=${token}`; await sendMail({ - to: email, + to: normalizedEmail, subject: "Passwort zurücksetzen", text: `Passwort zurücksetzen: ${resetUrl}` }); diff --git a/app/api/register/route.ts b/app/api/register/route.ts index c018201..37e0dcb 100644 --- a/app/api/register/route.ts +++ b/app/api/register/route.ts @@ -4,6 +4,8 @@ import { randomUUID } from "crypto"; import { prisma } from "../../../lib/prisma"; import { isAdminEmail, isSuperAdminEmail } from "../../../lib/auth"; import { sendMail } from "../../../lib/mailer"; +import { checkRateLimit, getRateLimitConfig } from "../../../lib/rate-limit"; +import { getClientIp } from "../../../lib/request"; export async function POST(request: Request) { const registrationSetting = await prisma.setting.findUnique({ @@ -24,6 +26,21 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Email und Passwort sind erforderlich." }, { status: 400 }); } + const ip = getClientIp(request); + const rateKey = `register:${normalizedEmail}:${ip}`; + const rateConfig = getRateLimitConfig("RATE_LIMIT_REGISTER", 5); + const rate = await checkRateLimit({ + key: rateKey, + limit: rateConfig.limit, + windowMs: rateConfig.windowMs + }); + if (!rate.ok) { + return NextResponse.json( + { error: "Zu viele Anfragen. Bitte später erneut versuchen." }, + { status: 429 } + ); + } + const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } }); if (existing) { return NextResponse.json({ error: "Account existiert bereits." }, { status: 409 }); diff --git a/app/api/settings/app-name/route.ts b/app/api/settings/app-name/route.ts new file mode 100644 index 0000000..b393659 --- /dev/null +++ b/app/api/settings/app-name/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; +import { isSuperAdminSession, requireSession } from "../../../../lib/auth-helpers"; + +const DEFAULT_APP_NAME = "Vereinskalender"; + +export async function GET() { + const setting = await prisma.setting.findUnique({ + where: { key: "app_name" } + }); + return NextResponse.json({ name: setting?.value || DEFAULT_APP_NAME }); +} + +export async function POST(request: Request) { + const { session } = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!isSuperAdminSession(session)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await request.json(); + const name = String(body?.name || "").trim(); + if (!name) { + return NextResponse.json({ error: "Name erforderlich." }, { status: 400 }); + } + if (name.length > 60) { + return NextResponse.json({ error: "Name ist zu lang." }, { status: 400 }); + } + + await prisma.setting.upsert({ + where: { key: "app_name" }, + update: { value: name }, + create: { key: "app_name", value: name } + }); + + return NextResponse.json({ name }); +} diff --git a/app/api/settings/logo/route.ts b/app/api/settings/logo/route.ts index 5a1ac38..26e5ede 100644 --- a/app/api/settings/logo/route.ts +++ b/app/api/settings/logo/route.ts @@ -16,6 +16,7 @@ const MIME_TO_EXT: Record = { "image/webp": "webp", "image/svg+xml": "svg" }; +const MAX_FILE_SIZE = 2 * 1024 * 1024; const resolveLogoPath = (relativePath: string) => { const absolutePath = path.join(DATA_DIR, relativePath); @@ -51,6 +52,9 @@ export async function POST(request: Request) { if (!extension) { return NextResponse.json({ error: "Dateityp nicht unterstützt." }, { status: 400 }); } + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json({ error: "Datei ist zu groß (max. 2 MB)." }, { status: 400 }); + } await fs.mkdir(UPLOADS_DIR, { recursive: true }); diff --git a/app/api/settings/google-places/route.ts b/app/api/settings/system/route.ts similarity index 100% rename from app/api/settings/google-places/route.ts rename to app/api/settings/system/route.ts diff --git a/app/api/verify-email/request/route.ts b/app/api/verify-email/request/route.ts index 2df63cc..5db4c84 100644 --- a/app/api/verify-email/request/route.ts +++ b/app/api/verify-email/request/route.ts @@ -2,6 +2,8 @@ import { randomUUID } from "crypto"; import { NextResponse } from "next/server"; import { prisma } from "../../../../lib/prisma"; import { sendMail } from "../../../../lib/mailer"; +import { checkRateLimit, getRateLimitConfig } from "../../../../lib/rate-limit"; +import { getClientIp } from "../../../../lib/request"; export async function POST(request: Request) { const body = await request.json(); @@ -11,7 +13,23 @@ export async function POST(request: Request) { return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 }); } - const user = await prisma.user.findUnique({ where: { email } }); + const normalizedEmail = String(email).trim().toLowerCase(); + const ip = getClientIp(request); + const rateKey = `verify:${normalizedEmail}:${ip}`; + const rateConfig = getRateLimitConfig("RATE_LIMIT_VERIFY_EMAIL", 3); + const rate = await checkRateLimit({ + key: rateKey, + limit: rateConfig.limit, + windowMs: rateConfig.windowMs + }); + if (!rate.ok) { + return NextResponse.json( + { error: "Zu viele Anfragen. Bitte später erneut versuchen." }, + { status: 429 } + ); + } + + const user = await prisma.user.findUnique({ where: { email: normalizedEmail } }); if (!user) { return NextResponse.json({ ok: true }); } @@ -20,13 +38,21 @@ export async function POST(request: Request) { return NextResponse.json({ ok: true }); } - await prisma.verificationToken.deleteMany({ where: { identifier: email } }); + const existingToken = await prisma.verificationToken.findFirst({ + where: { identifier: normalizedEmail, expires: { gt: new Date() } } + }); + + if (existingToken) { + return NextResponse.json({ ok: true }); + } + + await prisma.verificationToken.deleteMany({ where: { identifier: normalizedEmail } }); const token = randomUUID(); const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); await prisma.verificationToken.create({ data: { - identifier: email, + identifier: normalizedEmail, token, expires } @@ -35,7 +61,7 @@ export async function POST(request: Request) { const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000"; const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`; await sendMail({ - to: email, + to: normalizedEmail, subject: "E-Mail verifizieren", text: `Bitte verifiziere deine E-Mail: ${verifyUrl}` }); diff --git a/app/api/views/default/route.ts b/app/api/views/default/route.ts index 8409c08..87427ee 100644 --- a/app/api/views/default/route.ts +++ b/app/api/views/default/route.ts @@ -55,3 +55,35 @@ export async function GET() { return NextResponse.json(hydrated, { status: 201 }); } + +export async function PATCH(request: Request) { + const { session } = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const icalPastDays = Number(body?.icalPastDays); + if (!Number.isFinite(icalPastDays) || icalPastDays < 0 || icalPastDays > 365) { + return NextResponse.json( + { error: "iCal-Rückblick ist ungültig." }, + { status: 400 } + ); + } + + const email = session.user?.email || ""; + const view = await prisma.userView.findFirst({ + where: { user: { email } } + }); + + if (!view) { + return NextResponse.json({ error: "Ansicht nicht gefunden." }, { status: 404 }); + } + + const updated = await prisma.userView.update({ + where: { id: view.id }, + data: { icalPastDays: Math.floor(icalPastDays) } + }); + + return NextResponse.json(updated); +} diff --git a/app/globals.css b/app/globals.css index 4569817..800fbeb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -128,29 +128,129 @@ html[data-theme="dark"] .btn-accent:hover { background: var(--accent-strong); } +html[data-theme="dark"] .btn-primary { + background: #f8f7f2; + color: #0f1110; + border-color: rgba(148, 163, 184, 0.4); +} + +html[data-theme="dark"] .btn-primary:hover { + background: #ffffff; +} + html[data-theme="dark"] .btn-ghost { border-color: rgba(71, 85, 105, 0.6); color: #e2e8f0; - background: rgba(15, 17, 16, 0.35); + background: rgba(30, 41, 59, 0.55); } html[data-theme="dark"] .btn-ghost:hover { - background: rgba(148, 163, 184, 0.12); + background: rgba(148, 163, 184, 0.18); +} + +html[data-theme="dark"] .ical-link { + border-color: rgba(71, 85, 105, 0.5); + background: rgba(30, 41, 59, 0.6); +} + +html[data-theme="dark"] .ical-link span { + color: #e2e8f0; +} + +html[data-theme="dark"] button.text-slate-600 { + color: #e2e8f0; +} + +html[data-theme="dark"] button.text-slate-600:hover { + color: #f8fafc; +} + +html[data-theme="dark"] .list-table button { + color: #e2e8f0; +} + +html[data-theme="dark"] .list-table button:hover { + color: #f8fafc; +} + +html[data-theme="dark"] .list-table .text-slate-600 { + color: #e2e8f0; +} + +html[data-theme="dark"] .list-table svg { + color: #e2e8f0; +} + +html[data-theme="dark"] .category-pill { + border-color: rgba(71, 85, 105, 0.6); + background: rgba(30, 41, 59, 0.55); + color: #e2e8f0; +} + +html[data-theme="dark"] .category-pill button { + color: #e2e8f0; } html[data-theme="dark"] input, html[data-theme="dark"] select, html[data-theme="dark"] textarea { - background: rgba(15, 17, 16, 0.65); - border-color: rgba(71, 85, 105, 0.5); + background: rgba(30, 41, 59, 0.55); + border-color: rgba(148, 163, 184, 0.45); color: #f8fafc; } +html[data-theme="dark"] input::placeholder, +html[data-theme="dark"] textarea::placeholder { + color: rgba(226, 232, 240, 0.6); +} + html[data-theme="dark"] header { border-color: rgba(71, 85, 105, 0.35); background: rgba(15, 17, 16, 0.8); } +html[data-theme="dark"] .brand-title { + color: #f8fafc; +} + +.nav-link { + color: #334155; + background: transparent; +} + +.nav-link:hover { + background: #f1f5f9; +} + +.nav-link-active { + background: #0f172a; + color: #ffffff; +} + +.nav-link-active:hover { + background: #0f172a; + color: #ffffff; +} + +html[data-theme="dark"] .nav-link { + color: #e2e8f0; +} + +html[data-theme="dark"] .nav-link:hover { + background: rgba(148, 163, 184, 0.12); +} + +html[data-theme="dark"] .nav-link-active { + background: #f8f7f2; + color: #0f1110; +} + +html[data-theme="dark"] .nav-link-active:hover { + background: #f8f7f2; + color: #0f1110; +} + + html[data-theme="dark"] .fc .fc-button { border-color: rgba(71, 85, 105, 0.5); background: rgba(15, 17, 16, 0.75); @@ -190,6 +290,30 @@ html[data-theme="dark"] .fc .fc-daygrid-day.fc-day-past .fc-daygrid-day-number { color: rgba(226, 232, 240, 0.7); } +html[data-theme="dark"] .list-table tr { + border-color: rgba(71, 85, 105, 0.4); +} + +html[data-theme="dark"] .list-table tr[data-bucket="past"] { + background: rgba(30, 41, 59, 0.55); + color: rgba(148, 163, 184, 0.9); +} + +html[data-theme="dark"] .list-table tr[data-bucket="today"] { + background: rgba(217, 119, 6, 0.18); + color: #f8fafc; +} + +html[data-theme="dark"] .list-table tr[data-bucket="tomorrow"] { + background: rgba(16, 185, 129, 0.16); + color: #f8fafc; +} + +html[data-theme="dark"] .list-table tr[data-bucket="future"] { + background: rgba(59, 130, 246, 0.12); + color: #f8fafc; +} + html[data-theme="dark"] .drag-handle { border-color: rgba(71, 85, 105, 0.5); color: #e2e8f0; @@ -225,8 +349,8 @@ html[data-theme="dark"] .drag-handle:hover { .fc .fc-timegrid-event { border-radius: 0.6rem; border: none; - background: #1f2937; - color: #ffffff; + background: #e2e8f0; + color: #0f172a; } .fc .fc-daygrid-event .fc-event-main, @@ -235,13 +359,15 @@ html[data-theme="dark"] .drag-handle:hover { .fc .fc-timegrid-event .fc-event-title, .fc .fc-daygrid-event .fc-event-time, .fc .fc-timegrid-event .fc-event-time { - color: #ffffff; + color: #0f172a; } .fc .fc-daygrid-event .event-shell, .fc .fc-timegrid-event .event-shell { position: relative; padding-right: 1.75rem; + overflow: hidden; + max-height: 100%; } .fc .fc-daygrid-event .event-toggle, diff --git a/app/login/page.tsx b/app/login/page.tsx index 30203c3..8f140b6 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -35,6 +35,10 @@ export default function LoginPage() { setError("Zu viele Versuche. Bitte später erneut versuchen."); return; } + if (result.error === "RATE_LIMIT") { + setError("Zu viele Anfragen. Bitte später erneut versuchen."); + return; + } setError("Login fehlgeschlagen."); return; } diff --git a/app/providers.tsx b/app/providers.tsx index 3502f27..c9afb78 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -2,7 +2,27 @@ import { SessionProvider } from "next-auth/react"; import type { ReactNode } from "react"; +import { useEffect } from "react"; export default function Providers({ children }: { children: ReactNode }) { + useEffect(() => { + if (typeof document === "undefined") return; + const root = document.documentElement; + const applyTheme = () => { + try { + const saved = window.localStorage.getItem("theme"); + if (saved === "dark" || saved === "light") { + root.dataset.theme = saved; + } + } catch { + // ignore + } + }; + applyTheme(); + const handler = () => applyTheme(); + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); + }, []); + return {children}; } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index c0135be..a4d4656 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { signOut, useSession } from "next-auth/react"; export default function SettingsPage() { @@ -17,6 +17,9 @@ export default function SettingsPage() { const [profileStatus, setProfileStatus] = useState(null); const [theme, setTheme] = useState<"light" | "dark">("light"); const [copyStatus, setCopyStatus] = useState<"success" | "error" | null>(null); + const [appName, setAppName] = useState("Vereinskalender"); + const [icalPastDays, setIcalPastDays] = useState(14); + const icalReadyRef = useRef(false); const loadView = async () => { try { @@ -25,6 +28,10 @@ export default function SettingsPage() { const payload = await response.json(); setViewToken(payload.token); setViewId(payload.id); + setIcalPastDays( + typeof payload.icalPastDays === "number" ? payload.icalPastDays : 14 + ); + icalReadyRef.current = true; const ids = new Set( (payload.categories || []).map((item: { categoryId: string }) => item.categoryId) ); @@ -44,10 +51,23 @@ export default function SettingsPage() { } }; + const loadAppName = async () => { + try { + const nameResponse = await fetch("/api/settings/app-name"); + if (nameResponse.ok) { + const payload = await nameResponse.json(); + setAppName(payload.name || "Vereinskalender"); + } + } catch { + // ignore + } + }; + useEffect(() => { if (data?.user) { loadView(); loadCategories(); + loadAppName(); } }, [data?.user]); @@ -106,7 +126,39 @@ export default function SettingsPage() { }; const baseUrl = typeof window === "undefined" ? "" : window.location.origin; - const icalUrl = viewToken ? `${baseUrl}/api/ical/${viewToken}` : ""; + const toFilename = (value: string) => + value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || "kalender"; + + const icalQuery = icalPastDays > 0 ? `?pastDays=${icalPastDays}` : ""; + const icalUrl = viewToken + ? `${baseUrl}/api/ical/${viewToken}/${toFilename(appName)}.ical${icalQuery}` + : ""; + + const updateIcalPastDays = async (value: number) => { + setError(null); + setStatus(null); + const response = await fetch("/api/views/default", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ icalPastDays: value }) + }); + if (!response.ok) { + const data = await response.json(); + setError(data.error || "Einstellung konnte nicht gespeichert werden."); + return; + } + setStatus("iCal-Einstellung gespeichert."); + window.setTimeout(() => setStatus(null), 2500); + }; + + useEffect(() => { + if (!icalReadyRef.current || !viewId) return; + updateIcalPastDays(icalPastDays); + }, [icalPastDays, viewId]); const applyTheme = (next: "light" | "dark") => { setTheme(next); @@ -247,43 +299,73 @@ export default function SettingsPage() { Dein Link kann in externen Kalender-Apps abonniert werden.

{viewToken ? ( -
-
-

iCal URL

-
- - {copyStatus && ( -
- {copyStatus === "success" ? "Kopiert" : "Fehler"} -
- )} -
+ + + + + {copyStatus && ( +
+ {copyStatus === "success" ? "Kopiert" : "Fehler"} +
+ )}
-

{icalUrl}

) : (

iCal-Link wird geladen...

)} - - {status &&

{status}

} + {status && ( +
+ {status} +
+ )} {error &&

{error}

} diff --git a/components/AdminPanel.tsx b/components/AdminPanel.tsx index 9b97c09..380958f 100644 --- a/components/AdminPanel.tsx +++ b/components/AdminPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import Pagination from "./Pagination"; type EventItem = { id: string; @@ -14,6 +15,7 @@ type EventItem = { locationLng?: number | null; description?: string | null; category?: { id: string; name: string } | null; + createdBy?: { name?: string | null; email?: string | null } | null; }; export default function AdminPanel() { @@ -33,6 +35,12 @@ export default function AdminPanel() { const [editStatus, setEditStatus] = useState(null); const [editError, setEditError] = useState(null); const [isEditOpen, setIsEditOpen] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [sortKey, setSortKey] = useState<"startAt" | "title" | "category" | "status">( + "startAt" + ); + const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const [importFile, setImportFile] = useState(null); const [importCategoryId, setImportCategoryId] = useState(""); const [importStatus, setImportStatus] = useState(null); @@ -80,6 +88,43 @@ export default function AdminPanel() { loadAllEvents(); }, []); + useEffect(() => { + setPage(1); + }, [allEvents.length]); + + useEffect(() => { + setPage(1); + }, [pageSize]); + + const totalPages = Math.max(1, Math.ceil(allEvents.length / pageSize)); + + const sortedEvents = [...allEvents].sort((a, b) => { + const dir = sortDir === "asc" ? 1 : -1; + if (sortKey === "title") { + return a.title.localeCompare(b.title) * dir; + } + if (sortKey === "category") { + const aCat = a.category?.name || "Ohne Kategorie"; + const bCat = b.category?.name || "Ohne Kategorie"; + return aCat.localeCompare(bCat) * dir; + } + if (sortKey === "status") { + return a.status.localeCompare(b.status) * dir; + } + const aDate = new Date(a.startAt).getTime(); + const bDate = new Date(b.startAt).getTime(); + return (aDate - bDate) * dir; + }); + + const toggleSort = (nextKey: "startAt" | "title" | "category" | "status") => { + if (sortKey === nextKey) { + setSortDir((prev) => (prev === "asc" ? "desc" : "asc")); + return; + } + setSortKey(nextKey); + setSortDir("asc"); + }; + const updateStatus = async (id: string, status: "APPROVED" | "REJECTED") => { await fetch(`/api/events/${id}`, { method: "PATCH", @@ -298,53 +343,60 @@ export default function AdminPanel() { return (
-
-

Admin

-

Offene Vorschläge

-
- {error &&

{error}

} - {events.length === 0 ? ( -
+
+
+

+ Vorschläge +

+

Terminvorschläge

+
+ {error &&

{error}

} + {events.length === 0 ? (

Keine offenen Vorschläge.

-
- ) : ( -
- {events.map((event) => ( -
-
-
-

{event.title}

-

- {new Date(event.startAt).toLocaleString()} - {new Date(event.endAt).toLocaleString()} -

- {event.location && ( -

Ort: {event.location}

- )} -
-
- - + ) : ( +
+ {events.map((event) => ( +
+
+
+

{event.title}

+

+ {new Date(event.startAt).toLocaleString()} - {new Date(event.endAt).toLocaleString()} +

+ {event.createdBy && ( +

+ Vorschlag von {event.createdBy.name || event.createdBy.email || "Unbekannt"} +

+ )} + {event.location && ( +

Ort: {event.location}

+ )} +
+
+ + +
+ {event.description && ( +

{event.description}

+ )}
- {event.description && ( -

{event.description}

- )} -
- ))} -
- )} + ))} +
+ )} +

@@ -375,30 +427,56 @@ export default function AdminPanel() { Noch keine Kategorien. ) : ( - categories.map((category) => ( -

- {category.name} -
+
+ {categories.map((category) => ( +
+ {category.name}
-
- )) + ))} +
)}
@@ -447,14 +525,20 @@ export default function AdminPanel() {
- - setImportFile(event.currentTarget.files?.[0] || null) - } - className="block text-sm text-slate-600" - /> + + + {importFile ? importFile.name : "Keine Datei ausgewählt"} + - setLogoFile(event.currentTarget.files?.[0] || null) - } - className="block text-sm text-slate-600" - /> + + + {logoFile ? logoFile.name : "Keine Datei ausgewählt"} +
+
+ + setAppName(event.target.value)} + className="w-full rounded-xl border border-slate-300 px-3 py-2" + placeholder="Vereinskalender" + required + maxLength={60} + /> +