Aktueller Stand
This commit is contained in:
@@ -3,3 +3,5 @@ node_modules
|
|||||||
.git
|
.git
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
|
prisma/data
|
||||||
|
fullcalendar-*.tgz
|
||||||
|
|||||||
@@ -10,3 +10,9 @@ SMTP_PASS="password"
|
|||||||
SMTP_SECURE="false"
|
SMTP_SECURE="false"
|
||||||
SMTP_FROM="Vereinskalender <noreply@example.com>"
|
SMTP_FROM="Vereinskalender <noreply@example.com>"
|
||||||
NOMINATIM_USER_AGENT="vereinskalender/1.0 (mailto:admin@example.com)"
|
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"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ WORKDIR /app
|
|||||||
RUN apk add --no-cache openssl
|
RUN apk add --no-cache openssl
|
||||||
RUN npm install -g npm@11.7.0
|
RUN npm install -g npm@11.7.0
|
||||||
ENV NPM_CONFIG_UPDATE_NOTIFIER=false
|
ENV NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
@@ -11,10 +12,13 @@ RUN --mount=type=cache,target=/root/.npm \
|
|||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY prisma ./prisma
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npx prisma generate
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
--mount=type=cache,target=/app/.next/cache \
|
||||||
if [ ! -d node_modules/@fullcalendar/core ]; then npm install; fi
|
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
|
FROM base AS runner
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
36
README.md
36
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
|
- Backend: Next.js Route Handlers, Prisma ORM, SQLite
|
||||||
- Auth: NextAuth (Credentials + Prisma Adapter)
|
- Auth: NextAuth (Credentials + Prisma Adapter)
|
||||||
- Export: iCal via `ical-generator`
|
- Export: iCal via `ical-generator`
|
||||||
|
- Import: iCal via `node-ical`
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
- `app/` - Routen, Layouts und Seiten
|
- `app/` - Routen, Layouts und Seiten
|
||||||
@@ -52,6 +53,12 @@ SMTP_PASS="password"
|
|||||||
SMTP_SECURE="false"
|
SMTP_SECURE="false"
|
||||||
SMTP_FROM="Vereinskalender <noreply@example.com>"
|
SMTP_FROM="Vereinskalender <noreply@example.com>"
|
||||||
NOMINATIM_USER_AGENT="vereinskalender/1.0 (mailto:admin@example.com)"
|
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
|
## Admin-Setup
|
||||||
@@ -84,6 +91,7 @@ Admin-Konten werden sofort freigeschaltet, normale Mitglieder bleiben auf `PENDI
|
|||||||
- `DELETE /api/views/:id/items` - Termin entfernen
|
- `DELETE /api/views/:id/items` - Termin entfernen
|
||||||
- `GET /api/views/default` - Standardansicht laden/erstellen
|
- `GET /api/views/default` - Standardansicht laden/erstellen
|
||||||
- `GET /api/ical/:token` - iCal Feed der Ansicht
|
- `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/default/rotate` - iCal-Link erneuern
|
||||||
- `POST /api/views/:id/categories` - Kategorie abonnieren
|
- `POST /api/views/:id/categories` - Kategorie abonnieren
|
||||||
- `DELETE /api/views/:id/categories` - Kategorie-Abo entfernen
|
- `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/password-reset/confirm` - Passwort setzen (mit Token)
|
||||||
- `POST /api/verify-email/request` - Verifizierungslink senden
|
- `POST /api/verify-email/request` - Verifizierungslink senden
|
||||||
- `POST /api/verify-email/confirm` - E-Mail verifizieren
|
- `POST /api/verify-email/confirm` - E-Mail verifizieren
|
||||||
- `GET /api/settings/google-places` - Ortsanbieter + API Key abrufen (Login)
|
- `GET /api/settings/system` - Ortsanbieter + API Key abrufen (Login)
|
||||||
- `POST /api/settings/google-places` - Ortsanbieter/Key speichern (Superadmin)
|
- `POST /api/settings/system` - Ortsanbieter/Key speichern (Superadmin)
|
||||||
- `GET /api/places/autocomplete` - Places Autocomplete (Login)
|
- `GET /api/places/autocomplete` - Places Autocomplete (Login)
|
||||||
- `GET /api/places/details` - Places Details (Login)
|
- `GET /api/places/details` - Places Details (Login)
|
||||||
- `GET /api/places/reverse` - Reverse-Geocoding (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
|
## iCal-Abonnement
|
||||||
|
|
||||||
Unter `/settings` wird die iCal-URL angezeigt. Diese kann in Kalender-Apps (iOS/Android) abonniert werden.
|
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
|
## Standardansicht
|
||||||
|
|
||||||
- Jeder Benutzer hat eine Standardansicht (`/api/views/default`).
|
- 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.
|
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
|
## Orte & Karten
|
||||||
|
|
||||||
- Der Ort wird per Google Places oder OpenStreetMap (Nominatim) vorgeschlagen und mit `placeId` sowie Koordinaten gespeichert.
|
- 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.
|
- 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.
|
- `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)
|
## Schnellere Builds (Best Practices)
|
||||||
- `package-lock.json` committen und im Dockerfile `npm ci` nutzen (bereits vorbereitet).
|
- `package-lock.json` committen und im Dockerfile `npm ci` nutzen (bereits vorbereitet).
|
||||||
- BuildKit-Cache nutzen (im Dockerfile aktiv, benötigt Docker BuildKit).
|
- BuildKit-Cache nutzen (im Dockerfile aktiv, benötigt Docker BuildKit).
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export async function GET(request: Request) {
|
|||||||
const events = await prisma.event.findMany({
|
const events = await prisma.event.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { startAt: "asc" },
|
orderBy: { startAt: "asc" },
|
||||||
include: { category: true }
|
include: isAdmin
|
||||||
|
? { category: true, createdBy: { select: { name: true, email: true } } }
|
||||||
|
: { category: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(events);
|
return NextResponse.json(events);
|
||||||
|
|||||||
8
app/api/ical/[token]/[filename]/route.ts
Normal file
8
app/api/ical/[token]/[filename]/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,66 +1,8 @@
|
|||||||
import ical from "ical-generator";
|
import { getIcalResponse } from "../../../../lib/ical-export";
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { prisma } from "../../../../lib/prisma";
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: Request,
|
request: Request,
|
||||||
context: { params: { token: string } }
|
context: { params: { token: string } }
|
||||||
) {
|
) {
|
||||||
const view = await prisma.userView.findUnique({
|
return getIcalResponse(request, context.params.token);
|
||||||
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"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
|
|||||||
import { parseICS } from "node-ical";
|
import { parseICS } from "node-ical";
|
||||||
import { isAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
import { isAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
|
import { checkRateLimit, getRateLimitConfig } from "../../../../lib/rate-limit";
|
||||||
|
import { getClientIp } from "../../../../lib/request";
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -11,6 +13,32 @@ const asText = (value: unknown) => {
|
|||||||
return String(value).trim();
|
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<string, unknown>;
|
||||||
|
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) {
|
export async function POST(request: Request) {
|
||||||
const { session } = await requireSession();
|
const { session } = await requireSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -20,6 +48,22 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Nur für Admins." }, { status: 403 });
|
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 formData = await request.formData();
|
||||||
const file = formData.get("file");
|
const file = formData.get("file");
|
||||||
const categoryId = asText(formData.get("categoryId"));
|
const categoryId = asText(formData.get("categoryId"));
|
||||||
@@ -103,6 +147,7 @@ export async function POST(request: Request) {
|
|||||||
: new Date(start.getTime() + 3 * 60 * 60 * 1000);
|
: new Date(start.getTime() + 3 * 60 * 60 * 1000);
|
||||||
const location = asText(entry.location) || null;
|
const location = asText(entry.location) || null;
|
||||||
const description = asText(entry.description) || null;
|
const description = asText(entry.description) || null;
|
||||||
|
const geo = parseGeo(entry.geo);
|
||||||
|
|
||||||
const existing = await prisma.event.findFirst({
|
const existing = await prisma.event.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -123,6 +168,8 @@ export async function POST(request: Request) {
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
location,
|
location,
|
||||||
|
locationLat: geo ? geo.lat : null,
|
||||||
|
locationLng: geo ? geo.lng : null,
|
||||||
startAt: start,
|
startAt: start,
|
||||||
endAt: end,
|
endAt: end,
|
||||||
status: "APPROVED",
|
status: "APPROVED",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { randomUUID } from "crypto";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import { sendMail } from "../../../../lib/mailer";
|
import { sendMail } from "../../../../lib/mailer";
|
||||||
|
import { checkRateLimit, getRateLimitConfig } from "../../../../lib/rate-limit";
|
||||||
|
import { getClientIp } from "../../../../lib/request";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@@ -11,9 +13,37 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 });
|
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) {
|
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 } });
|
await prisma.passwordResetToken.deleteMany({ where: { userId: user.id } });
|
||||||
|
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
@@ -31,7 +61,7 @@ export async function POST(request: Request) {
|
|||||||
const resetUrl = `${baseUrl}/reset/confirm?token=${token}`;
|
const resetUrl = `${baseUrl}/reset/confirm?token=${token}`;
|
||||||
|
|
||||||
await sendMail({
|
await sendMail({
|
||||||
to: email,
|
to: normalizedEmail,
|
||||||
subject: "Passwort zurücksetzen",
|
subject: "Passwort zurücksetzen",
|
||||||
text: `Passwort zurücksetzen: ${resetUrl}`
|
text: `Passwort zurücksetzen: ${resetUrl}`
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { randomUUID } from "crypto";
|
|||||||
import { prisma } from "../../../lib/prisma";
|
import { prisma } from "../../../lib/prisma";
|
||||||
import { isAdminEmail, isSuperAdminEmail } from "../../../lib/auth";
|
import { isAdminEmail, isSuperAdminEmail } from "../../../lib/auth";
|
||||||
import { sendMail } from "../../../lib/mailer";
|
import { sendMail } from "../../../lib/mailer";
|
||||||
|
import { checkRateLimit, getRateLimitConfig } from "../../../lib/rate-limit";
|
||||||
|
import { getClientIp } from "../../../lib/request";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const registrationSetting = await prisma.setting.findUnique({
|
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 });
|
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 } });
|
const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return NextResponse.json({ error: "Account existiert bereits." }, { status: 409 });
|
return NextResponse.json({ error: "Account existiert bereits." }, { status: 409 });
|
||||||
|
|||||||
39
app/api/settings/app-name/route.ts
Normal file
39
app/api/settings/app-name/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ const MIME_TO_EXT: Record<string, string> = {
|
|||||||
"image/webp": "webp",
|
"image/webp": "webp",
|
||||||
"image/svg+xml": "svg"
|
"image/svg+xml": "svg"
|
||||||
};
|
};
|
||||||
|
const MAX_FILE_SIZE = 2 * 1024 * 1024;
|
||||||
|
|
||||||
const resolveLogoPath = (relativePath: string) => {
|
const resolveLogoPath = (relativePath: string) => {
|
||||||
const absolutePath = path.join(DATA_DIR, relativePath);
|
const absolutePath = path.join(DATA_DIR, relativePath);
|
||||||
@@ -51,6 +52,9 @@ export async function POST(request: Request) {
|
|||||||
if (!extension) {
|
if (!extension) {
|
||||||
return NextResponse.json({ error: "Dateityp nicht unterstützt." }, { status: 400 });
|
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 });
|
await fs.mkdir(UPLOADS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { randomUUID } from "crypto";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import { sendMail } from "../../../../lib/mailer";
|
import { sendMail } from "../../../../lib/mailer";
|
||||||
|
import { checkRateLimit, getRateLimitConfig } from "../../../../lib/rate-limit";
|
||||||
|
import { getClientIp } from "../../../../lib/request";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@@ -11,7 +13,23 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 });
|
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) {
|
if (!user) {
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -20,13 +38,21 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ ok: true });
|
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 token = randomUUID();
|
||||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
await prisma.verificationToken.create({
|
await prisma.verificationToken.create({
|
||||||
data: {
|
data: {
|
||||||
identifier: email,
|
identifier: normalizedEmail,
|
||||||
token,
|
token,
|
||||||
expires
|
expires
|
||||||
}
|
}
|
||||||
@@ -35,7 +61,7 @@ export async function POST(request: Request) {
|
|||||||
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
|
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
|
||||||
const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`;
|
const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`;
|
||||||
await sendMail({
|
await sendMail({
|
||||||
to: email,
|
to: normalizedEmail,
|
||||||
subject: "E-Mail verifizieren",
|
subject: "E-Mail verifizieren",
|
||||||
text: `Bitte verifiziere deine E-Mail: ${verifyUrl}`
|
text: `Bitte verifiziere deine E-Mail: ${verifyUrl}`
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,3 +55,35 @@ export async function GET() {
|
|||||||
|
|
||||||
return NextResponse.json(hydrated, { status: 201 });
|
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);
|
||||||
|
}
|
||||||
|
|||||||
140
app/globals.css
140
app/globals.css
@@ -128,29 +128,129 @@ html[data-theme="dark"] .btn-accent:hover {
|
|||||||
background: var(--accent-strong);
|
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 {
|
html[data-theme="dark"] .btn-ghost {
|
||||||
border-color: rgba(71, 85, 105, 0.6);
|
border-color: rgba(71, 85, 105, 0.6);
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
background: rgba(15, 17, 16, 0.35);
|
background: rgba(30, 41, 59, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .btn-ghost:hover {
|
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"] input,
|
||||||
html[data-theme="dark"] select,
|
html[data-theme="dark"] select,
|
||||||
html[data-theme="dark"] textarea {
|
html[data-theme="dark"] textarea {
|
||||||
background: rgba(15, 17, 16, 0.65);
|
background: rgba(30, 41, 59, 0.55);
|
||||||
border-color: rgba(71, 85, 105, 0.5);
|
border-color: rgba(148, 163, 184, 0.45);
|
||||||
color: #f8fafc;
|
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 {
|
html[data-theme="dark"] header {
|
||||||
border-color: rgba(71, 85, 105, 0.35);
|
border-color: rgba(71, 85, 105, 0.35);
|
||||||
background: rgba(15, 17, 16, 0.8);
|
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 {
|
html[data-theme="dark"] .fc .fc-button {
|
||||||
border-color: rgba(71, 85, 105, 0.5);
|
border-color: rgba(71, 85, 105, 0.5);
|
||||||
background: rgba(15, 17, 16, 0.75);
|
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);
|
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 {
|
html[data-theme="dark"] .drag-handle {
|
||||||
border-color: rgba(71, 85, 105, 0.5);
|
border-color: rgba(71, 85, 105, 0.5);
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
@@ -225,8 +349,8 @@ html[data-theme="dark"] .drag-handle:hover {
|
|||||||
.fc .fc-timegrid-event {
|
.fc .fc-timegrid-event {
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.6rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: #1f2937;
|
background: #e2e8f0;
|
||||||
color: #ffffff;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-daygrid-event .fc-event-main,
|
.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-timegrid-event .fc-event-title,
|
||||||
.fc .fc-daygrid-event .fc-event-time,
|
.fc .fc-daygrid-event .fc-event-time,
|
||||||
.fc .fc-timegrid-event .fc-event-time {
|
.fc .fc-timegrid-event .fc-event-time {
|
||||||
color: #ffffff;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-daygrid-event .event-shell,
|
.fc .fc-daygrid-event .event-shell,
|
||||||
.fc .fc-timegrid-event .event-shell {
|
.fc .fc-timegrid-event .event-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-right: 1.75rem;
|
padding-right: 1.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fc .fc-daygrid-event .event-toggle,
|
.fc .fc-daygrid-event .event-toggle,
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export default function LoginPage() {
|
|||||||
setError("Zu viele Versuche. Bitte später erneut versuchen.");
|
setError("Zu viele Versuche. Bitte später erneut versuchen.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result.error === "RATE_LIMIT") {
|
||||||
|
setError("Zu viele Anfragen. Bitte später erneut versuchen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError("Login fehlgeschlagen.");
|
setError("Login fehlgeschlagen.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,27 @@
|
|||||||
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function Providers({ children }: { children: ReactNode }) {
|
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 <SessionProvider>{children}</SessionProvider>;
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -17,6 +17,9 @@ export default function SettingsPage() {
|
|||||||
const [profileStatus, setProfileStatus] = useState<string | null>(null);
|
const [profileStatus, setProfileStatus] = useState<string | null>(null);
|
||||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||||
const [copyStatus, setCopyStatus] = useState<"success" | "error" | null>(null);
|
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 () => {
|
const loadView = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -25,6 +28,10 @@ export default function SettingsPage() {
|
|||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
setViewToken(payload.token);
|
setViewToken(payload.token);
|
||||||
setViewId(payload.id);
|
setViewId(payload.id);
|
||||||
|
setIcalPastDays(
|
||||||
|
typeof payload.icalPastDays === "number" ? payload.icalPastDays : 14
|
||||||
|
);
|
||||||
|
icalReadyRef.current = true;
|
||||||
const ids = new Set<string>(
|
const ids = new Set<string>(
|
||||||
(payload.categories || []).map((item: { categoryId: string }) => item.categoryId)
|
(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(() => {
|
useEffect(() => {
|
||||||
if (data?.user) {
|
if (data?.user) {
|
||||||
loadView();
|
loadView();
|
||||||
loadCategories();
|
loadCategories();
|
||||||
|
loadAppName();
|
||||||
}
|
}
|
||||||
}, [data?.user]);
|
}, [data?.user]);
|
||||||
|
|
||||||
@@ -106,7 +126,39 @@ export default function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const baseUrl = typeof window === "undefined" ? "" : window.location.origin;
|
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") => {
|
const applyTheme = (next: "light" | "dark") => {
|
||||||
setTheme(next);
|
setTheme(next);
|
||||||
@@ -247,17 +299,30 @@ export default function SettingsPage() {
|
|||||||
Dein Link kann in externen Kalender-Apps abonniert werden.
|
Dein Link kann in externen Kalender-Apps abonniert werden.
|
||||||
</p>
|
</p>
|
||||||
{viewToken ? (
|
{viewToken ? (
|
||||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm">
|
<div className="ical-link flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">
|
||||||
<p className="font-medium">iCal URL</p>
|
iCal
|
||||||
<div className="relative">
|
</span>
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 truncate text-slate-700"
|
||||||
|
title={icalUrl}
|
||||||
|
>
|
||||||
|
{icalUrl}
|
||||||
|
</span>
|
||||||
|
<div className="relative flex items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copyIcalUrl}
|
onClick={copyIcalUrl}
|
||||||
aria-label="iCal-Link kopieren"
|
aria-label="iCal-Link kopieren"
|
||||||
className="rounded-full border border-slate-200 p-2 text-slate-600 transition hover:bg-slate-100"
|
className="rounded-full border border-slate-200 p-2 text-slate-600 transition hover:bg-slate-100"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||||
<rect x="3" y="3" width="13" height="13" rx="2" />
|
<rect x="3" y="3" width="13" height="13" rx="2" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -275,15 +340,32 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="break-all text-slate-700">{icalUrl}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-slate-600">iCal-Link wird geladen...</p>
|
<p className="text-sm text-slate-600">iCal-Link wird geladen...</p>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="btn-ghost" onClick={rotateToken}>
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="flex items-center gap-3 text-sm text-slate-700">
|
||||||
|
<span className="relative inline-flex h-6 w-11 items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="peer sr-only"
|
||||||
|
checked={icalPastDays > 0}
|
||||||
|
onChange={(event) => setIcalPastDays(event.target.checked ? 14 : 0)}
|
||||||
|
/>
|
||||||
|
<span className="h-6 w-11 rounded-full bg-slate-200 transition peer-checked:bg-emerald-500"></span>
|
||||||
|
<span className="absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-5"></span>
|
||||||
|
</span>
|
||||||
|
Rückblick der letzten 14 Tage aktivieren
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="hidden" onClick={rotateToken}>
|
||||||
Link erneuern
|
Link erneuern
|
||||||
</button>
|
</button>
|
||||||
{status && <p className="text-sm text-emerald-600">{status}</p>}
|
{status && (
|
||||||
|
<div className="fixed bottom-6 right-6 z-40 rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-lg">
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import Pagination from "./Pagination";
|
||||||
|
|
||||||
type EventItem = {
|
type EventItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +15,7 @@ type EventItem = {
|
|||||||
locationLng?: number | null;
|
locationLng?: number | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
category?: { id: string; name: string } | null;
|
category?: { id: string; name: string } | null;
|
||||||
|
createdBy?: { name?: string | null; email?: string | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
@@ -33,6 +35,12 @@ export default function AdminPanel() {
|
|||||||
const [editStatus, setEditStatus] = useState<string | null>(null);
|
const [editStatus, setEditStatus] = useState<string | null>(null);
|
||||||
const [editError, setEditError] = useState<string | null>(null);
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
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<File | null>(null);
|
const [importFile, setImportFile] = useState<File | null>(null);
|
||||||
const [importCategoryId, setImportCategoryId] = useState("");
|
const [importCategoryId, setImportCategoryId] = useState("");
|
||||||
const [importStatus, setImportStatus] = useState<string | null>(null);
|
const [importStatus, setImportStatus] = useState<string | null>(null);
|
||||||
@@ -80,6 +88,43 @@ export default function AdminPanel() {
|
|||||||
loadAllEvents();
|
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") => {
|
const updateStatus = async (id: string, status: "APPROVED" | "REJECTED") => {
|
||||||
await fetch(`/api/events/${id}`, {
|
await fetch(`/api/events/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -298,25 +343,31 @@ export default function AdminPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4 fade-up">
|
<section className="space-y-4 fade-up">
|
||||||
|
<section className="card space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Admin</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
<h1 className="text-2xl font-semibold">Offene Vorschläge</h1>
|
Vorschläge
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-semibold">Terminvorschläge</h2>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<div className="card-muted">
|
|
||||||
<p className="text-slate-600">Keine offenen Vorschläge.</p>
|
<p className="text-slate-600">Keine offenen Vorschläge.</p>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<div key={event.id} className="card">
|
<div key={event.id} className="card-muted">
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-medium">{event.title}</h2>
|
<h3 className="text-lg font-medium">{event.title}</h3>
|
||||||
<p className="text-sm text-slate-600">
|
<p className="text-sm text-slate-600">
|
||||||
{new Date(event.startAt).toLocaleString()} - {new Date(event.endAt).toLocaleString()}
|
{new Date(event.startAt).toLocaleString()} - {new Date(event.endAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
|
{event.createdBy && (
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Vorschlag von {event.createdBy.name || event.createdBy.email || "Unbekannt"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{event.location && (
|
{event.location && (
|
||||||
<p className="text-sm text-slate-600">Ort: {event.location}</p>
|
<p className="text-sm text-slate-600">Ort: {event.location}</p>
|
||||||
)}
|
)}
|
||||||
@@ -345,6 +396,7 @@ export default function AdminPanel() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
<section className="card space-y-4">
|
<section className="card space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
@@ -375,30 +427,56 @@ export default function AdminPanel() {
|
|||||||
Noch keine Kategorien.
|
Noch keine Kategorien.
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
categories.map((category) => (
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{categories.map((category) => (
|
||||||
<div
|
<div
|
||||||
key={category.id}
|
key={category.id}
|
||||||
className="flex items-center justify-between rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-700"
|
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"
|
||||||
>
|
>
|
||||||
<span>{category.name}</span>
|
<span className="font-medium">{category.name}</span>
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
|
className="rounded-full border border-slate-200 p-1 text-slate-600"
|
||||||
onClick={() => openCategoryModal(category)}
|
onClick={() => openCategoryModal(category)}
|
||||||
|
aria-label="Kategorie bearbeiten"
|
||||||
>
|
>
|
||||||
Bearbeiten
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 20h4l10-10-4-4L4 16v4z"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M13 7l4 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-full border border-red-200 px-3 py-1 text-xs text-red-600"
|
className="rounded-full border border-red-200 p-1 text-red-600"
|
||||||
onClick={() => deleteCategory(category.id)}
|
onClick={() => deleteCategory(category.id)}
|
||||||
|
aria-label="Kategorie löschen"
|
||||||
>
|
>
|
||||||
Löschen
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18" strokeLinecap="round" />
|
||||||
|
<path d="M8 6V4h8v2" strokeLinecap="round" />
|
||||||
|
<path d="M19 6l-1 14H6L5 6" strokeLinecap="round" />
|
||||||
|
<path d="M10 11v6M14 11v6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -447,14 +525,20 @@ export default function AdminPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<form onSubmit={importIcal} className="space-y-3">
|
<form onSubmit={importIcal} className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="btn-ghost cursor-pointer">
|
||||||
|
Datei auswählen
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".ics,text/calendar"
|
accept=".ics,text/calendar"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setImportFile(event.currentTarget.files?.[0] || null)
|
setImportFile(event.currentTarget.files?.[0] || null)
|
||||||
}
|
}
|
||||||
className="block text-sm text-slate-600"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="text-sm text-slate-600">
|
||||||
|
{importFile ? importFile.name : "Keine Datei ausgewählt"}
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
value={importCategoryId}
|
value={importCategoryId}
|
||||||
onChange={(event) => setImportCategoryId(event.target.value)}
|
onChange={(event) => setImportCategoryId(event.target.value)}
|
||||||
@@ -485,6 +569,17 @@ export default function AdminPanel() {
|
|||||||
</p>
|
</p>
|
||||||
<h2 className="text-lg font-semibold">Alle Termine verwalten</h2>
|
<h2 className="text-lg font-semibold">Alle Termine verwalten</h2>
|
||||||
</div>
|
</div>
|
||||||
|
{allEvents.length > 0 ? (
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-600">Keine Termine vorhanden.</p>
|
||||||
|
)}
|
||||||
{isEditOpen && editEvent && (
|
{isEditOpen && editEvent && (
|
||||||
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6">
|
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6">
|
||||||
<div className="card w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
<div className="card w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
@@ -581,10 +676,38 @@ export default function AdminPanel() {
|
|||||||
<table className="min-w-full text-left text-sm">
|
<table className="min-w-full text-left text-sm">
|
||||||
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="pb-2">Datum</th>
|
<th className="pb-2">
|
||||||
<th className="pb-2">Titel</th>
|
<SortButton
|
||||||
<th className="pb-2">Kategorie</th>
|
label="Datum"
|
||||||
<th className="pb-2">Status</th>
|
active={sortKey === "startAt"}
|
||||||
|
direction={sortDir}
|
||||||
|
onClick={() => toggleSort("startAt")}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="pb-2">
|
||||||
|
<SortButton
|
||||||
|
label="Titel"
|
||||||
|
active={sortKey === "title"}
|
||||||
|
direction={sortDir}
|
||||||
|
onClick={() => toggleSort("title")}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="pb-2">
|
||||||
|
<SortButton
|
||||||
|
label="Kategorie"
|
||||||
|
active={sortKey === "category"}
|
||||||
|
direction={sortDir}
|
||||||
|
onClick={() => toggleSort("category")}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="pb-2">
|
||||||
|
<SortButton
|
||||||
|
label="Status"
|
||||||
|
active={sortKey === "status"}
|
||||||
|
direction={sortDir}
|
||||||
|
onClick={() => toggleSort("status")}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th className="pb-2">Aktion</th>
|
<th className="pb-2">Aktion</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -596,7 +719,9 @@ export default function AdminPanel() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
allEvents.map((event) => (
|
sortedEvents
|
||||||
|
.slice((page - 1) * pageSize, page * pageSize)
|
||||||
|
.map((event) => (
|
||||||
<tr key={event.id} className="border-t border-slate-200">
|
<tr key={event.id} className="border-t border-slate-200">
|
||||||
<td className="py-3 pr-3">
|
<td className="py-3 pr-3">
|
||||||
{new Date(event.startAt).toLocaleString("de-DE", {
|
{new Date(event.startAt).toLocaleString("de-DE", {
|
||||||
@@ -617,20 +742,46 @@ export default function AdminPanel() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
|
className="rounded-full border border-slate-200 p-2 text-slate-600"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditEvent(event);
|
setEditEvent(event);
|
||||||
setIsEditOpen(true);
|
setIsEditOpen(true);
|
||||||
}}
|
}}
|
||||||
|
aria-label="Termin bearbeiten"
|
||||||
>
|
>
|
||||||
Bearbeiten
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4 20h4l10-10-4-4L4 16v4z"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M13 7l4 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-full border border-red-200 px-3 py-1 text-xs text-red-600"
|
className="rounded-full border border-red-200 p-2 text-red-600"
|
||||||
onClick={() => deleteEvent(event.id)}
|
onClick={() => deleteEvent(event.id)}
|
||||||
|
aria-label="Termin löschen"
|
||||||
>
|
>
|
||||||
Löschen
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18" strokeLinecap="round" />
|
||||||
|
<path d="M8 6V4h8v2" strokeLinecap="round" />
|
||||||
|
<path d="M19 6l-1 14H6L5 6" strokeLinecap="round" />
|
||||||
|
<path d="M10 11v6M14 11v6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -645,6 +796,34 @@ export default function AdminPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SortButton({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
direction,
|
||||||
|
onClick
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`inline-flex items-center gap-2 text-xs uppercase tracking-[0.2em] ${
|
||||||
|
active ? "text-slate-700" : "text-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{active && (
|
||||||
|
<span aria-hidden="true" className="text-[10px]">
|
||||||
|
{direction === "asc" ? "▲" : "▼"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
function StatusIcon({ status }: { status: string }) {
|
function StatusIcon({ status }: { status: string }) {
|
||||||
if (status === "APPROVED") {
|
if (status === "APPROVED") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default function AdminSystemSettings() {
|
|||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [provider, setProvider] = useState("osm");
|
const [provider, setProvider] = useState("osm");
|
||||||
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
||||||
|
const [appName, setAppName] = useState("Vereinskalender");
|
||||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||||
const [logoVersion, setLogoVersion] = useState(() => Date.now());
|
const [logoVersion, setLogoVersion] = useState(() => Date.now());
|
||||||
const [hasLogo, setHasLogo] = useState<boolean | null>(null);
|
const [hasLogo, setHasLogo] = useState<boolean | null>(null);
|
||||||
@@ -14,14 +15,21 @@ export default function AdminSystemSettings() {
|
|||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/settings/google-places");
|
const [placesResponse, appNameResponse] = await Promise.all([
|
||||||
if (!response.ok) {
|
fetch("/api/settings/system"),
|
||||||
|
fetch("/api/settings/app-name")
|
||||||
|
]);
|
||||||
|
if (!placesResponse.ok) {
|
||||||
throw new Error("Einstellungen konnten nicht geladen werden.");
|
throw new Error("Einstellungen konnten nicht geladen werden.");
|
||||||
}
|
}
|
||||||
const payload = await response.json();
|
const payload = await placesResponse.json();
|
||||||
setApiKey(payload.apiKey || "");
|
setApiKey(payload.apiKey || "");
|
||||||
setProvider(payload.provider || "osm");
|
setProvider(payload.provider || "osm");
|
||||||
setRegistrationEnabled(payload.registrationEnabled !== false);
|
setRegistrationEnabled(payload.registrationEnabled !== false);
|
||||||
|
if (appNameResponse.ok) {
|
||||||
|
const appPayload = await appNameResponse.json();
|
||||||
|
setAppName(appPayload.name || "Vereinskalender");
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
}
|
}
|
||||||
@@ -49,18 +57,31 @@ export default function AdminSystemSettings() {
|
|||||||
setStatus(null);
|
setStatus(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await fetch("/api/settings/google-places", {
|
const [settingsResponse, appNameResponse] = await Promise.all([
|
||||||
|
fetch("/api/settings/system", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ apiKey, provider, registrationEnabled })
|
body: JSON.stringify({ apiKey, provider, registrationEnabled })
|
||||||
});
|
}),
|
||||||
|
fetch("/api/settings/app-name", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: appName })
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!settingsResponse.ok) {
|
||||||
const data = await response.json();
|
const data = await settingsResponse.json();
|
||||||
setError(data.error || "Speichern fehlgeschlagen.");
|
setError(data.error || "Speichern fehlgeschlagen.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!appNameResponse.ok) {
|
||||||
|
const data = await appNameResponse.json();
|
||||||
|
setError(data.error || "App-Name konnte nicht gespeichert werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus("Gespeichert.");
|
setStatus("Gespeichert.");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,20 +162,40 @@ export default function AdminSystemSettings() {
|
|||||||
<p className="text-sm text-slate-500">Kein Logo hinterlegt.</p>
|
<p className="text-sm text-slate-500">Kein Logo hinterlegt.</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="btn-ghost cursor-pointer">
|
||||||
|
Datei auswählen
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setLogoFile(event.currentTarget.files?.[0] || null)
|
setLogoFile(event.currentTarget.files?.[0] || null)
|
||||||
}
|
}
|
||||||
className="block text-sm text-slate-600"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="text-sm text-slate-600">
|
||||||
|
{logoFile ? logoFile.name : "Keine Datei ausgewählt"}
|
||||||
|
</span>
|
||||||
<button type="submit" className="btn-accent">
|
<button type="submit" className="btn-accent">
|
||||||
Logo hochladen
|
Logo hochladen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form onSubmit={onSubmit} className="space-y-3">
|
<form onSubmit={onSubmit} className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700">
|
||||||
|
App-Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={appName}
|
||||||
|
onChange={(event) => setAppName(event.target.value)}
|
||||||
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
placeholder="Vereinskalender"
|
||||||
|
required
|
||||||
|
maxLength={60}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-700">
|
<label className="text-sm font-medium text-slate-700">
|
||||||
Ortsanbieter
|
Ortsanbieter
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -84,7 +84,7 @@ export default function EventForm({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadKey = async () => {
|
const loadKey = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/settings/google-places");
|
const response = await fetch("/api/settings/system");
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
setPlacesKey(payload.apiKey || "");
|
setPlacesKey(payload.apiKey || "");
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ export default function NavBar() {
|
|||||||
const isAdmin = data?.user?.role === "ADMIN" || data?.user?.role === "SUPERADMIN";
|
const isAdmin = data?.user?.role === "ADMIN" || data?.user?.role === "SUPERADMIN";
|
||||||
const isSuperAdmin = data?.user?.role === "SUPERADMIN";
|
const isSuperAdmin = data?.user?.role === "SUPERADMIN";
|
||||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||||
|
const [logoBrightness, setLogoBrightness] = useState<number | null>(null);
|
||||||
|
const [isDarkTheme, setIsDarkTheme] = useState(false);
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [appName, setAppName] = useState("Vereinskalender");
|
||||||
const linkClass = (href: string) =>
|
const linkClass = (href: string) =>
|
||||||
pathname === href
|
pathname === href
|
||||||
? "rounded-full bg-slate-900 px-3 py-1 text-white"
|
? "nav-link-active rounded-full px-3 py-1"
|
||||||
: "rounded-full px-3 py-1 text-slate-700 hover:bg-slate-100";
|
: "nav-link rounded-full px-3 py-1";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadLogo = async () => {
|
const loadLogo = async () => {
|
||||||
@@ -35,21 +40,113 @@ export default function NavBar() {
|
|||||||
loadLogo();
|
loadLogo();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAppName = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/settings/app-name");
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = await response.json();
|
||||||
|
setAppName(payload.name || "Vereinskalender");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAppName();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
const updateTheme = () => {
|
||||||
|
setIsDarkTheme(root.dataset.theme === "dark");
|
||||||
|
};
|
||||||
|
updateTheme();
|
||||||
|
const observer = new MutationObserver(updateTheme);
|
||||||
|
observer.observe(root, { attributes: true, attributeFilter: ["data-theme"] });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
setIsScrolled(window.scrollY > 12);
|
||||||
|
};
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logoUrl) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.src = logoUrl;
|
||||||
|
img.onload = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const size = 32;
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.drawImage(img, 0, 0, size, size);
|
||||||
|
const data = ctx.getImageData(0, 0, size, size).data;
|
||||||
|
let total = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const alpha = data[i + 3];
|
||||||
|
if (alpha === 0) continue;
|
||||||
|
const r = data[i];
|
||||||
|
const g = data[i + 1];
|
||||||
|
const b = data[i + 2];
|
||||||
|
total += 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
if (count > 0) {
|
||||||
|
setLogoBrightness(total / count);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [logoUrl]);
|
||||||
|
|
||||||
|
const shouldInvertLogo =
|
||||||
|
logoBrightness !== null &&
|
||||||
|
((isDarkTheme && logoBrightness > 140) ||
|
||||||
|
(!isDarkTheme && logoBrightness < 200));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 border-b border-slate-200/70 bg-white/70 backdrop-blur">
|
<header className="sticky top-0 z-20 border-b border-slate-200/70 bg-white/70 backdrop-blur transition-all duration-300">
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
|
<div
|
||||||
<Link href="/" className="flex items-center gap-3 text-lg font-semibold tracking-tight text-slate-900">
|
className={`mx-auto flex max-w-6xl items-center justify-between px-4 transition-all duration-300 ${
|
||||||
|
isScrolled ? "py-2" : "py-4"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className={`brand-title flex items-center gap-3 font-semibold tracking-tight text-slate-900 transition-all duration-300 ${
|
||||||
|
isScrolled ? "text-base" : "text-lg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{logoUrl && (
|
{logoUrl && (
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
alt="Vereinskalender Logo"
|
alt="Vereinskalender Logo"
|
||||||
className="h-8 w-auto max-w-[140px] object-contain"
|
className={`w-auto object-contain transition-all duration-300 ${
|
||||||
|
isScrolled ? "h-7 max-w-[140px]" : "h-12 max-w-[210px]"
|
||||||
|
}`}
|
||||||
|
style={shouldInvertLogo ? { filter: "invert(1)" } : undefined}
|
||||||
onError={() => setLogoUrl(null)}
|
onError={() => setLogoUrl(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span>Vereinskalender</span>
|
<span>{appName}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-3 text-sm">
|
<nav className="hidden items-center gap-3 text-sm md:flex">
|
||||||
{data?.user && (
|
{data?.user && (
|
||||||
<>
|
<>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -71,8 +168,18 @@ export default function NavBar() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => signOut()}
|
onClick={() => signOut()}
|
||||||
className="btn-primary"
|
className="btn-primary inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M10 6h8a2 2 0 012 2v8a2 2 0 01-2 2h-8" strokeLinecap="round" />
|
||||||
|
<path d="M14 12H4m0 0l3-3M4 12l3 3" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -85,6 +192,99 @@ export default function NavBar() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full border border-slate-200 p-2 text-slate-600 md:hidden"
|
||||||
|
onClick={() => setMobileOpen((prev) => !prev)}
|
||||||
|
aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"}
|
||||||
|
aria-expanded={mobileOpen}
|
||||||
|
>
|
||||||
|
<span className="relative block h-5 w-5">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className={`absolute inset-0 h-5 w-5 transition-all duration-300 ${
|
||||||
|
mobileOpen ? "rotate-90 opacity-0" : "rotate-0 opacity-100"
|
||||||
|
}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className={`absolute inset-0 h-5 w-5 transition-all duration-300 ${
|
||||||
|
mobileOpen ? "rotate-0 opacity-100" : "-rotate-90 opacity-0"
|
||||||
|
}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M6 6l12 12M18 6l-12 12" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{mobileOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10 bg-black/30 md:hidden"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`relative z-20 overflow-hidden transition-all duration-300 md:hidden ${
|
||||||
|
mobileOpen ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-6xl space-y-2 px-4 pb-4">
|
||||||
|
{data?.user && (
|
||||||
|
<>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<Link href="/admin" className="nav-link block rounded-xl px-3 py-2 text-sm">
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/users"
|
||||||
|
className="nav-link block rounded-xl px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
Registrierungen
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Link href="/settings" className="nav-link block rounded-xl px-3 py-2 text-sm">
|
||||||
|
Einstellungen
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{data?.user ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => signOut()}
|
||||||
|
className="btn-primary inline-flex w-full items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M10 6h8a2 2 0 012 2v8a2 2 0 01-2 2h-8" strokeLinecap="round" />
|
||||||
|
<path d="M14 12H4m0 0l3-3M4 12l3 3" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => signIn()}
|
||||||
|
className="btn-accent w-full"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
91
components/Pagination.tsx
Normal file
91
components/Pagination.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
type PaginationProps = {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
pageSize: number;
|
||||||
|
onPageSizeChange: (pageSize: number) => void;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Pagination({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
pageSize,
|
||||||
|
onPageSizeChange,
|
||||||
|
pageSizeOptions = [20, 50, 100],
|
||||||
|
className
|
||||||
|
}: PaginationProps) {
|
||||||
|
const windowPages = Array.from({ length: totalPages }, (_, i) => i + 1).filter(
|
||||||
|
(pageNumber) =>
|
||||||
|
pageNumber === 1 ||
|
||||||
|
pageNumber === totalPages ||
|
||||||
|
Math.abs(pageNumber - page) <= 2
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-center justify-between gap-2 text-sm text-slate-600 ${
|
||||||
|
className || ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Seite {page} von {totalPages}</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||||
|
className="rounded-full border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700"
|
||||||
|
aria-label="Einträge pro Seite"
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option} pro Seite
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
|
||||||
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{windowPages.map((pageNumber, index) => {
|
||||||
|
const previous = windowPages[index - 1];
|
||||||
|
const gap = previous && pageNumber - previous > 1;
|
||||||
|
return (
|
||||||
|
<span key={pageNumber} className="flex items-center gap-1">
|
||||||
|
{gap && <span className="px-1 text-xs text-slate-400">…</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full border px-2 py-1 text-xs ${
|
||||||
|
pageNumber === page
|
||||||
|
? "border-slate-900 bg-slate-900 text-white"
|
||||||
|
: "border-slate-200 text-slate-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => onPageChange(pageNumber)}
|
||||||
|
aria-current={pageNumber === page ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,10 +3,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function ViewManager() {
|
export default function ViewManager() {
|
||||||
const [view, setView] = useState<{ id: string; name: string; token: string } | null>(
|
const [view, setView] = useState<{
|
||||||
null
|
id: string;
|
||||||
);
|
name: string;
|
||||||
|
token: string;
|
||||||
|
icalPastDays?: number;
|
||||||
|
} | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [appName, setAppName] = useState("Vereinskalender");
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -20,8 +24,21 @@ export default function ViewManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
loadAppName();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -30,6 +47,13 @@ export default function ViewManager() {
|
|||||||
return () => window.removeEventListener("views-updated", handler);
|
return () => window.removeEventListener("views-updated", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toFilename = (value: string) =>
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "") || "kalender";
|
||||||
|
|
||||||
const icalBase = typeof window === "undefined" ? "" : window.location.origin;
|
const icalBase = typeof window === "undefined" ? "" : window.location.origin;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,7 +77,11 @@ export default function ViewManager() {
|
|||||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm">
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||||
<p className="font-medium">iCal URL</p>
|
<p className="font-medium">iCal URL</p>
|
||||||
<p className="break-all text-slate-700">
|
<p className="break-all text-slate-700">
|
||||||
{icalBase}/api/ical/{view.token}
|
{icalBase}/api/ical/{view.token}/{toFilename(appName)}.ical{
|
||||||
|
view.icalPastDays && view.icalPastDays > 0
|
||||||
|
? `?pastDays=${view.icalPastDays}`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
target: deps
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
10
lib/auth.ts
10
lib/auth.ts
@@ -3,6 +3,7 @@ import bcrypt from "bcryptjs";
|
|||||||
import type { NextAuthOptions } from "next-auth";
|
import type { NextAuthOptions } from "next-auth";
|
||||||
import CredentialsProvider from "next-auth/providers/credentials";
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
import { checkRateLimit, getRateLimitConfig } from "./rate-limit";
|
||||||
|
|
||||||
const MAX_LOGIN_ATTEMPTS = 5;
|
const MAX_LOGIN_ATTEMPTS = 5;
|
||||||
const LOGIN_WINDOW_MINUTES = 15;
|
const LOGIN_WINDOW_MINUTES = 15;
|
||||||
@@ -76,6 +77,15 @@ export const authOptions: NextAuthOptions = {
|
|||||||
|
|
||||||
const email = normalizeEmail(credentials.email);
|
const email = normalizeEmail(credentials.email);
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
|
const rateConfig = getRateLimitConfig("RATE_LIMIT_LOGIN", 10);
|
||||||
|
const rate = await checkRateLimit({
|
||||||
|
key: `login:${email}:${ip}`,
|
||||||
|
limit: rateConfig.limit,
|
||||||
|
windowMs: rateConfig.windowMs
|
||||||
|
});
|
||||||
|
if (!rate.ok) {
|
||||||
|
throw new Error("RATE_LIMIT");
|
||||||
|
}
|
||||||
let attempt: { id: string; attempts: number; lastAttempt: Date; lockedUntil: Date | null } | null = null;
|
let attempt: { id: string; attempts: number; lastAttempt: Date; lockedUntil: Date | null } | null = null;
|
||||||
try {
|
try {
|
||||||
attempt = await prisma.loginAttempt.findUnique({
|
attempt = await prisma.loginAttempt.findUnique({
|
||||||
|
|||||||
91
lib/ical-export.ts
Normal file
91
lib/ical-export.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import ical from "ical-generator";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
const DEFAULT_APP_NAME = "Vereinskalender";
|
||||||
|
|
||||||
|
const toFilename = (value: string) =>
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "") || "kalender";
|
||||||
|
|
||||||
|
export async function getIcalResponse(request: Request, token: string) {
|
||||||
|
const view = await prisma.userView.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: {
|
||||||
|
items: { include: { event: true } },
|
||||||
|
categories: true,
|
||||||
|
exclusions: true,
|
||||||
|
user: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!view) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const appNameSetting = await prisma.setting.findUnique({
|
||||||
|
where: { key: "app_name" }
|
||||||
|
});
|
||||||
|
const appName = appNameSetting?.value || DEFAULT_APP_NAME;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const pastDaysParam = Number(url.searchParams.get("pastDays"));
|
||||||
|
const rawPastDays =
|
||||||
|
Number.isFinite(pastDaysParam) && pastDaysParam >= 0
|
||||||
|
? pastDaysParam
|
||||||
|
: Number.isFinite(view.icalPastDays) && view.icalPastDays >= 0
|
||||||
|
? view.icalPastDays
|
||||||
|
: 14;
|
||||||
|
const pastDays = Math.min(365, Math.floor(rawPastDays));
|
||||||
|
const cutoff = new Date(Date.now() - pastDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const calendar = ical({
|
||||||
|
name: appName,
|
||||||
|
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) &&
|
||||||
|
event.startAt >= cutoff
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const filename = `${toFilename(appName)}.ical`;
|
||||||
|
|
||||||
|
return new NextResponse(calendar.toString(), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/calendar; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
57
lib/rate-limit.ts
Normal file
57
lib/rate-limit.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
type RateLimitResult = {
|
||||||
|
ok: boolean;
|
||||||
|
remaining: number;
|
||||||
|
resetAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseNumber = (value: string | undefined, fallback: number) => {
|
||||||
|
if (!value) return fallback;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRateLimitConfig = (envKey: string, defaultLimit: number) => {
|
||||||
|
const limit = parseNumber(process.env[envKey], defaultLimit);
|
||||||
|
const windowMinutes = parseNumber(process.env.RATE_LIMIT_WINDOW_MINUTES, 15);
|
||||||
|
return { limit, windowMs: windowMinutes * 60 * 1000 };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function checkRateLimit({
|
||||||
|
key,
|
||||||
|
limit,
|
||||||
|
windowMs
|
||||||
|
}: {
|
||||||
|
key: string;
|
||||||
|
limit: number;
|
||||||
|
windowMs: number;
|
||||||
|
}): Promise<RateLimitResult> {
|
||||||
|
const now = new Date();
|
||||||
|
const resetAt = new Date(now.getTime() + windowMs);
|
||||||
|
|
||||||
|
const existing = await prisma.rateLimit.findUnique({ where: { key } });
|
||||||
|
if (!existing || existing.resetAt <= now) {
|
||||||
|
await prisma.rateLimit.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { count: 1, resetAt },
|
||||||
|
create: { key, count: 1, resetAt }
|
||||||
|
});
|
||||||
|
return { ok: true, remaining: Math.max(0, limit - 1), resetAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.count >= limit) {
|
||||||
|
return { ok: false, remaining: 0, resetAt: existing.resetAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.rateLimit.update({
|
||||||
|
where: { key },
|
||||||
|
data: { count: { increment: 1 } }
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
remaining: Math.max(0, limit - updated.count),
|
||||||
|
resetAt: updated.resetAt
|
||||||
|
};
|
||||||
|
}
|
||||||
5
lib/request.ts
Normal file
5
lib/request.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const getClientIp = (req: Request) => {
|
||||||
|
const forwarded = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip");
|
||||||
|
if (!forwarded) return "unknown";
|
||||||
|
return forwarded.split(",")[0].trim() || "unknown";
|
||||||
|
};
|
||||||
@@ -55,6 +55,7 @@ model UserView {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
token String @unique
|
token String @unique
|
||||||
|
icalPastDays Int @default(14)
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
items UserViewItem[]
|
items UserViewItem[]
|
||||||
@@ -161,3 +162,12 @@ model LoginAttempt {
|
|||||||
|
|
||||||
@@unique([email, ip])
|
@@unique([email, ip])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model RateLimit {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
count Int @default(0)
|
||||||
|
resetAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user