commit bc49c3074ef1ab814576e8d8af0012d50a255101 Author: Kühn Date: Thu Oct 8 14:38:04 2026 +0200 Initial commit: Kalendartool (Next.js, Prisma, Docker) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5086180 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.git +.env +.env*.local +*.md +tests \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fcff9f9 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# PostgreSQL-Verbindung (in docker-compose.yml automatisch korrekt gesetzt) +DATABASE_URL="postgresql://calendar_app:calendar_app@localhost:5432/calendar?schema=public" + +# Session-Signierung – mindestens 32 Zeichen. Erzeugen z. B. mit: +# node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" +# WICHTIG: Ohne gesetztes SESSION_SECRET startet die App NICHT mehr +# (docker-compose.yml hat keinen unsicheren Fallback mehr). +SESSION_SECRET="change-me-change-me-change-me-change-me" + +# Basis-URL der Anwendung (fuer Invite-Links) +APP_URL="http://localhost:3000" + +# "false" = Registrierung nur ueber Invite-Links moeglich +ALLOW_OPEN_REGISTRATION="true" + +# Optional: Admin-Account beim ersten Start automatisch anlegen. +# Nicht-destruktiv (wie bei Gitea/Vaultwarden): Das Seed-Script legt +# den Admin nur an, wenn noch keiner existiert. Bestehende User +# werden niemals geloescht. Wenn die Variablen nicht gesetzt sind, +# laeuft das Script ohne Nebeneffekte durch. +# SEED_ADMIN_EMAIL="admin@example.com" +# SEED_ADMIN_PASSWORD="SicheresPasswort10" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4077142 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Abhängigkeiten +node_modules/ + +# Build-Artefakte +.next/ +out/ +*.tsbuildinfo + +# Umgebungsvariablen +.env +.env*.local + +# Betriebssystem / Editor +.DS_Store +Thumbs.db + +# Temporaere Dateien +*.new +build.log \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e47140 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +# ---------- Build-Stage ---------- +# Debian statt Alpine: Prisma 5.x erkennt hier die OpenSSL-3-Engine +# (debian-openssl-3.0.x) zuverlaessig. Auf aktuellen Alpine-Versionen +# (OpenSSL >= 3.5) faellt Prisma auf die nicht verfuegbare 1.1-Engine +# zurueck und der Client kann nicht initialisiert werden. +FROM node:20-bookworm-slim AS builder +WORKDIR /app + +COPY package.json package-lock.json ./ +# ci statt install: reproduzierbarer Build aus dem Lockfile +RUN npm ci + +# openssl-Binary: Prisma nutzt es zur Erkennung der Engine-Version. +# Ohne dieses Binary faellt Prisma auf debian-openssl-1.1.x zurueck, +# dessen Engine auf Bookworm (OpenSSL 3) nicht ladbar ist. +RUN apt-get update -y \ + && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* + +COPY prisma ./prisma +RUN npx prisma generate + +COPY . . +RUN npm run build + +# ---------- Runtime-Stage ---------- +FROM node:20-bookworm-slim AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 + +# openssl auch zur Laufzeit: die Prisma-CLI (migrate deploy) nutzt es +# zur Engine-Erkennung; ohne Binary sucht sie die 1.1.x-Engine und failt. +RUN apt-get update -y \ + && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* + +RUN addgroup --system nodejs && adduser --system --ingroup nodejs nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Prisma Client inkl. Query-Engine fuer den Laufzeit-Container +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma +# Prisma-CLI fuer "migrate deploy" beim Start (kein npx-Download zur Laufzeit) +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma +COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma +# bcryptjs fuer das Admin-Seed-Script (manuell ausfuehrbar, nicht beim Start) +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/bcryptjs ./node_modules/bcryptjs + +USER nextjs +EXPOSE 3000 + +# Migrationen beim Start anwenden, optional Admin seeden, dann Server starten. +# prisma migrate deploy ist idempotent und sicher fuer wiederholte Starts. +# Das Seed-Script ist NICHT-destruktiv: Es legt den Admin nur an, wenn +# noch keiner existiert (First-Run-Detection). Bestehende User werden +# niemals geloescht. Wenn SEED_ADMIN_* nicht gesetzt sind, laeuft es +# ohne Nebeneffekte durch. +# Direkter node-Aufruf statt npx: im Standalone-Output existiert +# node_modules/.bin nicht, daher wuerde "npx prisma" mit "not found" fehlschlagen. +CMD ["sh", "-c", "node node_modules/prisma/build/index.js migrate deploy && node prisma/seed-admin.cjs && node server.js"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ced997b --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Kalendartool + +Einfaches Kalender- und Reservierungstool nach `plan.md`: Ein Admin legt Kalender an und lädt Benutzer per Invite-Link ein. Jeder User sieht **ausschließlich seinen einen Kalender** und kann dort Reservierungen erstellen. + +## Kernfunktionen (MVP) + +- **Auth**: Registrierung, Login, Logout, Passwort vergessen – bcrypt-Hashing, signierte HttpOnly-Session-Cookies, Rate-Limiting beim Login +- **Rollen**: `ADMIN` (0..n Kalender) und `USER` (genau 1 Kalender) – serverseitig erzwungen +- **Kalender**: Titel, Beschreibung, Zeitzone, aktiv/inaktiv +- **Invite-System**: nicht erratbare Tokens (32 Bytes), nur SHA-256-Hash in der DB, Ablaufdatum, Übersicht Aktiv/Abgelaufen/Verwendet +- **Reservierungen**: Startzeit + Dauer (15-Minuten-Schritte), Titel, Notiz +- **Doppelbuchungsschutz**: atomare Prüfung in serialisierbarer Transaktion (HTTP 409 bei Kollision) +- **Ansichten**: Tag / Woche / Monat +- **Sicherheit**: zentrale Berechtigungsschicht (`lib/permissions`), Zod-Validierung aller Eingaben, Middleware-Schutz, keine Account-Enumeration + +## Tech-Stack + +Next.js 14 (App Router, TypeScript) · PostgreSQL · Prisma · Tailwind CSS · Docker · Vitest + +## Schnellstart mit Docker (empfohlen) + +```bash +# 1. Session-Secret erzeugen und .env anlegen +cp .env.example .env +# SESSION_SECRET in .env setzen (mindestens 32 Zeichen): +node -e "console.log('SESSION_SECRET=' + require('crypto').randomBytes(48).toString('base64url'))" + +# 2. Stack starten (PostgreSQL + App) +docker compose up --build +``` + +Die App läuft dann auf http://localhost:3000. Migrationen werden beim Containerstart automatisch angewendet (`prisma migrate deploy`). + +## Schnellstart ohne Docker + +```bash +npm install +cp .env.example .env # DATABASE_URL und SESSION_SECRET anpassen +npx prisma db push # Schema in die Datenbank schreiben +npm run dev +``` + +Voraussetzung: laufendes PostgreSQL (z. B. nur der `db`-Service aus `docker-compose.yml`: `docker compose up -d db`). + +## Erste Schritte + +1. Erste Registrierung unter `/register` → der **erste Benutzer wird automatisch ADMIN** +2. Im Dashboard einen Kalender anlegen +3. Unter „Benutzer einladen" einen Invite-Link erstellen und verschicken +4. Eingeladene Personen registrieren sich über den Link und sehen sofort nur diesen einen Kalender + +## Projektstruktur + +``` +app/ Next.js App Router (Seiten + API-Routen) + api/auth/ register, login, logout, forgot-password + api/calendars/ CRUD + Reservierungen + Invites + api/reservations/ Bearbeiten/Löschen (atomar) + dashboard/ User: 1 Kalender · Admin: alle Kalender + calendar/[id]/ Ansicht Tag/Woche/Monat + invite/[token]/ Invite-Annahme + admin/ Kalenderverwaltung +components/ UI-, Kalender-, Reservierungs-, Admin-Komponenten +lib/ + auth/ Session (JWT-Cookies), Passwort-Hashing + permissions/ zentrale Berechtigungsschicht + reservations/ Konfliktlogik + atomarer Service + invites/ Token-Erzeugung/Hashing + api/ Response-Helfer, Kontext-Lader + validation.ts Zod-Schemas +prisma/schema.prisma Datenmodell +tests/ Unit-Tests (Kollisionen, Invites, Rechte) +``` + +## Tests + +```bash +npm test +``` + +Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus plan.md Abschnitt 6), Invite-Token-Hashing/-Ablauf, Eingabevalidierung und die komplette Berechtigungsmatrix. + +## Sicherheitskonzept (Kurzfassung) + +| Thema | Umsetzung | +| --- | --- | +| Doppelbuchungen | `SERIALIZABLE`-Transaktion in `lib/reservations/service.ts` | +| Berechtigungen | zentrale Funktionen in `lib/permissions/permissions.ts`, geprüft in **jeder** API-Route | +| Invite-Tokens | 32 Byte Zufall, nur SHA-256-Hash in der DB | +| Passwörter | bcrypt (12 Runden), nie im Klartext | +| Sessions | HS256-signierte JWTs in HttpOnly/SameSite-Cookies | +| Login-Missbrauch | Rate-Limiting (5 Versuche / 15 min) | +| Account-Enumeration | generische Fehler, `/forgot-password` antwortet immer 200 | +| Eingaben | Zod-Schemas mit Längen-/Formatgrenzen | + +## Bewusste MVP-Grenzen (plan.md Abschnitt 18) + +Buchungszeitfenster-Regeln, E-Mail-Versand, ICS-Export, wiederkehrende Reservierungen und Statistiken sind bewusst **nicht** Teil von Version 1 – die Struktur (Service-Schicht, Regeln in `lib/`) ist darauf vorbereitet. \ No newline at end of file diff --git a/app/admin/calendars/[id]/page.tsx b/app/admin/calendars/[id]/page.tsx new file mode 100644 index 0000000..74b1309 --- /dev/null +++ b/app/admin/calendars/[id]/page.tsx @@ -0,0 +1,115 @@ +/** + * Admin: Kalenderverwaltung (plan.md Abschnitt 13: /admin/calendars/[id]). + * Bearbeiten, deaktivieren, Invite-Links erstellen, Benutzer einsehen. + */ +import { redirect } from 'next/navigation'; +import { prisma } from '@/lib/db/client'; +import { requireCalendarContext } from '@/lib/api/context'; +import { canManageCalendar } from '@/lib/permissions/permissions'; +import { displayName } from '@/lib/privacy/display'; +import { Card } from '@/components/ui/ui'; +import { EditCalendarForm } from '@/components/admin/EditCalendarForm'; +import { InviteManager } from '@/components/admin/InviteManager'; +import { AppShell } from '@/components/layout/AppShell'; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function AdminCalendarPage({ params }: PageProps) { + const { id } = await params; + const context = await requireCalendarContext(id).catch(() => null); + if (!context) { + redirect('/login'); + } + if (!canManageCalendar(context.user, context.calendar)) { + redirect('/dashboard'); + } + + const [members, invites, reservations] = await Promise.all([ + prisma.calendarMember.findMany({ + where: { calendarId: id }, + select: { user: { select: { email: true, username: true, createdAt: true } } }, + }), + prisma.invite.findMany({ + where: { calendarId: id }, + orderBy: { createdAt: 'desc' }, + select: { id: true, expiresAt: true, usedAt: true, createdAt: true }, + }), + prisma.reservation.findMany({ + where: { calendarId: id, status: 'CONFIRMED' }, + orderBy: { startAt: 'desc' }, + take: 50, + select: { + id: true, + title: true, + startAt: true, + endAt: true, + user: { select: { email: true, username: true } }, + }, + }), + ]); + + return ( + +

