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"}
+
+ )}
-
{icalUrl}
) : (
iCal-Link wird geladen...
)}
-
+
+
+
+
Link erneuern
- {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}
- )}
-
-
-
updateStatus(event.id, "APPROVED")}
- className="rounded-full bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white"
- >
- Freigeben
-
-
updateStatus(event.id, "REJECTED")}
- className="rounded-full bg-red-600 px-3 py-1.5 text-sm font-semibold text-white"
- >
- Ablehnen
-
+ ) : (
+
+ {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}
+ )}
+
+
+ updateStatus(event.id, "APPROVED")}
+ className="rounded-full bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white"
+ >
+ Freigeben
+
+ updateStatus(event.id, "REJECTED")}
+ className="rounded-full bg-red-600 px-3 py-1.5 text-sm font-semibold text-white"
+ >
+ Ablehnen
+
+
+ {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}
openCategoryModal(category)}
+ aria-label="Kategorie bearbeiten"
>
- Bearbeiten
+
+
+
+
deleteCategory(category.id)}
+ aria-label="Kategorie löschen"
>
- Löschen
+
+
+
+
+
+
-
- ))
+ ))}
+
)}
@@ -447,14 +525,20 @@ export default function AdminPanel() {