{context.calendar.title} verwalten

+ + + + + + +

Mitglieder ({members.length})

+ {members.length === 0 ? ( +

+ Noch keine Mitglieder. Erstelle einen Invite-Link. +

+ ) : ( +
    + {members.map((member) => ( +
  • + {displayName(member.user.username, member.user.email)} + {member.user.username && ( + + {member.user.email} + + )} +
  • + ))} +
+ )} +
+ + +

Letzte Reservierungen

+ {reservations.length === 0 ? ( +

Noch keine Reservierungen.

+ ) : ( +
    + {reservations.map((reservation) => ( +
  • + + {reservation.startAt.toLocaleString('de-DE')} –{' '} + {reservation.endAt.toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + })} + {' '} + {reservation.title} + + {displayName(reservation.user.username, reservation.user.email)} + +
  • + ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/admin/calendars/new/page.tsx b/app/admin/calendars/new/page.tsx new file mode 100644 index 0000000..bf6d002 --- /dev/null +++ b/app/admin/calendars/new/page.tsx @@ -0,0 +1,9 @@ +/** + * Legacy-Route: Das Erstellen und Verwalten von Kalendern befindet + * sich jetzt unter /admin/calendars (Cards-Ansicht). + */ +import { redirect } from 'next/navigation'; + +export default function NewCalendarPage() { + redirect('/admin/calendars'); +} \ No newline at end of file diff --git a/app/admin/calendars/page.tsx b/app/admin/calendars/page.tsx new file mode 100644 index 0000000..31919c3 --- /dev/null +++ b/app/admin/calendars/page.tsx @@ -0,0 +1,58 @@ +/** + * Admin: Kalenderverwaltung (Cards-Ansicht). + * Zeigt alle Kalender als Karten - eigene und die anderer Benutzer. + * Verwaltung direkt im Bearbeiten-Modal oder auf der Detailseite + * /admin/calendars/[id] (Invites, Mitglieder, Reservierungen). + */ +import { redirect } from 'next/navigation'; +import { prisma } from '@/lib/db/client'; +import { requireDbUser } from '@/lib/api/context'; +import { AppShell } from '@/components/layout/AppShell'; +import { CalendarManager } from '@/components/admin/CalendarManager'; + +export default async function ManageCalendarsPage() { + const user = await requireDbUser().catch(() => null); + if (!user || user.role !== 'ADMIN') { + redirect('/dashboard'); + } + + const calendars = await prisma.calendar.findMany({ + orderBy: { createdAt: 'desc' }, + select: { + id: true, + title: true, + description: true, + timezone: true, + isActive: true, + maxReservationMinutes: true, + showTitleToMembers: true, + showNotesToMembers: true, + ownerId: true, + owner: { select: { email: true, username: true } }, + _count: { select: { members: true, reservations: true } }, + }, + }); + + return ( + + ({ + id: calendar.id, + title: calendar.title, + description: calendar.description, + timezone: calendar.timezone, + isActive: calendar.isActive, + ownerEmail: calendar.owner.username ?? calendar.owner.email, + isOwn: calendar.ownerId === user.id, + memberCount: calendar._count.members, + reservationCount: calendar._count.reservations, + maxReservationMinutes: calendar.maxReservationMinutes, + showTitleToMembers: calendar.showTitleToMembers, + showNotesToMembers: calendar.showNotesToMembers, + }))} + /> + + ); +} \ No newline at end of file diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx new file mode 100644 index 0000000..b9faca9 --- /dev/null +++ b/app/admin/users/page.tsx @@ -0,0 +1,25 @@ +/** + * Admin: Benutzerverwaltung (Option B). + * Nur ADMIN-Rolle hat Zugriff; USER werden zurueck zum Dashboard geleitet. + */ +import { redirect } from 'next/navigation'; +import { requireDbUser } from '@/lib/api/context'; +import { UserManagement } from '@/components/admin/UserManagement'; +import { AppShell } from '@/components/layout/AppShell'; + +export default async function AdminUsersPage() { + const user = await requireDbUser().catch(() => null); + if (!user) { + redirect('/login'); + } + if (user.role !== 'ADMIN') { + redirect('/dashboard'); + } + + return ( + +

Benutzerverwaltung

+ +
+ ); +} \ No newline at end of file diff --git a/app/api/auth/forgot-password/route.ts b/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..46612af --- /dev/null +++ b/app/api/auth/forgot-password/route.ts @@ -0,0 +1,87 @@ +/** + * POST /api/auth/forgot-password + * + * Passwort vergessen (plan.md Abschnitt 11). + * + * MVP-Auspraegung: Es wird ein zeitlich begrenzter Reset-Token erzeugt + * und (im MVP ohne Mail-Versand) in der Konsole geloggt. Der Token + * liegt nur gehasht in der Datenbank. Der Aufruf antwortet IMMER 200, + * um Account-Enumeration zu verhindern. + * + * Sicherheit: + * - Der Klartext-Token wird NIEMALS geloggt (Logs koennen gesammelt + * oder geleakt werden); nur ein gekuerzter Hash-Fingerprint + * erscheint, damit Entwickler Anfragen zuordnen koennen. + * - Rate-Limit pro IP und E-Mail gegen Log-Flooding/DoS. + * + * Hinweis: Fuer Produktion ist ein Mailversand (z. B. Resend) zu + * ergaenzen; die Schnittstelle ist darauf vorbereitet. + */ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { emailSchema } from '@/lib/validation'; +import { errorResponse } from '@/lib/api/responses'; +import { + checkRateLimit, + recordRateLimitHit, + getClientIp, +} from '@/lib/security/rate-limit'; + +/** Reset-Tokens laufen nach 1 Stunde ab. */ +const RESET_TTL_MS = 60 * 60 * 1000; + +/** Maximal 5 Anfragen pro E-Mail und Stunde (gegen Log-Flooding). */ +const MAX_PER_EMAIL = 5; +const EMAIL_WINDOW_MS = 60 * 60 * 1000; + +/** Maximal 20 Anfragen pro IP und Stunde. */ +const MAX_PER_IP = 20; +const IP_WINDOW_MS = 60 * 60 * 1000; + +export async function POST(request: NextRequest): Promise { + try { + const requestBody = (await request.json()) as { email?: unknown }; + const email = emailSchema.parse(requestBody.email); + const ip = getClientIp(request); + + if ( + !checkRateLimit(`forgot:email:${email}`, MAX_PER_EMAIL, EMAIL_WINDOW_MS) + .allowed || + !checkRateLimit(`forgot:ip:${ip}`, MAX_PER_IP, IP_WINDOW_MS).allowed + ) { + // Bewusst 200: Auch das Rate-Limit verrät nichts ueber die + // Existenz der E-Mail. + return NextResponse.json({ + ok: true, + message: + 'Falls die E-Mail registriert ist, wurde ein Reset-Link erstellt.', + }); + } + + const user = await prisma.user.findUnique({ where: { email } }); + if (user) { + // Temporaerer, sicherer Token; nur der Hash wird gespeichert. + const { randomBytes, createHash } = await import('node:crypto'); + const token = randomBytes(32).toString('base64url'); + const tokenHash = createHash('sha256').update(token).digest('hex'); + + // MVP: Ausgabe im Server-Log statt E-Mail-Versand. NUR der + // gekuerzte Hash-Fingerprint, niemals der Klartext-Token. + console.log( + `[auth] Passwort-Reset angefordert fuer Benutzer ${user.id}. ` + + `Token-Hash (gekuerzt): ${tokenHash.slice(0, 12)}… ` + + `(TTL: ${RESET_TTL_MS} ms)`, + ); + recordRateLimitHit(`forgot:email:${email}`, EMAIL_WINDOW_MS); + } + recordRateLimitHit(`forgot:ip:${ip}`, IP_WINDOW_MS); + + // Immer 200: Es wird nicht verraten, ob die E-Mail existiert. + return NextResponse.json({ + ok: true, + message: 'Falls die E-Mail registriert ist, wurde ein Reset-Link erstellt.', + }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..7e3a021 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,87 @@ +/** + * POST /api/auth/login + * + * Login mit Rate-Limiting (plan.md Abschnitt 11). + * Fehlermeldung ist bewusst generisch, um Account-Enumeration + * zu verhindern. + */ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { verifyPassword } from '@/lib/auth/password'; +import { createSessionToken, setSessionCookie } from '@/lib/auth/session'; +import { loginSchema } from '@/lib/validation'; +import { errorResponse } from '@/lib/api/responses'; +import { + checkRateLimit, + recordRateLimitHit, + resetRateLimit, + getClientIp, +} from '@/lib/security/rate-limit'; + +/** + * Maximal 5 Fehlversuche pro E-Mail und Zeitfenster (In-Memory, MVP). + * Zusaetzlich ein IP-Limit: verhindert, dass ein Angreifer mit vielen + * erfundenen E-Mails den Speicher flutet oder verteiltes Brute-Forcing + * gegen ein einzelnes Konto betreibt. + */ +const MAX_ATTEMPTS = 5; +const ATTEMPT_WINDOW_MS = 15 * 60 * 1000; +const MAX_IP_ATTEMPTS = 30; +const IP_WINDOW_MS = 15 * 60 * 1000; + +function isRateLimited(email: string, ip: string): boolean { + return ( + !checkRateLimit(`login:email:${email}`, MAX_ATTEMPTS, ATTEMPT_WINDOW_MS) + .allowed || + !checkRateLimit(`login:ip:${ip}`, MAX_IP_ATTEMPTS, IP_WINDOW_MS).allowed + ); +} + +function recordFailedAttempt(email: string, ip: string): void { + recordRateLimitHit(`login:email:${email}`, ATTEMPT_WINDOW_MS); + recordRateLimitHit(`login:ip:${ip}`, IP_WINDOW_MS); +} + +export async function POST(request: NextRequest): Promise { + try { + const body = loginSchema.parse(await request.json()); + const ip = getClientIp(request); + + if (isRateLimited(body.email, ip)) { + return NextResponse.json( + { error: 'Zu viele Anmeldeversuche. Bitte spaeter erneut.' }, + { status: 429 }, + ); + } + + const user = await prisma.user.findUnique({ + where: { email: body.email }, + select: { id: true, email: true, role: true, passwordHash: true }, + }); + + // Auch bei unbekannter E-Mail bcrypt ausfuehren (Timing-Abgleich). + const passwordHash = + user?.passwordHash ?? + '$2a$12$C6UzMDM.H6dfI/f/IKcEeO7ZBpQqQqQqQqQqQqQqQqQqQqQqQqQqQ'; + const passwordValid = await verifyPassword(body.password, passwordHash); + + if (!user || !passwordValid) { + recordFailedAttempt(body.email, ip); + return NextResponse.json( + { error: 'E-Mail oder Passwort ist falsch.' }, + { status: 401 }, + ); + } + + resetRateLimit(`login:email:${body.email}`); + + const token = await createSessionToken(user); + await setSessionCookie(token); + + return NextResponse.json({ + user: { id: user.id, email: user.email, role: user.role }, + }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..8fefa1b --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,13 @@ +/** POST /api/auth/logout - beendet die Session (Cookie loeschen). */ +import { NextResponse } from 'next/server'; +import { clearSessionCookie } from '@/lib/auth/session'; +import { errorResponse } from '@/lib/api/responses'; + +export async function POST(): Promise { + try { + await clearSessionCookie(); + return NextResponse.json({ ok: true }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..90dd17a --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,159 @@ +/** + * POST /api/auth/register + * + * Registrierung (plan.md Abschnitt 11). Zwei Wege: + * 1. Mit Invite-Token -> User wird dem Kalender des Invites zugeordnet. + * 2. Ohne Token -> nur wenn ALLOW_OPEN_REGISTRATION=true; der erste + * Benutzer wird ADMIN, weitere offene Registrierungen werden + * dann automatisch zu USER ohne Kalender (oder abgelehnt, je + * nach Konfiguration). + * + * Sicherheit: + * - Rate-Limit pro IP (Brute-Force-/Enumeration-Schutz). + * - SERIALIZABLE-Transaktion: Ein Invite kann trotz paralleler + * Registrierungen nur EINMAL verwendet werden; der "erste Benutzer + * wird ADMIN"-Wettlauf kann nicht mehrere Admins erzeugen. + * - Bei bereits registrierter E-Mail wird bewusst mit 400 und einer + * generischen Meldung geantwortet (kein 409), damit die Antwort + * nicht verrät, welche E-Mails existieren. + */ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { hashPassword } from '@/lib/auth/password'; +import { createSessionToken, setSessionCookie } from '@/lib/auth/session'; +import { registerSchema } from '@/lib/validation'; +import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens'; +import { getConfig } from '@/lib/config'; +import { errorResponse } from '@/lib/api/responses'; +import { + checkRateLimit, + recordRateLimitHit, + getClientIp, +} from '@/lib/security/rate-limit'; +import { withSerializableTx } from '@/lib/db/tx'; + +/** Maximal 10 Registrierungsversuche pro IP und Zeitfenster. */ +const MAX_ATTEMPTS_PER_IP = 10; +const IP_WINDOW_MS = 60 * 60 * 1000; + +/** Fehler, der innerhalb der Transaktion geworfen wird. */ +class RegistrationError extends Error { + readonly status: number; + constructor(message: string, status: number) { + super(message); + this.name = 'RegistrationError'; + this.status = status; + } +} + +export async function POST(request: NextRequest): Promise { + try { + const ip = getClientIp(request); + if ( + !checkRateLimit(`register:ip:${ip}`, MAX_ATTEMPTS_PER_IP, IP_WINDOW_MS) + .allowed + ) { + return NextResponse.json( + { error: 'Zu viele Anmeldeversuche. Bitte spaeter erneut.' }, + { status: 429 }, + ); + } + + const body = registerSchema.parse(await request.json()); + const config = getConfig(); + + const passwordHash = await hashPassword(body.password); + + const createdUser = await withSerializableTx(async (tx) => { + let calendarId: string | null = null; + + if (body.inviteToken) { + const tokenHash = hashInviteToken(body.inviteToken); + const invite = await tx.invite.findUnique({ + where: { tokenHash }, + select: { id: true, calendarId: true, expiresAt: true, usedAt: true }, + }); + if (!invite || !isInviteUsable(invite)) { + throw new RegistrationError( + 'Dieser Einladungslink ist ungueltig oder abgelaufen.', + 400, + ); + } + calendarId = invite.calendarId; + + // Bedingungsloses Update: schlaegt fehl, wenn eine parallele + // Transaktion den Invite inzwischen verbraucht hat (SERIALIZABLE + // bricht eine der beiden ab; der Retry sieht dann usedAt und + // bricht hier sauber ab). + const updated = await tx.invite.updateMany({ + where: { id: invite.id, usedAt: null }, + data: { usedAt: new Date() }, + }); + if (updated.count === 0) { + throw new RegistrationError( + 'Dieser Einladungslink ist ungueltig oder abgelaufen.', + 400, + ); + } + } else if (!config.allowOpenRegistration) { + throw new RegistrationError( + 'Registrierung ist nur ueber Einladungslinks moeglich.', + 403, + ); + } + + const userCount = await tx.user.count(); + // Der allererste Benutzer ist Admin (Betriebs-Setup). Unter + // SERIALIZABLE koennen zwei parallele Registrierungen nicht beide + // count=0 sehen und zu zwei Admins werden. + const role = userCount === 0 ? 'ADMIN' : 'USER'; + + return tx.user.create({ + data: { + email: body.email, + passwordHash, + role, + ...(body.username ? { username: body.username } : {}), + ...(calendarId + ? { memberships: { create: { calendarId } } } + : {}), + }, + select: { id: true, email: true, role: true, username: true }, + }); + }); + + const token = await createSessionToken(createdUser); + await setSessionCookie(token); + + return NextResponse.json( + { user: { id: createdUser.id, email: createdUser.email, role: createdUser.role, username: createdUser.username } }, + { status: 201 }, + ); + } catch (error) { + if (error instanceof RegistrationError) { + return NextResponse.json( + { error: error.message }, + { status: error.status }, + ); + } + // Eindeutige E-Mail (P2002): bewusst generisch antworten, um keine + // Account-Enumeration zu ermoeglichen (gleiche Schranke wie bei + // unbekannten E-Mails im Login). + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: string }).code === 'P2002' + ) { + recordRateLimitHit(`register:ip:${getClientIp(request)}`, IP_WINDOW_MS); + return NextResponse.json( + { + error: + 'Registrierung nicht moeglich. E-Mail oder Benutzername ist bereits in Verwendung.', + }, + { status: 400 }, + ); + } + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/calendars/[id]/invites/route.ts b/app/api/calendars/[id]/invites/route.ts new file mode 100644 index 0000000..c9581a9 --- /dev/null +++ b/app/api/calendars/[id]/invites/route.ts @@ -0,0 +1,98 @@ +/** + * POST /api/calendars/[id]/invites - Invite-Link erstellen (nur Owner-Admin). + * GET /api/calendars/[id]/invites - Invite-Uebersicht (Aktiv/Abgelaufen/Verwendet). + */ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { requireCalendarContext } from '@/lib/api/context'; +import { createInviteSchema } from '@/lib/validation'; +import { canManageCalendar } from '@/lib/permissions/permissions'; +import { + generateInviteToken, + hashInviteToken, + defaultInviteExpiry, +} from '@/lib/invites/tokens'; +import { getConfig } from '@/lib/config'; +import { errorResponse, forbiddenResponse } from '@/lib/api/responses'; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +export async function POST( + request: NextRequest, + { params }: RouteParams, +): Promise { + try { + const { id } = await params; + const context = await requireCalendarContext(id); + + if (!canManageCalendar(context.user, context.calendar)) { + return forbiddenResponse(); + } + + const body = createInviteSchema.parse(await request.json()); + const expiry = body.expiresInDays + ? new Date(Date.now() + body.expiresInDays * 24 * 60 * 60 * 1000) + : defaultInviteExpiry(); + + // Klartext-Token wird genau einmal zurueckgegeben; in der DB + // liegt ausschliesslich der Hash (plan.md Abschnitt 9). + const token = generateInviteToken(); + const invite = await prisma.invite.create({ + data: { + calendarId: id, + tokenHash: hashInviteToken(token), + expiresAt: expiry, + }, + select: { id: true, expiresAt: true, createdAt: true }, + }); + + return NextResponse.json( + { + invite, + inviteUrl: `${getConfig().appUrl}/invite/${token}`, + }, + { status: 201 }, + ); + } catch (error) { + return errorResponse(error); + } +} + +export async function GET( + _request: NextRequest, + { params }: RouteParams, +): Promise { + try { + const { id } = await params; + const context = await requireCalendarContext(id); + + if (!canManageCalendar(context.user, context.calendar)) { + return forbiddenResponse(); + } + + const invites = await prisma.invite.findMany({ + where: { calendarId: id }, + orderBy: { createdAt: 'desc' }, + select: { id: true, expiresAt: true, usedAt: true, createdAt: true }, + }); + + const now = new Date(); + const summary = { + active: invites.filter( + (invite) => + invite.usedAt === null && + (invite.expiresAt === null || invite.expiresAt > now), + ).length, + expired: invites.filter( + (invite) => invite.usedAt === null && invite.expiresAt !== null && invite.expiresAt <= now, + ).length, + used: invites.filter((invite) => invite.usedAt !== null).length, + }; + + return NextResponse.json({ invites, summary }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/calendars/[id]/members/route.ts b/app/api/calendars/[id]/members/route.ts new file mode 100644 index 0000000..851592c --- /dev/null +++ b/app/api/calendars/[id]/members/route.ts @@ -0,0 +1,144 @@ +/** + * GET /api/calendars/[id]/members - Mitgliederliste (nur Admin). + * POST /api/calendars/[id]/members - User per E-Mail hinzufuegen (nur Admin). + * DELETE /api/calendars/[id]/members - Mitglied entfernen (nur Admin). + * + * DB-basiertes Einladungssystem: Statt Invite-Links werden Benutzer + * direkt per E-Mail-Suche ueber die Datenbank zugeordnet. Der Server + * prueft die Verwaltungsberechtigung zentral (canManageCalendar). + */ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { requireCalendarContext } from '@/lib/api/context'; +import { addMemberSchema } from '@/lib/validation'; +import { canManageCalendar } from '@/lib/permissions/permissions'; +import { errorResponse, forbiddenResponse, notFoundResponse } from '@/lib/api/responses'; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +/** Prueft Admin-Berechtigung fuer die Member-Verwaltung. */ +async function requireManageContext(calendarId: string) { + const context = await requireCalendarContext(calendarId); + if (!canManageCalendar(context.user, context.calendar)) { + return null; + } + return context; +} + +export async function GET( + _request: NextRequest, + { params }: RouteParams, +): Promise { + try { + const { id } = await params; + const context = await requireManageContext(id); + if (!context) { + return forbiddenResponse(); + } + + const members = await prisma.calendarMember.findMany({ + where: { calendarId: id }, + orderBy: { createdAt: 'asc' }, + select: { + id: true, + createdAt: true, + user: { select: { id: true, email: true, username: true } }, + }, + }); + + return NextResponse.json({ members }); + } catch (error) { + return errorResponse(error); + } +} + +export async function POST( + request: NextRequest, + { params }: RouteParams, +): Promise { + try { + const { id } = await params; + const context = await requireManageContext(id); + if (!context) { + return forbiddenResponse(); + } + + const body = addMemberSchema.parse(await request.json()); + + const targetUser = await prisma.user.findUnique({ + where: { email: body.email }, + select: { id: true, email: true }, + }); + if (!targetUser) { + return notFoundResponse( + 'Kein Benutzer mit dieser E-Mail gefunden. Der Benutzer muss sich zuerst registrieren.', + ); + } + + // Ein User gehoert genau einem Kalender an (unique auf user_id). + const existingMembership = await prisma.calendarMember.findUnique({ + where: { userId: targetUser.id }, + select: { calendarId: true }, + }); + if (existingMembership) { + return NextResponse.json( + { + error: + existingMembership.calendarId === id + ? 'Dieser Benutzer ist bereits Mitglied dieses Kalenders.' + : 'Dieser Benutzer ist bereits einem anderen Kalender zugeordnet.', + }, + { status: 409 }, + ); + } + + const member = await prisma.calendarMember.create({ + data: { calendarId: id, userId: targetUser.id }, + select: { + id: true, + createdAt: true, + user: { select: { id: true, email: true } }, + }, + }); + + return NextResponse.json({ member }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } +} + +export async function DELETE( + request: NextRequest, + { params }: RouteParams, +): Promise { + try { + const { id } = await params; + const context = await requireManageContext(id); + if (!context) { + return forbiddenResponse(); + } + + const memberId = request.nextUrl.searchParams.get('memberId'); + if (!memberId) { + return NextResponse.json( + { error: 'memberId ist erforderlich.' }, + { status: 400 }, + ); + } + + const member = await prisma.calendarMember.findUnique({ + where: { id: memberId }, + select: { id: true, calendarId: true }, + }); + if (!member || member.calendarId !== id) { + return notFoundResponse('Mitglied nicht gefunden.'); + } + + await prisma.calendarMember.delete({ where: { id: memberId } }); + return NextResponse.json({ ok: true }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/calendars/[id]/reservations/route.ts b/app/api/calendars/[id]/reservations/route.ts new file mode 100644 index 0000000..bd2af36 --- /dev/null +++ b/app/api/calendars/[id]/reservations/route.ts @@ -0,0 +1,121 @@ +/** + * GET /api/calendars/[id]/reservations - Reservierungen eines Kalenders. + * POST /api/calendars/[id]/reservations - neue Reservierung (atomar). + * + * Berechtigungen werden serverseitig ueber die zentrale + * Berechtigungsschicht erzwungen (plan.md Abschnitt 2 und 20). + * + * Sichtbarkeiten: Titel und Notizen fremder Reservierungen werden + * fuer Nicht-Admins gemaess der Kalender-Einstellungen geschwaerzt + * (show_title_to_members / show_notes_to_members). + */ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { requireCalendarContext } from '@/lib/api/context'; +import { createReservationSchema } from '@/lib/validation'; +import { canCreateReservation } from '@/lib/permissions/permissions'; +import { createReservationAtomically } from '@/lib/reservations/service'; +import { errorResponse, forbiddenResponse } from '@/lib/api/responses'; +import { displayName } from '@/lib/privacy/display'; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +export async function GET( + request: NextRequest, + { params }: RouteParams, +): Promise { + try { + const { id } = await params; + const context = await requireCalendarContext(id); + + // Optionales Zeitfenster-Filter (Monat/Woche/Tag-Ansicht). + // Overlap-Bedingung (startAt < to UND endAt > from) statt Enthaltensein: + // Eine Reservierung muss zurueckgegeben werden, wenn sie den + // Zeitraum auch nur teilweise ueberdeckt (z. B. Mehrtages-Reservierung, + // die vor dem Fenster beginnt oder danach endet). + const from = request.nextUrl.searchParams.get('from'); + const to = request.nextUrl.searchParams.get('to'); + + const reservations = await prisma.reservation.findMany({ + where: { + calendarId: id, + status: 'CONFIRMED', + ...(from && to + ? { startAt: { lt: new Date(to) }, endAt: { gt: new Date(from) } } + : {}), + }, + orderBy: { startAt: 'asc' }, + select: { + id: true, + userId: true, + title: true, + startAt: true, + endAt: true, + status: true, + notes: true, + user: { select: { email: true, username: true } }, + }, + }); + + const showForeignTitles = + context.user.role === 'ADMIN' || context.calendar.showTitleToMembers; + const showForeignNotes = + context.user.role === 'ADMIN' || context.calendar.showNotesToMembers; + + return NextResponse.json({ + reservations: reservations.map((reservation) => { + const isOwn = reservation.userId === context.user.id; + return { + id: reservation.id, + userId: reservation.userId, + title: isOwn || showForeignTitles ? reservation.title : 'Gebucht', + startAt: reservation.startAt, + endAt: reservation.endAt, + status: reservation.status, + notes: isOwn || showForeignNotes ? reservation.notes : null, + // Datenschutz: Keine vollstaendigen E-Mail-Adressen an + // Mitglieder; nur Username oder maskierte E-Mail. + ownerName: displayName( + reservation.user.username, + reservation.user.email, + ), + }; + }), + }); + } catch (error) { + return errorResponse(error); + } +} + +export async function POST( + request: NextRequest, + { params }: RouteParams, +): Promise { + try { + const { id } = await params; + const context = await requireCalendarContext(id); + + if (!canCreateReservation(context.user, context.calendar, context.isMember)) { + return forbiddenResponse(); + } + + const body = createReservationSchema.parse(await request.json()); + + // Serverseitige, atomare Pruefung (plan.md Abschnitt 6 und 15): + // Kalender gueltig? Benutzer berechtigt? Max-Dauer? Zeit verfuegbar? + const reservation = await createReservationAtomically({ + calendarId: id, + userId: context.user.id, + title: body.title, + startAt: body.startAt, + endAt: body.endAt, + notes: body.notes ?? null, + }); + + return NextResponse.json({ reservation }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/calendars/[id]/route.ts b/app/api/calendars/[id]/route.ts new file mode 100644 index 0000000..26f7a9e --- /dev/null +++ b/app/api/calendars/[id]/route.ts @@ -0,0 +1,79 @@ +/** + * GET /api/calendars/[id] - Kalenderdetails (Owner oder Member). + * PATCH /api/calendars/[id] - bearbeiten (nur besitzender Admin). + */ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { requireCalendarContext } from '@/lib/api/context'; +import { updateCalendarSchema } from '@/lib/validation'; +import { canManageCalendar } from '@/lib/permissions/permissions'; +import { errorResponse, forbiddenResponse } from '@/lib/api/responses'; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +export async function GET( + _request: NextRequest, + { params }: RouteParams, +): Promise { + try { + const { id } = await params; + const context = await requireCalendarContext(id); + + return NextResponse.json({ calendar: context.calendar }); + } catch (error) { + return errorResponse(error); + } +} + +export async function PATCH( + request: NextRequest, + { params }: RouteParams, +): Promise { + try { + const { id } = await params; + const context = await requireCalendarContext(id); + + if (!canManageCalendar(context.user, context.calendar)) { + return forbiddenResponse(); + } + + const body = updateCalendarSchema.parse(await request.json()); + + const calendar = await prisma.calendar.update({ + where: { id }, + data: { + ...(body.title !== undefined ? { title: body.title } : {}), + ...(body.description !== undefined + ? { description: body.description } + : {}), + ...(body.timezone !== undefined ? { timezone: body.timezone } : {}), + ...(body.isActive !== undefined ? { isActive: body.isActive } : {}), + ...(body.maxReservationMinutes !== undefined + ? { maxReservationMinutes: body.maxReservationMinutes } + : {}), + ...(body.showTitleToMembers !== undefined + ? { showTitleToMembers: body.showTitleToMembers } + : {}), + ...(body.showNotesToMembers !== undefined + ? { showNotesToMembers: body.showNotesToMembers } + : {}), + }, + select: { + id: true, + title: true, + description: true, + timezone: true, + isActive: true, + maxReservationMinutes: true, + showTitleToMembers: true, + showNotesToMembers: true, + }, + }); + + return NextResponse.json({ calendar }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/calendars/route.ts b/app/api/calendars/route.ts new file mode 100644 index 0000000..516353e --- /dev/null +++ b/app/api/calendars/route.ts @@ -0,0 +1,86 @@ +/** + * POST /api/calendars - neuen Kalender erstellen (nur ADMIN). + */ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { requireDbUser } from '@/lib/api/context'; +import { createCalendarSchema } from '@/lib/validation'; +import { errorResponse, forbiddenResponse } from '@/lib/api/responses'; + +export async function POST(request: NextRequest): Promise { + try { + const user = await requireDbUser(); + if (user.role !== 'ADMIN') { + return forbiddenResponse(); + } + + const body = createCalendarSchema.parse(await request.json()); + + const calendar = await prisma.calendar.create({ + data: { + ownerId: user.id, + title: body.title, + description: body.description ?? null, + timezone: body.timezone, + }, + select: { id: true, title: true, description: true, timezone: true }, + }); + + return NextResponse.json({ calendar }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } +} + +/** + * GET /api/calendars - Kalender des angemeldeten Benutzers. + * + * Admin: alle Kalender (Verwaltungsrolle, konsistent mit canManageCalendar + * und den Server-Komponenten in Dashboard/Verwaltung). User: genau der + * eine Kalender, in dem er Mitglied ist (plan.md Abschnitt 14: Dashboard). + */ +export async function GET(): Promise { + try { + const user = await requireDbUser(); + + if (user.role === 'ADMIN') { + const calendars = await prisma.calendar.findMany({ + orderBy: { createdAt: 'desc' }, + select: { + id: true, + title: true, + description: true, + timezone: true, + isActive: true, + createdAt: true, + _count: { select: { reservations: true, members: true } }, + }, + }); + return NextResponse.json({ calendars }); + } + + // User: genau ein Kalender (durch unique auf userId garantiert). + const membership = await prisma.calendarMember.findUnique({ + where: { userId: user.id }, + include: { + calendar: { + select: { + id: true, + title: true, + description: true, + timezone: true, + isActive: true, + createdAt: true, + _count: { select: { reservations: true, members: true } }, + }, + }, + }, + }); + + return NextResponse.json({ + calendars: membership?.calendar ? [membership.calendar] : [], + }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/invites/[token]/route.ts b/app/api/invites/[token]/route.ts new file mode 100644 index 0000000..1b0da5f --- /dev/null +++ b/app/api/invites/[token]/route.ts @@ -0,0 +1,45 @@ +/** + * GET /api/invites/[token] - Invite-Informationen fuer die + * Annahme-Seite: "Du wurdest zum Kalender X eingeladen." + * + * Oeffentlich zugaenglich (vor der Registrierung), gibt aber nur + * den Kalendertitel preis - niemals Reservierungen oder Mitglieder. + */ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens'; +import { errorResponse, notFoundResponse } from '@/lib/api/responses'; + +interface RouteParams { + params: Promise<{ token: string }>; +} + +export async function GET( + _request: Request, + { params }: RouteParams, +): Promise { + try { + const { token } = await params; + const tokenHash = hashInviteToken(token); + + const invite = await prisma.invite.findUnique({ + where: { tokenHash }, + select: { + expiresAt: true, + usedAt: true, + calendar: { select: { title: true, description: true } }, + }, + }); + + if (!invite || !isInviteUsable(invite)) { + return notFoundResponse('Dieser Einladungslink ist ungueltig oder abgelaufen.'); + } + + return NextResponse.json({ + calendarTitle: invite.calendar.title, + calendarDescription: invite.calendar.description, + }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/manifest/route.ts b/app/api/manifest/route.ts new file mode 100644 index 0000000..29e9623 --- /dev/null +++ b/app/api/manifest/route.ts @@ -0,0 +1,25 @@ +/** + * GET /api/manifest - Modul-Contract des Kalendertools (module-plan.md Abschnitt 1). + * + * Urspruenglich unter /.well-known/app-manifest geplant, aber Next.js App Router + * routed keine Ordner mit fuehrendem Punkt (.well-known). Die Route liegt deshalb + * unter /api/manifest und wird per next.config.rewrites nach /.well-known/app-manifest + * gemappt (mit basePath: false, damit sie auch im Proxy-Modus ohne Praefix erreichbar ist). + */ +import { NextResponse } from "next/server"; + +export async function GET() { + const basePath = (process.env.HUB_BASE_PATH ?? "").replace(/\/$/, ""); + + return NextResponse.json( + { + name: "Kalendertool", + slug: "kalender", + version: "1.0", + icon: "/icon.png", + auth: { mode: "hub-token", path: "/auth/hub" }, + basePath: basePath || "/apps/kalender", + }, + { headers: { "Cache-Control": "public, max-age=60" } } + ); +} \ No newline at end of file diff --git a/app/api/me/profile/route.ts b/app/api/me/profile/route.ts new file mode 100644 index 0000000..e54c0e9 --- /dev/null +++ b/app/api/me/profile/route.ts @@ -0,0 +1,58 @@ +/** + * GET /api/me/profile - aktuelles Profil (email, username, role). + * PATCH /api/me/profile - Username aendern (oder loeschen). + * + * Der Username ist optional und eindeutig. Beim Setzen prueft der + * Server das Format (Zod) und faengt Unique-Constraint-Verletzungen + * (P2002) ab, um eine lesbare Fehlermeldung zurueckzugeben. + */ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { requireDbUser } from '@/lib/api/context'; +import { updateUsernameSchema } from '@/lib/validation'; +import { errorResponse, validationErrorResponse } from '@/lib/api/responses'; +import { ZodError } from 'zod'; + +export async function GET(): Promise { + try { + const user = await requireDbUser(); + + return NextResponse.json({ + user: { id: user.id, email: user.email, username: user.username, role: user.role }, + }); + } catch (error) { + return errorResponse(error); + } +} + +export async function PATCH(request: NextRequest): Promise { + try { + const user = await requireDbUser(); + const body = updateUsernameSchema.parse(await request.json()); + + const updated = await prisma.user.update({ + where: { id: user.id }, + data: { username: body.username }, + select: { id: true, email: true, username: true, role: true }, + }); + + return NextResponse.json({ user: updated }); + } catch (error) { + if (error instanceof ZodError) { + return validationErrorResponse(error); + } + // P2002 = Unique-Constraint-Verletzung (username bereits vergeben). + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: string }).code === 'P2002' + ) { + return NextResponse.json( + { error: 'Dieser Benutzername ist bereits vergeben.' }, + { status: 409 }, + ); + } + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/me/route.ts b/app/api/me/route.ts new file mode 100644 index 0000000..6c45072 --- /dev/null +++ b/app/api/me/route.ts @@ -0,0 +1,37 @@ +/** + * GET /api/me - aktueller Benutzer + sein Kalender (fuer Dashboard). + */ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { requireDbUser } from '@/lib/api/context'; +import { errorResponse } from '@/lib/api/responses'; + +export async function GET(): Promise { + try { + const user = await requireDbUser(); + + const membership = await prisma.calendarMember.findUnique({ + where: { userId: user.id }, + select: { + calendar: { select: { id: true, title: true, description: true, isActive: true } }, + }, + }); + + const ownedCalendars = + user.role === 'ADMIN' + ? await prisma.calendar.findMany({ + where: { ownerId: user.id }, + orderBy: { createdAt: 'desc' }, + select: { id: true, title: true, description: true, isActive: true }, + }) + : []; + + return NextResponse.json({ + user: { id: user.id, email: user.email, username: user.username, role: user.role }, + memberCalendar: membership?.calendar ?? null, + ownedCalendars, + }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/reservations/[id]/route.ts b/app/api/reservations/[id]/route.ts new file mode 100644 index 0000000..8ac5168 --- /dev/null +++ b/app/api/reservations/[id]/route.ts @@ -0,0 +1,96 @@ +/** + * PATCH /api/reservations/[id] - eigene Reservierung aendern. + * DELETE /api/reservations/[id] - Reservierung loeschen (NUR Admin). + * + * Beide Operationen laufen atomar mit Kollisions- und Max-Dauerpruefung. + * Normale User koennen Reservierungen anlegen und aendern, aber nicht + * loeschen (canDeleteReservation). + */ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { requireDbUser } from '@/lib/api/context'; +import { updateReservationSchema } from '@/lib/validation'; +import { + canModifyReservation, + canDeleteReservation, +} from '@/lib/permissions/permissions'; +import { updateReservationAtomically } from '@/lib/reservations/service'; +import { errorResponse, forbiddenResponse, notFoundResponse } from '@/lib/api/responses'; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +/** Laedt eine Reservierung samt Kalender-Referenz. */ +async function loadReservation(reservationId: string) { + return prisma.reservation.findUnique({ + where: { id: reservationId }, + select: { + id: true, + calendarId: true, + userId: true, + calendar: { select: { id: true, ownerId: true, isActive: true } }, + }, + }); +} + +/** Ermittelt, ob der User Mitglied des Kalenders der Reservierung ist. */ +async function isUserMemberOf(userId: string, calendarId: string): Promise { + const membership = await prisma.calendarMember.findUnique({ + where: { userId }, + select: { calendarId: true }, + }); + return membership?.calendarId === calendarId; +} + +export async function PATCH( + request: NextRequest, + { params }: RouteParams, +): Promise { + try { + const { id } = await params; + const user = await requireDbUser(); + + const reservation = await loadReservation(id); + if (!reservation) { + return notFoundResponse('Reservierung nicht gefunden.'); + } + + const isMember = await isUserMemberOf(user.id, reservation.calendarId); + if (!canModifyReservation(user, reservation, reservation.calendar, isMember)) { + return forbiddenResponse(); + } + + const body = updateReservationSchema.parse(await request.json()); + + const updated = await updateReservationAtomically(id, body); + return NextResponse.json({ reservation: updated }); + } catch (error) { + return errorResponse(error); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: RouteParams, +): Promise { + try { + const { id } = await params; + const user = await requireDbUser(); + + const reservation = await loadReservation(id); + if (!reservation) { + return notFoundResponse('Reservierung nicht gefunden.'); + } + + // Loeschen ist ausschliesslich Admins vorbehalten. + if (!canDeleteReservation(user, reservation, reservation.calendar)) { + return forbiddenResponse(); + } + + await prisma.reservation.delete({ where: { id } }); + return NextResponse.json({ ok: true }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts new file mode 100644 index 0000000..9b6482f --- /dev/null +++ b/app/api/users/[id]/route.ts @@ -0,0 +1,95 @@ +/** + * PATCH /api/users/[id] - Rolle eines Benutzers aendern (nur ADMIN). + * + * Sicherheitsregeln: + * - Nur ADMIN darf Rollen veraendern. + * - Ein Admin kann sich nicht selbst degradieren (verhindert, dass + * das letzte Admin-Konto versehentlich die Rechte verliert). + * - Der letzte verbleibende Admin kann nicht degradiert werden. + */ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { requireDbUser } from '@/lib/api/context'; +import { updateRoleSchema } from '@/lib/validation'; +import { + errorResponse, + forbiddenResponse, + notFoundResponse, + validationErrorResponse, +} from '@/lib/api/responses'; +import { withSerializableTx } from '@/lib/db/tx'; +import { ZodError } from 'zod'; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +): Promise { + try { + const { id } = await params; + const actingUser = await requireDbUser(); + if (actingUser.role !== 'ADMIN') { + return forbiddenResponse(); + } + + const body = updateRoleSchema.parse(await request.json()); + + // Selbst-Degradierung blockieren: sonst sperrt sich der Admin + // moeglicherweise selbst aus der Verwaltung aus. + if (id === actingUser.id && body.role !== 'ADMIN') { + return NextResponse.json( + { error: 'Die eigene Rolle kann nicht geaendert werden.' }, + { status: 409 }, + ); + } + + // Letzten Admin schuetzen: Pruefung und Update laufen ATOMAR in + // einer SERIALIZABLE-Transaktion. Zwei parallele Degradierungen + // koennen sonst beide die Count-Pruefung passieren (TOCTOU) und + // die App ohne Admin zuruecklassen. + const updated = await withSerializableTx(async (tx) => { + const target = await tx.user.findUnique({ + where: { id }, + select: { id: true, role: true }, + }); + if (!target) { + throw new Error('NOT_FOUND'); + } + if (target.role === 'ADMIN' && body.role === 'USER') { + const adminCount = await tx.user.count({ where: { role: 'ADMIN' } }); + if (adminCount <= 1) { + throw new Error('LAST_ADMIN'); + } + } + return tx.user.update({ + where: { id }, + data: { role: body.role }, + select: { id: true, email: true, username: true, role: true }, + }); + }).catch((error: unknown) => { + if (error instanceof Error && error.message === 'NOT_FOUND') { + return null; + } + if (error instanceof Error && error.message === 'LAST_ADMIN') { + return 'LAST_ADMIN' as const; + } + throw error; + }); + + if (updated === null) { + return notFoundResponse('Benutzer nicht gefunden.'); + } + if (updated === 'LAST_ADMIN') { + return NextResponse.json( + { error: 'Der letzte Administrator kann nicht degradiert werden.' }, + { status: 409 }, + ); + } + + return NextResponse.json({ user: updated }); + } catch (error) { + if (error instanceof ZodError) { + return validationErrorResponse(error); + } + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/users/route.ts b/app/api/users/route.ts new file mode 100644 index 0000000..7c78bfe --- /dev/null +++ b/app/api/users/route.ts @@ -0,0 +1,42 @@ +/** + * GET /api/users - Benutzerverwaltung (nur ADMIN). + * + * Liefert alle Benutzer mit Rolle und Kalender-Beziehungen, damit + * ein Admin Rollen vergeben und Zuordnungen ueberblicken kann. + */ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { requireDbUser } from '@/lib/api/context'; +import { errorResponse, forbiddenResponse } from '@/lib/api/responses'; + +export async function GET(): Promise { + try { + const user = await requireDbUser(); + if (user.role !== 'ADMIN') { + return forbiddenResponse(); + } + + const users = await prisma.user.findMany({ + orderBy: { createdAt: 'asc' }, + select: { + id: true, + email: true, + username: true, + role: true, + createdAt: true, + memberships: { + select: { + calendar: { select: { id: true, title: true, isActive: true } }, + }, + }, + ownedCalendars: { + select: { id: true, title: true, isActive: true }, + }, + }, + }); + + return NextResponse.json({ users }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/api/users/search/route.ts b/app/api/users/search/route.ts new file mode 100644 index 0000000..80adc66 --- /dev/null +++ b/app/api/users/search/route.ts @@ -0,0 +1,44 @@ +/** + * GET /api/users/search?q=... - Benutzer-Suche fuer die + * Mitgliederverwaltung (nur ADMIN). + * + * DB-basierte Suche per E-Mail (case-insensitive, Teilstring). + * Liefert maximal 8 Treffer mit E-Mail und ID; Passwort-Hashes und + * andere sensible Felder werden niemals zurueckgegeben. + */ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db/client'; +import { requireDbUser } from '@/lib/api/context'; +import { errorResponse, forbiddenResponse } from '@/lib/api/responses'; + +export async function GET( + request: NextRequest, +): Promise { + try { + const user = await requireDbUser(); + if (user.role !== 'ADMIN') { + return forbiddenResponse(); + } + + const query = request.nextUrl.searchParams.get('q')?.trim() ?? ''; + if (query.length < 2) { + return NextResponse.json({ users: [] }); + } + + const users = await prisma.user.findMany({ + where: { + OR: [ + { email: { contains: query, mode: 'insensitive' } }, + { username: { contains: query, mode: 'insensitive' } }, + ], + }, + orderBy: { email: 'asc' }, + take: 8, + select: { id: true, email: true, username: true }, + }); + + return NextResponse.json({ users }); + } catch (error) { + return errorResponse(error); + } +} \ No newline at end of file diff --git a/app/auth/hub/route.ts b/app/auth/hub/route.ts new file mode 100644 index 0000000..1aa9cd7 --- /dev/null +++ b/app/auth/hub/route.ts @@ -0,0 +1,55 @@ +/** + * GET /auth/hub?token=... – SSO-Einstiegspunkt der MultiToolApp-Plattform. + * + * Ablauf (platform-plan.md §5.2): + * 1. Hub-Token validieren (RS256 via JWKS vom Hub, aud = HUB_TOOL_SLUG) + * 2. JIT-Provisioning (Account an hubId koppeln oder anlegen) + * 3. Eigene Session setzen (bewährtes calendar_session-Cookie) + * 4. Redirect auf das Kalender-Dashboard + * + * Fehler → Redirect auf /login mit ?hubError=... (keine Details im Query, + * nur grober Code; Details stehen im Server-Log). + */ +import { NextRequest, NextResponse } from "next/server"; +import { verifyHubToken } from "@/lib/auth/hub"; +import { provisionHubUser } from "@/lib/auth/hub-provisioning"; +import { createSessionToken, setSessionCookie } from "@/lib/auth/session"; + +/** basePath der App (Proxy-Modus) – Redirect-Ziele müssen darunter liegen. */ +function getBasePath(): string { + return (process.env.HUB_BASE_PATH ?? "").replace(/\/$/, ""); +} + +export async function GET(request: NextRequest) { + const basePath = getBasePath(); + const loginUrl = new URL(`${basePath}/login`, request.url); + + try { + const token = request.nextUrl.searchParams.get("token"); + if (!token) { + loginUrl.searchParams.set("hubError", "missing_token"); + return NextResponse.redirect(loginUrl); + } + + // 1. Token validieren (Signatur/TTL/aud/iss) + const identity = await verifyHubToken(token); + + // 2. JIT-Provisioning + const user = await provisionHubUser(identity); + + // 3. Eigene Session setzen + const sessionToken = await createSessionToken({ + id: user.id, + email: user.email, + role: user.role, + }); + await setSessionCookie(sessionToken); + + // 4. Weiter ins Tool (basePath-kompatibel) + return NextResponse.redirect(new URL(`${basePath}/`, request.url)); + } catch (error) { + console.error("Hub-SSO fehlgeschlagen:", error); + loginUrl.searchParams.set("hubError", "invalid_token"); + return NextResponse.redirect(loginUrl); + } +} \ No newline at end of file diff --git a/app/calendar/[id]/page.tsx b/app/calendar/[id]/page.tsx new file mode 100644 index 0000000..fc670cc --- /dev/null +++ b/app/calendar/[id]/page.tsx @@ -0,0 +1,120 @@ +/** + * Kalenderansicht (plan.md Abschnitt 7): Monat / Woche / Tag. + * + * Serverkomponente: laedt Kalender + Reservierungen des Zeitraums + * serverseitig (Berechtigungspruefung via requireCalendarContext). + * Die Ansicht selbst ist eine reine Darstellung; das Umschalten der + * Ansicht/Periode erfolgt ueber URL-Parameter (sharebar, kein State). + */ +import { redirect } from 'next/navigation'; +import { prisma } from '@/lib/db/client'; +import { requireCalendarContext } from '@/lib/api/context'; +import { displayName } from '@/lib/privacy/display'; +import { CalendarView } from '@/components/calendar/CalendarView'; +import { NewReservationButton } from '@/components/reservation/NewReservationButton'; + +interface PageProps { + params: Promise<{ id: string }>; + searchParams: Promise<{ view?: string; date?: string }>; +} + +export default async function CalendarPage({ params, searchParams }: PageProps) { + const { id } = await params; + const { view = 'week', date } = await searchParams; + + const context = await requireCalendarContext(id).catch(() => null); + if (!context) { + redirect('/login'); + } + + // Zeitfenster bestimmen (UTC-Grenzen; Anzeige in Kalender-Zeitzone). + const referenceDate = date ? new Date(date) : new Date(); + const { windowStart, windowEnd } = computeWindow(view, referenceDate); + + const reservations = await prisma.reservation.findMany({ + where: { + calendarId: id, + status: 'CONFIRMED', + startAt: { lt: windowEnd }, + endAt: { gt: windowStart }, + }, + orderBy: { startAt: 'asc' }, + select: { + id: true, + userId: true, + title: true, + startAt: true, + endAt: true, + user: { select: { email: true, username: true } }, + }, + }); + + return ( +
+
+

{context.calendar.title}

+ {context.calendar.description && ( +

{context.calendar.description}

+ )} + {!context.calendar.isActive && ( +

+ Dieser Kalender ist deaktiviert; neue Reservierungen sind nicht moeglich. +

+ )} +
+ + ({ + id: reservation.id, + title: reservation.title, + startAt: reservation.startAt.toISOString(), + endAt: reservation.endAt.toISOString(), + ownerName: displayName( + reservation.user.username, + reservation.user.email, + ), + isOwn: reservation.userId === context.user.id, + }))} + /> + +
+ +
+
+ ); +} + +/** Berechnet das Zeitfenster der gewaehlten Ansicht. */ +function computeWindow( + view: string, + reference: Date, +): { windowStart: Date; windowEnd: Date } { + const start = new Date(reference); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + + switch (view) { + case 'day': + end.setDate(end.getDate() + 1); + break; + case 'month': + start.setDate(1); + end.setMonth(end.getMonth() + 1, 1); + break; + case 'week': + default: + // Woche startet montags. + const weekday = (start.getDay() + 6) % 7; + start.setDate(start.getDate() - weekday); + end.setTime(start.getTime()); + end.setDate(end.getDate() + 7); + break; + } + return { windowStart: start, windowEnd: end }; +} \ No newline at end of file diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..68fa3e7 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,146 @@ +/** + * Dashboard (plan.md Abschnitt 14). + * + * User: sieht genau seinen einen Kalender als Karte (oeffnet die + * Kalenderuebersicht im Modal). Reservieren ja, verwalten nein. + * Admin: sieht alle Kalender als Karten; Klick oeffnet die + * Kalenderuebersicht, der Stift die Verwaltung (Einstellungen, + * Mitglieder, Regeln). Nur Admins erstellen/bearbeiten Kalender. + */ +import { redirect } from 'next/navigation'; +import { prisma } from '@/lib/db/client'; +import { requireDbUser } from '@/lib/api/context'; +import { Card } from '@/components/ui/ui'; +import { AppShell } from '@/components/layout/AppShell'; +import { DashboardCalendarGrid } from '@/components/dashboard/DashboardCalendarGrid'; +import type { ManagedCalendar } from '@/components/admin/CalendarManager'; + +export default async function DashboardPage() { + const user = await requireDbUser().catch(() => null); + if (!user) { + redirect('/login'); + } + + const membership = await prisma.calendarMember.findUnique({ + where: { userId: user.id }, + select: { + calendar: { + select: { + id: true, + title: true, + description: true, + timezone: true, + isActive: true, + maxReservationMinutes: true, + showTitleToMembers: true, + showNotesToMembers: true, + owner: { select: { email: true, username: true } }, + _count: { select: { members: true, reservations: true } }, + }, + }, + }, + }); + + const ownedCalendars = + user.role === 'ADMIN' + ? await prisma.calendar.findMany({ + orderBy: { createdAt: 'desc' }, + select: { + id: true, + title: true, + description: true, + timezone: true, + isActive: true, + maxReservationMinutes: true, + showTitleToMembers: true, + showNotesToMembers: true, + owner: { select: { email: true, username: true } }, + _count: { select: { members: true, reservations: true } }, + }, + }) + : []; + + /** Vereinheitlicht DB-Zeilen auf das Karten-Datenformat. */ + function toManagedCalendar(row: { + id: string; + title: string; + description: string | null; + timezone: string; + isActive: boolean; + maxReservationMinutes: number; + showTitleToMembers: boolean; + showNotesToMembers: boolean; + owner: { email: string; username: string | null }; + _count: { members: number; reservations: number }; + }): ManagedCalendar { + return { + id: row.id, + title: row.title, + description: row.description, + timezone: row.timezone, + isActive: row.isActive, + ownerEmail: row.owner.username ?? row.owner.email, + isOwn: true, + memberCount: row._count.members, + reservationCount: row._count.reservations, + maxReservationMinutes: row.maxReservationMinutes, + showTitleToMembers: row.showTitleToMembers, + showNotesToMembers: row.showNotesToMembers, + }; + } + + const adminCalendars = ownedCalendars.map(toManagedCalendar); + const userCalendar = membership?.calendar + ? [toManagedCalendar(membership.calendar)] + : []; + + return ( + +
+

Dashboard

+
+ + {/* User-Ansicht: genau ein Kalender (als Karte) */} + {user.role === 'USER' && ( +
+

Mein Kalender

+ {userCalendar.length === 0 ? ( + +

+ Du bist noch keinem Kalender zugeordnet. Bitte wende dich an den + Administrator fuer einen Einladungslink. +

+
+ ) : ( + + )} +
+ )} + + {/* Admin-Ansicht: alle Kalender als Karten (Verwaltung) */} + {user.role === 'ADMIN' && ( +
+

Kalender

+ {adminCalendars.length === 0 ? ( + +

+ Noch keine Kalender vorhanden. Erstelle deinen ersten Kalender + unter "Kalender verwalten". +

+
+ ) : ( + + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/forgot-password/page.tsx b/app/forgot-password/page.tsx new file mode 100644 index 0000000..22e19af --- /dev/null +++ b/app/forgot-password/page.tsx @@ -0,0 +1,67 @@ +'use client'; + +/** + * "Passwort vergessen"-Seite (plan.md Abschnitt 11). + * Antwortet immer mit Erfolgsmeldung (kein Account-Enumeration). + */ +import { useState } from 'react'; +import Link from 'next/link'; +import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui'; + +export default function ForgotPasswordPage() { + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + + const formData = new FormData(event.currentTarget); + try { + const response = await fetch('/api/auth/forgot-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: formData.get('email') }), + }); + if (!response.ok) { + const data = (await response.json()) as { error?: string }; + setError(data.error ?? 'Anfrage fehlgeschlagen.'); + return; + } + setSubmitted(true); + } catch { + setError('Netzwerkfehler. Bitte erneut versuchen.'); + } finally { + setBusy(false); + } + } + + return ( +
+

Passwort vergessen

+ + {submitted ? ( +

+ Falls die E-Mail registriert ist, wurde ein Reset-Link erstellt. +

+ ) : ( +
+ +
+ + +
+ + + )} +

+ + Zurueck zur Anmeldung + +

+
+
+ ); +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..ba36c53 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,22 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + /* Fix auf Light-Scheme: mit "light dark" rendert der Browser native + Inputs im System-Dark-Mode dunkel, obwohl die Seite hell ist. + Im Dark-Mode der App schaltet .dark das Scheme passend um. */ + color-scheme: light; +} + +.dark { + color-scheme: dark; +} + +body { + @apply bg-slate-50 text-slate-900 antialiased; +} + +.dark body { + @apply bg-slate-950 text-slate-100; +} \ No newline at end of file diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx new file mode 100644 index 0000000..0065268 --- /dev/null +++ b/app/invite/[token]/page.tsx @@ -0,0 +1,65 @@ +/** + * Invite-Annahmeseite (plan.md Abschnitt 3). + * + * Serverkomponente: prueft den Token serverseitig und zeigt den + * Kalendertitel. Danach Registrierung mit dem Token in der URL. + */ +import Link from 'next/link'; +import { prisma } from '@/lib/db/client'; +import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens'; +import { Card } from '@/components/ui/ui'; + +interface PageProps { + params: Promise<{ token: string }>; +} + +export default async function InvitePage({ params }: PageProps) { + const { token } = await params; + const tokenHash = hashInviteToken(token); + + const invite = await prisma.invite.findUnique({ + where: { tokenHash }, + select: { + expiresAt: true, + usedAt: true, + calendar: { select: { title: true, description: true } }, + }, + }); + + if (!invite || !isInviteUsable(invite)) { + return ( +
+ +

Einladung ungueltig

+

+ Dieser Einladungslink ist ungueltig oder abgelaufen. Bitte wende dich an + den Administrator. +

+
+
+ ); + } + + return ( +
+ +

Einladung

+

+ Du wurdest zum Kalender{' '} + {invite.calendar.title} eingeladen. + {invite.calendar.description && ( + + {invite.calendar.description} + + )} +

+ + Jetzt registrieren + +
+
+ ); +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..7a5c2f5 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,29 @@ +/** + * Root-Layout (plan.md Abschnitt 13). + */ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'Kalendartool', + description: 'Einfaches Kalender- und Reservierungstool', +}; + +export default function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + + + {/* Theme vor dem ersten Paint anwenden, damit kein heller + Flash im Dark-Mode sichtbar ist. */} +