Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
*.md
|
||||||
|
tests
|
||||||
22
.env.example
Normal file
22
.env.example
Normal file
@@ -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"
|
||||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -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
|
||||||
65
Dockerfile
Normal file
65
Dockerfile
Normal file
@@ -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"]
|
||||||
98
README.md
Normal file
98
README.md
Normal file
@@ -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.
|
||||||
115
app/admin/calendars/[id]/page.tsx
Normal file
115
app/admin/calendars/[id]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<AppShell role={context.user.role} userEmail={context.user.email} username={context.user.username}>
|
||||||
|
<h1 className="text-2xl font-bold">{context.calendar.title} verwalten</h1>
|
||||||
|
|
||||||
|
<EditCalendarForm
|
||||||
|
calendarId={id}
|
||||||
|
initialTitle={context.calendar.title}
|
||||||
|
initialDescription={context.calendar.description ?? ''}
|
||||||
|
initialTimezone={context.calendar.timezone}
|
||||||
|
initialIsActive={context.calendar.isActive}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InviteManager calendarId={id} invites={invites} />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Mitglieder ({members.length})</h2>
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Noch keine Mitglieder. Erstelle einen Invite-Link.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{members.map((member) => (
|
||||||
|
<li key={member.user.email}>
|
||||||
|
{displayName(member.user.username, member.user.email)}
|
||||||
|
{member.user.username && (
|
||||||
|
<span className="ml-2 text-xs text-slate-500">
|
||||||
|
{member.user.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Letzte Reservierungen</h2>
|
||||||
|
{reservations.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Noch keine Reservierungen.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{reservations.map((reservation) => (
|
||||||
|
<li key={reservation.id} className="rounded bg-slate-100 px-3 py-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{reservation.startAt.toLocaleString('de-DE')} –{' '}
|
||||||
|
{reservation.endAt.toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>{' '}
|
||||||
|
{reservation.title}
|
||||||
|
<span className="ml-2 text-xs text-slate-500">
|
||||||
|
{displayName(reservation.user.username, reservation.user.email)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
app/admin/calendars/new/page.tsx
Normal file
9
app/admin/calendars/new/page.tsx
Normal file
@@ -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');
|
||||||
|
}
|
||||||
58
app/admin/calendars/page.tsx
Normal file
58
app/admin/calendars/page.tsx
Normal file
@@ -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 (
|
||||||
|
<AppShell role={user.role} userEmail={user.email} username={user.username}>
|
||||||
|
<CalendarManager
|
||||||
|
currentUserId={user.id}
|
||||||
|
isAdmin={user.role === 'ADMIN'}
|
||||||
|
initialCalendars={calendars.map((calendar) => ({
|
||||||
|
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,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
app/admin/users/page.tsx
Normal file
25
app/admin/users/page.tsx
Normal file
@@ -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 (
|
||||||
|
<AppShell role={user.role} userEmail={user.email} username={user.username}>
|
||||||
|
<h1 className="mb-6 text-2xl font-bold">Benutzerverwaltung</h1>
|
||||||
|
<UserManagement currentUserId={user.id} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
app/api/auth/forgot-password/route.ts
Normal file
87
app/api/auth/forgot-password/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/api/auth/login/route.ts
Normal file
87
app/api/auth/login/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/api/auth/logout/route.ts
Normal file
13
app/api/auth/logout/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
try {
|
||||||
|
await clearSessionCookie();
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
159
app/api/auth/register/route.ts
Normal file
159
app/api/auth/register/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/api/calendars/[id]/invites/route.ts
Normal file
98
app/api/calendars/[id]/invites/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
app/api/calendars/[id]/members/route.ts
Normal file
144
app/api/calendars/[id]/members/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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<NextResponse> {
|
||||||
|
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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/api/calendars/[id]/reservations/route.ts
Normal file
121
app/api/calendars/[id]/reservations/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/api/calendars/[id]/route.ts
Normal file
79
app/api/calendars/[id]/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/api/calendars/route.ts
Normal file
86
app/api/calendars/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/api/invites/[token]/route.ts
Normal file
45
app/api/invites/[token]/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/api/manifest/route.ts
Normal file
25
app/api/manifest/route.ts
Normal file
@@ -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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
58
app/api/me/profile/route.ts
Normal file
58
app/api/me/profile/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/api/me/route.ts
Normal file
37
app/api/me/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/api/reservations/[id]/route.ts
Normal file
96
app/api/reservations/[id]/route.ts
Normal file
@@ -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<boolean> {
|
||||||
|
const membership = await prisma.calendarMember.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { calendarId: true },
|
||||||
|
});
|
||||||
|
return membership?.calendarId === calendarId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: RouteParams,
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/api/users/[id]/route.ts
Normal file
95
app/api/users/[id]/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/api/users/route.ts
Normal file
42
app/api/users/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/api/users/search/route.ts
Normal file
44
app/api/users/search/route.ts
Normal file
@@ -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<NextResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/auth/hub/route.ts
Normal file
55
app/auth/hub/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/calendar/[id]/page.tsx
Normal file
120
app/calendar/[id]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<main className="mx-auto max-w-6xl px-4 py-8">
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">{context.calendar.title}</h1>
|
||||||
|
{context.calendar.description && (
|
||||||
|
<p className="mt-1 text-sm text-slate-600">{context.calendar.description}</p>
|
||||||
|
)}
|
||||||
|
{!context.calendar.isActive && (
|
||||||
|
<p className="mt-2 rounded-md bg-slate-100 px-3 py-2 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||||
|
Dieser Kalender ist deaktiviert; neue Reservierungen sind nicht moeglich.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<CalendarView
|
||||||
|
view={view}
|
||||||
|
referenceDate={referenceDate.toISOString()}
|
||||||
|
calendarId={id}
|
||||||
|
reservations={reservations.map((reservation) => ({
|
||||||
|
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,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<NewReservationButton
|
||||||
|
calendarId={id}
|
||||||
|
maxReservationMinutes={context.calendar.maxReservationMinutes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 };
|
||||||
|
}
|
||||||
146
app/dashboard/page.tsx
Normal file
146
app/dashboard/page.tsx
Normal file
@@ -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 (
|
||||||
|
<AppShell role={user.role} userEmail={user.email} username={user.username}>
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* User-Ansicht: genau ein Kalender (als Karte) */}
|
||||||
|
{user.role === 'USER' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Mein Kalender</h2>
|
||||||
|
{userCalendar.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Du bist noch keinem Kalender zugeordnet. Bitte wende dich an den
|
||||||
|
Administrator fuer einen Einladungslink.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<DashboardCalendarGrid
|
||||||
|
calendars={userCalendar}
|
||||||
|
currentUserId={user.id}
|
||||||
|
isAdmin={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Admin-Ansicht: alle Kalender als Karten (Verwaltung) */}
|
||||||
|
{user.role === 'ADMIN' && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Kalender</h2>
|
||||||
|
{adminCalendars.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Noch keine Kalender vorhanden. Erstelle deinen ersten Kalender
|
||||||
|
unter "Kalender verwalten".
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<DashboardCalendarGrid
|
||||||
|
calendars={adminCalendars}
|
||||||
|
currentUserId={user.id}
|
||||||
|
isAdmin={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
app/forgot-password/page.tsx
Normal file
67
app/forgot-password/page.tsx
Normal file
@@ -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<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
|
||||||
|
<h1 className="mb-6 text-center text-2xl font-bold">Passwort vergessen</h1>
|
||||||
|
<Card>
|
||||||
|
{submitted ? (
|
||||||
|
<p className="rounded-md bg-slate-100 px-3 py-2 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||||
|
Falls die E-Mail registriert ist, wurde ein Reset-Link erstellt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">E-Mail</Label>
|
||||||
|
<Input id="email" name="email" type="email" required />
|
||||||
|
</div>
|
||||||
|
<Button disabled={busy}>{busy ? 'Senden …' : 'Reset-Link anfordern'}</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<p className="mt-4 text-sm">
|
||||||
|
<Link href="/login" className="text-slate-700 underline hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100">
|
||||||
|
Zurueck zur Anmeldung
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
app/globals.css
Normal file
22
app/globals.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
65
app/invite/[token]/page.tsx
Normal file
65
app/invite/[token]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
|
||||||
|
<Card>
|
||||||
|
<h1 className="mb-2 text-xl font-bold">Einladung ungueltig</h1>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Dieser Einladungslink ist ungueltig oder abgelaufen. Bitte wende dich an
|
||||||
|
den Administrator.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
|
||||||
|
<Card>
|
||||||
|
<h1 className="mb-2 text-xl font-bold">Einladung</h1>
|
||||||
|
<p className="mb-4 text-sm text-slate-600">
|
||||||
|
Du wurdest zum Kalender{' '}
|
||||||
|
<strong>{invite.calendar.title}</strong> eingeladen.
|
||||||
|
{invite.calendar.description && (
|
||||||
|
<span className="mt-1 block text-slate-500">
|
||||||
|
{invite.calendar.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/register?token=${encodeURIComponent(token)}`}
|
||||||
|
className="inline-block rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
||||||
|
>
|
||||||
|
Jetzt registrieren
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
app/layout.tsx
Normal file
29
app/layout.tsx
Normal file
@@ -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 (
|
||||||
|
<html lang="de" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
{/* Theme vor dem ersten Paint anwenden, damit kein heller
|
||||||
|
Flash im Dark-Mode sichtbar ist. */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){try{var t=localStorage.getItem('calendar-theme');var d=window.matchMedia('(prefers-color-scheme: dark)').matches;if(t==='dark'||(!t&&d)){document.documentElement.classList.add('dark');}}catch(e){}})();`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
app/login/page.tsx
Normal file
79
app/login/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldeseite (plan.md Abschnitt 11).
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: formData.get('email'),
|
||||||
|
password: formData.get('password'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json()) as { error?: string };
|
||||||
|
setError(data.error ?? 'Anmeldung fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push('/dashboard');
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
|
||||||
|
<h1 className="mb-6 text-center text-2xl font-bold">Anmelden</h1>
|
||||||
|
<Card>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">E-Mail</Label>
|
||||||
|
<Input id="email" name="email" type="email" required autoComplete="email" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="password">Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button disabled={busy}>{busy ? 'Anmelden …' : 'Anmelden'}</Button>
|
||||||
|
</form>
|
||||||
|
<div className="mt-4 flex justify-between text-sm">
|
||||||
|
<Link href="/register" className="text-slate-700 underline hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100">
|
||||||
|
Konto erstellen
|
||||||
|
</Link>
|
||||||
|
<Link href="/forgot-password" className="text-slate-700 underline hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100">
|
||||||
|
Passwort vergessen?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/page.tsx
Normal file
11
app/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Startseite: leitet angemeldete Benutzer zum Dashboard weiter,
|
||||||
|
* unangemeldete zur Anmeldung.
|
||||||
|
*/
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { tryGetAuthenticatedUser } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const user = await tryGetAuthenticatedUser();
|
||||||
|
redirect(user ? '/dashboard' : '/login');
|
||||||
|
}
|
||||||
136
app/register/page.tsx
Normal file
136
app/register/page.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registrierungsseite (plan.md Abschnitt 3 und 11).
|
||||||
|
* Mit Invite-Token (aus URL) wird dem Kalender automatisch zugeordnet.
|
||||||
|
*/
|
||||||
|
import { Suspense, useState } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
|
|
||||||
|
function RegisterForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const inviteToken = searchParams.get('token') ?? '';
|
||||||
|
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const password = String(formData.get('password') ?? '');
|
||||||
|
const passwordRepeat = String(formData.get('passwordRepeat') ?? '');
|
||||||
|
|
||||||
|
if (password !== passwordRepeat) {
|
||||||
|
setError('Die Passwoerter stimmen nicht ueberein.');
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const username = String(formData.get('username') ?? '').trim();
|
||||||
|
|
||||||
|
const response = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: formData.get('email'),
|
||||||
|
password,
|
||||||
|
...(username ? { username } : {}),
|
||||||
|
...(inviteToken ? { inviteToken } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json()) as { error?: string };
|
||||||
|
setError(data.error ?? 'Registrierung fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push('/dashboard');
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{inviteToken && (
|
||||||
|
<p className="mb-4 rounded-md bg-slate-100 px-3 py-2 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||||
|
Du wurdest zu einem Kalender eingeladen. Nach der Registrierung siehst du
|
||||||
|
ausschliesslich diesen Kalender.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">E-Mail</Label>
|
||||||
|
<Input id="email" name="email" type="email" required autoComplete="email" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="username">Benutzername (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
maxLength={32}
|
||||||
|
autoComplete="username"
|
||||||
|
placeholder="z. B. max_mustermann"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
2-32 Zeichen; Buchstaben, Ziffern, _ - . erlaubt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="password">Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Mindestens 10 Zeichen mit Gross-/Kleinbuchstabe und Ziffer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="passwordRepeat">Passwort wiederholen</Label>
|
||||||
|
<Input
|
||||||
|
id="passwordRepeat"
|
||||||
|
name="passwordRepeat"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button disabled={busy}>{busy ? 'Registrieren …' : 'Registrieren'}</Button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-4 text-sm">
|
||||||
|
Bereits registriert?{' '}
|
||||||
|
<Link href="/login" className="text-slate-700 underline hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100">
|
||||||
|
Anmelden
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
|
||||||
|
<h1 className="mb-6 text-center text-2xl font-bold">Konto erstellen</h1>
|
||||||
|
<Suspense fallback={<Card>Wird geladen …</Card>}>
|
||||||
|
<RegisterForm />
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
components/admin/CalendarManager.tsx
Normal file
253
components/admin/CalendarManager.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kalenderverwaltung (Admin): alle Kalender als Karten.
|
||||||
|
*
|
||||||
|
* - Klick auf die Karte oeffnet das Kalender-Modal (Tag/Woche/Monat
|
||||||
|
* mit Reservierungen ansehen/anlegen/loeschen).
|
||||||
|
* - Stift-Icon oeffnet ein Bearbeiten-Modal (PATCH /api/calendars/[id]).
|
||||||
|
* - "Kalender erstellen" oeffnet ein Erstellen-Modal (POST /api/calendars).
|
||||||
|
* - Alle Modals sind per X-Icon, ESC und Hintergrund-Klick schliessbar.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
|
import { ModalShell } from '@/components/ui/ModalShell';
|
||||||
|
import { CalendarCard, getCardGradient } from '@/components/calendar/CalendarCard';
|
||||||
|
import { CalendarModal } from '@/components/calendar/CalendarModal';
|
||||||
|
import { EditCalendarModal } from '@/components/admin/EditCalendarModal';
|
||||||
|
|
||||||
|
/** Daten eines Kalenders fuer die Karten-Ansicht (serverseitig geladen). */
|
||||||
|
export interface ManagedCalendar {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
timezone: string;
|
||||||
|
isActive: boolean;
|
||||||
|
ownerEmail: string;
|
||||||
|
isOwn: boolean;
|
||||||
|
memberCount: number;
|
||||||
|
reservationCount: number;
|
||||||
|
/** Maximale Reservierungsdauer in Minuten; 0 = unbegrenzt. */
|
||||||
|
maxReservationMinutes: number;
|
||||||
|
/** Duerven Member die Titel fremder Reservierungen sehen? */
|
||||||
|
showTitleToMembers: boolean;
|
||||||
|
/** Duerven Member die Notizen fremder Reservierungen sehen? */
|
||||||
|
showNotesToMembers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formularwerte zum Erstellen/Bearbeiten eines Kalenders. */
|
||||||
|
interface CalendarFormValues {
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarManager({
|
||||||
|
initialCalendars,
|
||||||
|
currentUserId,
|
||||||
|
isAdmin,
|
||||||
|
}: {
|
||||||
|
initialCalendars: ManagedCalendar[];
|
||||||
|
currentUserId: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
/** Aktuell im Bearbeiten-Modal geoeffneter Kalender (null = geschlossen). */
|
||||||
|
const [editingCalendar, setEditingCalendar] = useState<ManagedCalendar | null>(null);
|
||||||
|
/** Aktuell im Kalender-Modal geoeffneter Kalender (null = geschlossen). */
|
||||||
|
const [openCalendar, setOpenCalendar] = useState<ManagedCalendar | null>(null);
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/** Erstellt einen neuen Kalender (POST /api/calendars). */
|
||||||
|
async function createCalendar(values: CalendarFormValues) {
|
||||||
|
setIsPending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/calendars', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||||
|
setError(data.error ?? 'Erstellen fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsCreating(false);
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setIsPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert einen Kalender: wird von EditCalendarModal selbst
|
||||||
|
* ausgefuehrt (PATCH inkl. router.refresh()); hier nur Schliessen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Kalender verwalten</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{initialCalendars.length} Kalender · eigene und Kalender anderer Benutzer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setIsCreating(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Kalender erstellen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && !isCreating && !editingCalendar && !openCalendar && (
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{initialCalendars.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Noch keine Kalender vorhanden. Erstelle deinen ersten Kalender.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{initialCalendars.map((calendar, index) => (
|
||||||
|
<li key={calendar.id}>
|
||||||
|
<CalendarCard
|
||||||
|
title={calendar.title}
|
||||||
|
subtitle={calendar.isOwn ? 'Mein Kalender' : calendar.ownerEmail}
|
||||||
|
isActive={calendar.isActive}
|
||||||
|
memberCount={calendar.memberCount}
|
||||||
|
href={`/admin/calendars/${calendar.id}`}
|
||||||
|
linkLabel={`Kalender ${calendar.title} verwalten`}
|
||||||
|
gradient={getCardGradient(index)}
|
||||||
|
iconTitle={`${calendar.memberCount} Mitglieder · ${calendar.reservationCount} Reservierungen`}
|
||||||
|
editLabel={`Kalender ${calendar.title} bearbeiten`}
|
||||||
|
onEdit={() => {
|
||||||
|
setError(null);
|
||||||
|
setEditingCalendar(calendar);
|
||||||
|
}}
|
||||||
|
onOpen={() => {
|
||||||
|
setError(null);
|
||||||
|
setOpenCalendar(calendar);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{openCalendar && (
|
||||||
|
<CalendarModal
|
||||||
|
key={openCalendar.id}
|
||||||
|
calendarId={openCalendar.id}
|
||||||
|
calendarTitle={openCalendar.title}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
maxReservationMinutes={openCalendar.maxReservationMinutes}
|
||||||
|
onClose={() => setOpenCalendar(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCreating && (
|
||||||
|
<CreateCalendarModal
|
||||||
|
isPending={isPending}
|
||||||
|
error={error}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCreating(false);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onCreate={createCalendar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingCalendar && (
|
||||||
|
<EditCalendarModal
|
||||||
|
key={editingCalendar.id}
|
||||||
|
calendar={editingCalendar}
|
||||||
|
onClose={() => {
|
||||||
|
setEditingCalendar(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modal: Neuen Kalender erstellen (POST /api/calendars). */
|
||||||
|
function CreateCalendarModal({
|
||||||
|
isPending,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
isPending: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (values: CalendarFormValues) => void;
|
||||||
|
}) {
|
||||||
|
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
onCreate({
|
||||||
|
title: String(formData.get('title') ?? '').trim(),
|
||||||
|
description: String(formData.get('description') ?? '').trim() || null,
|
||||||
|
timezone: String(formData.get('timezone') ?? '').trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalShell
|
||||||
|
title="Kalender erstellen"
|
||||||
|
ariaLabel="Kalender erstellen"
|
||||||
|
isPending={isPending}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="calendar-title">Titel</Label>
|
||||||
|
<Input id="calendar-title" name="title" type="text" required maxLength={120} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="calendar-description">Beschreibung (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="calendar-description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="calendar-timezone">Zeitzone</Label>
|
||||||
|
<Input
|
||||||
|
id="calendar-timezone"
|
||||||
|
name="timezone"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
defaultValue="Europe/Berlin"
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" disabled={isPending} onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button disabled={isPending}>{isPending ? 'Erstellen …' : 'Erstellen'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
components/admin/CreateCalendarForm.tsx
Normal file
71
components/admin/CreateCalendarForm.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/** Formular zum Erstellen eines Kalenders (nur Admin). */
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
|
|
||||||
|
export function CreateCalendarForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/calendars', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: formData.get('title'),
|
||||||
|
description: formData.get('description') || undefined,
|
||||||
|
timezone: formData.get('timezone'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json()) as { error?: string };
|
||||||
|
setError(data.error ?? 'Erstellen fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push('/dashboard');
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="title">Titel</Label>
|
||||||
|
<Input id="title" name="title" type="text" required maxLength={120} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description">Beschreibung (optional)</Label>
|
||||||
|
<Input id="description" name="description" type="text" maxLength={2000} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="timezone">Zeitzone</Label>
|
||||||
|
<Input
|
||||||
|
id="timezone"
|
||||||
|
name="timezone"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
defaultValue="Europe/Berlin"
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button disabled={busy}>{busy ? 'Erstellen …' : 'Kalender erstellen'}</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
components/admin/EditCalendarForm.tsx
Normal file
107
components/admin/EditCalendarForm.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/** Formular zum Bearbeiten/Deaktivieren eines Kalenders (Admin). */
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
|
|
||||||
|
export function EditCalendarForm({
|
||||||
|
calendarId,
|
||||||
|
initialTitle,
|
||||||
|
initialDescription,
|
||||||
|
initialTimezone,
|
||||||
|
initialIsActive,
|
||||||
|
}: {
|
||||||
|
calendarId: string;
|
||||||
|
initialTitle: string;
|
||||||
|
initialDescription: string;
|
||||||
|
initialTimezone: string;
|
||||||
|
initialIsActive: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/calendars/${calendarId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: formData.get('title'),
|
||||||
|
description: formData.get('description') || null,
|
||||||
|
timezone: formData.get('timezone'),
|
||||||
|
isActive: formData.get('isActive') === 'on',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json()) as { error?: string };
|
||||||
|
setError(data.error ?? 'Speichern fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Kalender bearbeiten</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="title">Titel</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
defaultValue={initialTitle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description">Beschreibung</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
maxLength={2000}
|
||||||
|
defaultValue={initialDescription}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="timezone">Zeitzone</Label>
|
||||||
|
<Input
|
||||||
|
id="timezone"
|
||||||
|
name="timezone"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxLength={64}
|
||||||
|
defaultValue={initialTimezone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="isActive"
|
||||||
|
name="isActive"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={initialIsActive}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isActive">Kalender aktiv</Label>
|
||||||
|
</div>
|
||||||
|
<Button disabled={busy}>{busy ? 'Speichern …' : 'Speichern'}</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
components/admin/EditCalendarModal.tsx
Normal file
219
components/admin/EditCalendarModal.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal zum Bearbeiten eines Kalenders (PATCH /api/calendars/[id])
|
||||||
|
* inkl. Mitgliederverwaltung (DB-Suche) und Reservierungsregeln.
|
||||||
|
*
|
||||||
|
* Einstellungen:
|
||||||
|
* - Titel, Beschreibung, Zeitzone, Aktiv-Status
|
||||||
|
* - Maximale Reservierungsdauer (15-Minuten-Schritte, 0 = unbegrenzt)
|
||||||
|
* - Sichtbarkeit von Titel/Notizen fremder Reservierungen fuer Member
|
||||||
|
* - Mitglieder per E-Mail-Suche hinzufuegen/entfernen
|
||||||
|
*
|
||||||
|
* Selbststaendig: fuehrt den PATCH selbst aus und aktualisiert die
|
||||||
|
* Seite per router.refresh(). Wird von der Kalenderverwaltung und vom
|
||||||
|
* Dashboard verwendet (DRY). Schliessen via X-Icon, ESC oder
|
||||||
|
* Hintergrund-Klick; waehrend des Speicherns ist Schliessen blockiert.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
|
import { ModalShell } from '@/components/ui/ModalShell';
|
||||||
|
import { MemberSection } from '@/components/admin/MemberSection';
|
||||||
|
import type { ManagedCalendar } from '@/components/admin/CalendarManager';
|
||||||
|
|
||||||
|
/** Auswahl der maximalen Reservierungsdauer in Minuten. */
|
||||||
|
const MAX_DURATION_OPTIONS = [
|
||||||
|
{ value: 30, label: '30 Minuten' },
|
||||||
|
{ value: 45, label: '45 Minuten' },
|
||||||
|
{ value: 60, label: '1 Stunde' },
|
||||||
|
{ value: 90, label: '1,5 Stunden' },
|
||||||
|
{ value: 120, label: '2 Stunden' },
|
||||||
|
{ value: 180, label: '3 Stunden' },
|
||||||
|
{ value: 240, label: '4 Stunden' },
|
||||||
|
{ value: 480, label: '8 Stunden' },
|
||||||
|
{ value: 720, label: '12 Stunden' },
|
||||||
|
{ value: 1440, label: '24 Stunden' },
|
||||||
|
{ value: 2880, label: '48 Stunden' },
|
||||||
|
{ value: 4320, label: '72 Stunden' },
|
||||||
|
{ value: 0, label: 'Unbegrenzt' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function EditCalendarModal({
|
||||||
|
calendar,
|
||||||
|
isAdmin = true,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
calendar: ManagedCalendar;
|
||||||
|
/** Nur Admins sehen Einstellungen und Mitgliederverwaltung. */
|
||||||
|
isAdmin?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/** Sendet die Formularwerte an PATCH /api/calendars/[id]. */
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsPending(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/calendars/${calendar.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: String(formData.get('title') ?? '').trim(),
|
||||||
|
description: String(formData.get('description') ?? '').trim() || null,
|
||||||
|
timezone: String(formData.get('timezone') ?? '').trim(),
|
||||||
|
isActive: formData.get('isActive') === 'on',
|
||||||
|
maxReservationMinutes: Number(formData.get('maxReservationMinutes')),
|
||||||
|
showTitleToMembers: formData.get('showTitleToMembers') === 'on',
|
||||||
|
showNotesToMembers: formData.get('showNotesToMembers') === 'on',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||||
|
setError(data.error ?? 'Speichern fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setIsPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalShell
|
||||||
|
title="Kalender verwalten"
|
||||||
|
ariaLabel={`Kalender ${calendar.title} verwalten`}
|
||||||
|
isPending={isPending}
|
||||||
|
size="lg"
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<form id="edit-calendar-form" onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-calendar-title">Titel</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-calendar-title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
defaultValue={calendar.title}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-calendar-description">Beschreibung</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-calendar-description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
maxLength={2000}
|
||||||
|
defaultValue={calendar.description ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-calendar-timezone">Zeitzone</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-calendar-timezone"
|
||||||
|
name="timezone"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxLength={64}
|
||||||
|
defaultValue={calendar.timezone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="edit-calendar-is-active"
|
||||||
|
name="isActive"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={calendar.isActive}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="edit-calendar-is-active">Kalender aktiv</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reservierungsregeln. */}
|
||||||
|
<fieldset className="rounded-md border border-slate-200 p-4 dark:border-slate-700">
|
||||||
|
<legend className="px-1 text-sm font-semibold">Reservierungsregeln</legend>
|
||||||
|
<div className="mb-3">
|
||||||
|
<Label htmlFor="edit-calendar-max-duration">
|
||||||
|
Maximale Reservierungsdauer
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="edit-calendar-max-duration"
|
||||||
|
name="maxReservationMinutes"
|
||||||
|
defaultValue={calendar.maxReservationMinutes}
|
||||||
|
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
|
||||||
|
>
|
||||||
|
{MAX_DURATION_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="edit-calendar-show-title"
|
||||||
|
name="showTitleToMembers"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={calendar.showTitleToMembers}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="edit-calendar-show-title">
|
||||||
|
Member sehen Titel aller Reservierungen
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="edit-calendar-show-notes"
|
||||||
|
name="showNotesToMembers"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={calendar.showNotesToMembers}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="edit-calendar-show-notes">
|
||||||
|
Member sehen Notizen aller Reservierungen
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Ohne Haken sehen Member nur ihre eigenen Titel/Notizen; fremde
|
||||||
|
Reservierungen erscheinen als "Gebucht".
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{calendar.memberCount} Mitglieder · {calendar.reservationCount} Reservierungen
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="mt-6 border-t border-slate-200 pt-4 dark:border-slate-700">
|
||||||
|
<MemberSection calendarId={calendar.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Aktionsleiste unten im Modal (ausserhalb des Formulars; der
|
||||||
|
Speichern-Button ist via form-Attribut mit dem Formular
|
||||||
|
verbunden und loest dessen Submit aus). */}
|
||||||
|
<div className="mt-6 flex justify-end gap-2 border-t border-slate-200 pt-4 dark:border-slate-700">
|
||||||
|
<Button type="button" variant="secondary" disabled={isPending} onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" form="edit-calendar-form" disabled={isPending}>
|
||||||
|
{isPending ? 'Speichern …' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
components/admin/InviteManager.tsx
Normal file
113
components/admin/InviteManager.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invite-Verwaltung (plan.md Abschnitt 3): Link erstellen, Liste mit
|
||||||
|
* Status (Aktiv/Abgelaufen/Verwendet) und Zaehleruebersicht.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button, Card, ErrorMessage, Badge } from '@/components/ui/ui';
|
||||||
|
|
||||||
|
export interface InviteRow {
|
||||||
|
id: string;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
usedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteManager({
|
||||||
|
calendarId,
|
||||||
|
invites,
|
||||||
|
}: {
|
||||||
|
calendarId: string;
|
||||||
|
invites: InviteRow[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [lastInviteUrl, setLastInviteUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function createInvite() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/calendars/${calendarId}/invites`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ calendarId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json()) as { error?: string };
|
||||||
|
setError(data.error ?? 'Erstellen fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { inviteUrl: string };
|
||||||
|
setLastInviteUrl(data.inviteUrl);
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const active = invites.filter(
|
||||||
|
(invite) =>
|
||||||
|
invite.usedAt === null &&
|
||||||
|
(invite.expiresAt === null || invite.expiresAt > now),
|
||||||
|
).length;
|
||||||
|
const expired = invites.filter(
|
||||||
|
(invite) =>
|
||||||
|
invite.usedAt === null &&
|
||||||
|
invite.expiresAt !== null &&
|
||||||
|
invite.expiresAt <= now,
|
||||||
|
).length;
|
||||||
|
const used = invites.filter((invite) => invite.usedAt !== null).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Einladungen</h2>
|
||||||
|
<Button type="button" variant="secondary" disabled={busy} onClick={createInvite}>
|
||||||
|
{busy ? 'Erstelle …' : '+ Benutzer einladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
|
||||||
|
{lastInviteUrl && (
|
||||||
|
<div className="mb-4 rounded-md bg-slate-100 px-3 py-2 text-sm text-slate-800 dark:bg-slate-800 dark:text-slate-200">
|
||||||
|
<p className="mb-1 font-medium">Neuer Invite-Link (einmalig anzeigen):</p>
|
||||||
|
<code className="break-all text-xs">{lastInviteUrl}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4 flex gap-4 text-sm">
|
||||||
|
<Badge variant="success">Aktiv: {active}</Badge>
|
||||||
|
<Badge variant="warning">Abgelaufen: {expired}</Badge>
|
||||||
|
<Badge variant="neutral">Verwendet: {used}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invites.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Noch keine Einladungen erstellt.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1 text-sm text-slate-600">
|
||||||
|
{invites.slice(0, 10).map((invite) => (
|
||||||
|
<li key={invite.id} className="flex justify-between rounded bg-slate-50 px-3 py-1.5">
|
||||||
|
<span>Erstellt {invite.createdAt.toLocaleDateString('de-DE')}</span>
|
||||||
|
<span>
|
||||||
|
{invite.usedAt
|
||||||
|
? 'Verwendet'
|
||||||
|
: invite.expiresAt && invite.expiresAt <= now
|
||||||
|
? 'Abgelaufen'
|
||||||
|
: 'Aktiv'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
components/admin/InviteSection.tsx
Normal file
159
components/admin/InviteSection.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einladungs-Sektion fuer Modals: laedt die Einladungen eines
|
||||||
|
* Kalenders per GET /api/calendars/[id]/invites und ermoeglicht das
|
||||||
|
* Erstellen neuer Invite-Links (POST, gleicher Endpoint).
|
||||||
|
*
|
||||||
|
* Der Klartext-Link wird nur einmal direkt nach dem Erstellen
|
||||||
|
* angezeigt (in der DB liegt nur der Hash - plan.md Abschnitt 9).
|
||||||
|
* Wird als eigenstaendige Komponente gefuehrt, damit sie sowohl im
|
||||||
|
* Bearbeiten-Modal als auch kuenftig anderswo verwendet werden kann.
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Button, ErrorMessage, Badge } from '@/components/ui/ui';
|
||||||
|
|
||||||
|
/** Einladung, wie der GET-Endpoint sie liefert (Datumswerte als ISO). */
|
||||||
|
interface InviteApiRow {
|
||||||
|
id: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
usedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteSection({ calendarId }: { calendarId: string }) {
|
||||||
|
const [invites, setInvites] = useState<InviteApiRow[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [lastInviteUrl, setLastInviteUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/** Laedt die Einladungen des Kalenders. */
|
||||||
|
const loadInvites = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/calendars/${calendarId}/invites`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||||
|
setError(data.error ?? 'Einladungen konnten nicht geladen werden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { invites: InviteApiRow[] };
|
||||||
|
setInvites(data.invites);
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [calendarId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadInvites();
|
||||||
|
}, [loadInvites]);
|
||||||
|
|
||||||
|
/** Erstellt einen neuen Invite-Link und zeigt ihn einmalig an. */
|
||||||
|
async function createInvite() {
|
||||||
|
setIsCreating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/calendars/${calendarId}/invites`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ calendarId }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||||
|
setError(data.error ?? 'Erstellen fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { inviteUrl: string };
|
||||||
|
setLastInviteUrl(data.inviteUrl);
|
||||||
|
await loadInvites();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const active = invites.filter(
|
||||||
|
(invite) =>
|
||||||
|
invite.usedAt === null &&
|
||||||
|
(invite.expiresAt === null || new Date(invite.expiresAt) > now),
|
||||||
|
).length;
|
||||||
|
const expired = invites.filter(
|
||||||
|
(invite) =>
|
||||||
|
invite.usedAt === null &&
|
||||||
|
invite.expiresAt !== null &&
|
||||||
|
new Date(invite.expiresAt) <= now,
|
||||||
|
).length;
|
||||||
|
const used = invites.filter((invite) => invite.usedAt !== null).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Einladungen">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold">Einladungen</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isCreating}
|
||||||
|
onClick={createInvite}
|
||||||
|
>
|
||||||
|
{isCreating ? 'Erstelle …' : '+ Benutzer einladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
|
||||||
|
{lastInviteUrl && (
|
||||||
|
<div className="mb-3 rounded-md bg-slate-100 px-3 py-2 text-sm text-slate-800 dark:bg-slate-800 dark:text-slate-200">
|
||||||
|
<p className="mb-1 font-medium">Neuer Invite-Link (einmalig anzeigen):</p>
|
||||||
|
<code className="break-all text-xs">{lastInviteUrl}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && (
|
||||||
|
<div className="mb-3 flex gap-3">
|
||||||
|
<Badge variant="success">Aktiv: {active}</Badge>
|
||||||
|
<Badge variant="warning">Abgelaufen: {expired}</Badge>
|
||||||
|
<Badge variant="neutral">Verwendet: {used}</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-slate-500">Einladungen werden geladen …</p>
|
||||||
|
) : invites.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Noch keine Einladungen erstellt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1 text-sm text-slate-600 dark:text-slate-300">
|
||||||
|
{invites.slice(0, 10).map((invite) => {
|
||||||
|
const isUsed = invite.usedAt !== null;
|
||||||
|
const isExpired =
|
||||||
|
!isUsed && invite.expiresAt !== null && new Date(invite.expiresAt) <= now;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={invite.id}
|
||||||
|
className="flex justify-between rounded bg-slate-50 px-3 py-1.5 dark:bg-slate-800"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Erstellt {new Date(invite.createdAt).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{isUsed
|
||||||
|
? 'Verwendet'
|
||||||
|
: isExpired
|
||||||
|
? 'Abgelaufen'
|
||||||
|
: 'Aktiv'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
components/admin/MemberSection.tsx
Normal file
240
components/admin/MemberSection.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mitgliederverwaltung fuer Modals (DB-basiertes Einladungssystem).
|
||||||
|
*
|
||||||
|
* Statt Invite-Links werden bestehende Benutzer direkt per E-Mail
|
||||||
|
* ueber die Datenbank gesucht und dem Kalender zugeordnet:
|
||||||
|
* - Suchfeld mit Live-Vorschlaegen (GET /api/users/search?q=...)
|
||||||
|
* - Hinzufuegen per Klick (POST /api/calendars/[id]/members)
|
||||||
|
* - Mitgliederliste mit Entfernen-Button (DELETE ...?memberId=...)
|
||||||
|
*
|
||||||
|
* Nur fuer Admins gerendert; der Server prueft die Berechtigung final.
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { ErrorMessage } from '@/components/ui/ui';
|
||||||
|
|
||||||
|
/** Mitglied, wie der Members-Endpoint es liefert. */
|
||||||
|
interface MemberRow {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
user: { id: string; email: string; username: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Treffer der Benutzer-Suche. */
|
||||||
|
interface UserSuggestion {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
username: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberSection({ calendarId }: { calendarId: string }) {
|
||||||
|
const [members, setMembers] = useState<MemberRow[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [addingEmail, setAddingEmail] = useState<string | null>(null);
|
||||||
|
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||||
|
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
/** Laedt die aktuelle Mitgliederliste. */
|
||||||
|
const loadMembers = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/calendars/${calendarId}/members`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||||
|
setError(data.error ?? 'Mitglieder konnten nicht geladen werden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { members: MemberRow[] };
|
||||||
|
setMembers(data.members);
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [calendarId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadMembers();
|
||||||
|
}, [loadMembers]);
|
||||||
|
|
||||||
|
/** Entfernt die Suchvorschlaege, wenn das Suchfeld den Fokus verliert.
|
||||||
|
* Die kleine Verzoegerung stellt sicher, dass Klicks auf Vorschlaege
|
||||||
|
* (die nach dem Blur-Event kommen) noch verarbeitet werden. */
|
||||||
|
function hideSuggestionsDelayed() {
|
||||||
|
window.setTimeout(() => setSuggestions([]), 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuegt einen Benutzer per E-Mail zum Kalender hinzu. */
|
||||||
|
async function addMember(email: string) {
|
||||||
|
setAddingEmail(email);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/calendars/${calendarId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||||
|
setError(data.error ?? 'Hinzufuegen fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQuery('');
|
||||||
|
setSuggestions([]);
|
||||||
|
await loadMembers();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setAddingEmail(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt ein Mitglied aus dem Kalender. */
|
||||||
|
async function removeMember(memberId: string) {
|
||||||
|
setRemovingId(memberId);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/calendars/${calendarId}/members?memberId=${encodeURIComponent(memberId)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||||
|
setError(data.error ?? 'Entfernen fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadMembers();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setRemovingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live-Suche mit 300 ms Debounce (schont die Datenbank). */
|
||||||
|
function handleQueryChange(nextQuery: string) {
|
||||||
|
setQuery(nextQuery);
|
||||||
|
if (searchTimer.current) {
|
||||||
|
clearTimeout(searchTimer.current);
|
||||||
|
}
|
||||||
|
if (nextQuery.trim().length < 2) {
|
||||||
|
setSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchTimer.current = setTimeout(async () => {
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/users/search?q=${encodeURIComponent(nextQuery.trim())}`,
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as { users: UserSuggestion[] };
|
||||||
|
setSuggestions(data.users);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Suche ist optional; Fehler still ignorieren, Vorschlaege bleiben leer.
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Mitglieder">
|
||||||
|
<h3 className="mb-3 text-base font-semibold">
|
||||||
|
Mitglieder ({members.length})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
|
||||||
|
{/* Suchfeld mit DB-Anbindung (Live-Vorschlaege). */}
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => handleQueryChange(event.target.value)}
|
||||||
|
onBlur={hideSuggestionsDelayed}
|
||||||
|
placeholder="Benutzer per E-Mail suchen und hinzufuegen …"
|
||||||
|
aria-label="Benutzer suchen"
|
||||||
|
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
{(suggestions.length > 0 || isSearching) && query.trim().length >= 2 && (
|
||||||
|
<ul className="absolute z-10 mt-1 w-full overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
|
||||||
|
{isSearching && (
|
||||||
|
<li className="px-3 py-2 text-sm text-slate-500">Suche …</li>
|
||||||
|
)}
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<li key={suggestion.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => addMember(suggestion.email)}
|
||||||
|
disabled={addingEmail !== null}
|
||||||
|
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-slate-100 disabled:opacity-50 dark:hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{suggestion.username ?? suggestion.email}
|
||||||
|
{suggestion.username && (
|
||||||
|
<span className="ml-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{suggestion.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
|
||||||
|
{addingEmail === suggestion.email ? 'Fuege hinzu …' : '+ Hinzufuegen'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{!isSearching && suggestions.length === 0 && (
|
||||||
|
<li className="px-3 py-2 text-sm text-slate-500">
|
||||||
|
Kein Benutzer gefunden.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-slate-500">Mitglieder werden geladen …</p>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Noch keine Mitglieder. Suche oben nach Benutzern, um sie hinzuzufuegen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{members.map((member) => (
|
||||||
|
<li
|
||||||
|
key={member.id}
|
||||||
|
className="flex items-center justify-between rounded bg-slate-50 px-3 py-1.5 dark:bg-slate-800"
|
||||||
|
>
|
||||||
|
<span className="text-slate-700 dark:text-slate-200">
|
||||||
|
{member.user.username ?? member.user.email}
|
||||||
|
{member.user.username && (
|
||||||
|
<span className="ml-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{member.user.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeMember(member.id)}
|
||||||
|
disabled={removingId === member.id}
|
||||||
|
aria-label={`Mitglied ${member.user.username ?? member.user.email} entfernen`}
|
||||||
|
className="text-xs font-medium text-slate-500 transition-colors hover:text-red-600 disabled:opacity-50 dark:text-slate-400 dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
{removingId === member.id ? 'Entferne …' : 'Entfernen'}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
components/admin/UserManagement.tsx
Normal file
288
components/admin/UserManagement.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzerverwaltung (Client): Liste aller Benutzer mit Edit-Modal.
|
||||||
|
* Im Modal laesst sich die Rolle aendern (PATCH /api/users/[id]);
|
||||||
|
* Fehler werden inline angezeigt. Der eigene Account ist geschuetzt
|
||||||
|
* (Server lehnt ab).
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Button, Card, ErrorMessage, Badge } from '@/components/ui/ui';
|
||||||
|
import { ModalShell } from '@/components/ui/ModalShell';
|
||||||
|
|
||||||
|
interface ManagedUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
username: string | null;
|
||||||
|
role: 'ADMIN' | 'USER';
|
||||||
|
createdAt: string;
|
||||||
|
memberships: { calendar: { id: string; title: string; isActive: boolean } }[];
|
||||||
|
ownedCalendars: { id: string; title: string; isActive: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserManagement({ currentUserId }: { currentUserId: string }) {
|
||||||
|
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||||
|
/** Aktuell im Edit-Modal geoeffneter Benutzer (null = geschlossen). */
|
||||||
|
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/users', { cache: 'no-store' });
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json().catch(() => null)) as
|
||||||
|
| { error?: string }
|
||||||
|
| null;
|
||||||
|
throw new Error(data?.error ?? 'Benutzer konnten nicht geladen werden.');
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { users: ManagedUser[] };
|
||||||
|
setUsers(data.users);
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(
|
||||||
|
loadError instanceof Error
|
||||||
|
? loadError.message
|
||||||
|
: 'Benutzer konnten nicht geladen werden.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadUsers();
|
||||||
|
}, [loadUsers]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert die Rolle eines Benutzers und schliesst bei Erfolg
|
||||||
|
* das Modal. Fehler werden im Modal (falls offen) bzw. inline
|
||||||
|
* angezeigt.
|
||||||
|
*/
|
||||||
|
async function changeRole(userId: string, nextRole: 'ADMIN' | 'USER') {
|
||||||
|
setPendingId(userId);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/users/${userId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ role: nextRole }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json().catch(() => null)) as
|
||||||
|
| { error?: string }
|
||||||
|
| null;
|
||||||
|
throw new Error(data?.error ?? 'Rolle konnte nicht geaendert werden.');
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { user: ManagedUser };
|
||||||
|
setUsers((previous) =>
|
||||||
|
previous.map((user) =>
|
||||||
|
user.id === data.user.id ? { ...user, role: data.user.role } : user,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setEditingUser((previous) =>
|
||||||
|
previous && previous.id === data.user.id
|
||||||
|
? { ...previous, role: data.user.role }
|
||||||
|
: previous,
|
||||||
|
);
|
||||||
|
} catch (changeError) {
|
||||||
|
setError(
|
||||||
|
changeError instanceof Error
|
||||||
|
? changeError.message
|
||||||
|
: 'Rolle konnte nicht geaendert werden.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setPendingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Benutzer werden geladen...
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error && <ErrorMessage message={error} />}
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Noch keine Benutzer registriert.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{users.map((user) => {
|
||||||
|
const isSelf = user.id === currentUserId;
|
||||||
|
return (
|
||||||
|
<li key={user.id}>
|
||||||
|
<Card>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium text-slate-900 dark:text-slate-100">
|
||||||
|
{user.username ?? user.email}
|
||||||
|
{user.username && (
|
||||||
|
<span className="ml-2 text-xs font-normal text-slate-500 dark:text-slate-400">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isSelf && (
|
||||||
|
<span className="ml-2">
|
||||||
|
<Badge variant="accent">Du</Badge>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Registriert am{' '}
|
||||||
|
{new Date(user.createdAt).toLocaleDateString('de-DE')}
|
||||||
|
{user.ownedCalendars.length > 0 &&
|
||||||
|
` · ${user.ownedCalendars.length} Kalender`}
|
||||||
|
{user.memberships.length > 0 &&
|
||||||
|
` · Mitglied in ${user.memberships.length} Kalender(n)`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setEditingUser(user);
|
||||||
|
}}
|
||||||
|
aria-label={`Benutzer ${user.email} bearbeiten`}
|
||||||
|
title="Bearbeiten"
|
||||||
|
className="rounded-md p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
|
||||||
|
>
|
||||||
|
{/* Stift-Icon (2D, Outline-Stil). */}
|
||||||
|
<svg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M17 3a2.8 2.8 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingUser && (
|
||||||
|
<EditUserModal
|
||||||
|
user={editingUser}
|
||||||
|
isSelf={editingUser.id === currentUserId}
|
||||||
|
isPending={pendingId === editingUser.id}
|
||||||
|
error={error}
|
||||||
|
onClose={() => {
|
||||||
|
setEditingUser(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onSave={(nextRole) => changeRole(editingUser.id, nextRole)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal zum Bearbeiten eines Benutzers (derzeit: Rolle).
|
||||||
|
* Schliessen via Abbrechen, X-Button, ESC oder Klick auf den Hintergrund.
|
||||||
|
*/
|
||||||
|
function EditUserModal({
|
||||||
|
user,
|
||||||
|
isSelf,
|
||||||
|
isPending,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
user: ManagedUser;
|
||||||
|
isSelf: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (nextRole: 'ADMIN' | 'USER') => void;
|
||||||
|
}) {
|
||||||
|
const [selectedRole, setSelectedRole] = useState<'ADMIN' | 'USER'>(user.role);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalShell
|
||||||
|
title="Benutzer bearbeiten"
|
||||||
|
ariaLabel={`Benutzer ${user.email} bearbeiten`}
|
||||||
|
isPending={isPending}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||||
|
{user.username ?? user.email}
|
||||||
|
{user.username && (
|
||||||
|
<span className="ml-2 text-xs font-normal text-slate-500 dark:text-slate-400">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Registriert am{' '}
|
||||||
|
{new Date(user.createdAt).toLocaleDateString('de-DE')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label
|
||||||
|
htmlFor="edit-user-role"
|
||||||
|
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
Rolle
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="edit-user-role"
|
||||||
|
value={selectedRole}
|
||||||
|
disabled={isSelf || isPending}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelectedRole(event.target.value as 'ADMIN' | 'USER')
|
||||||
|
}
|
||||||
|
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
|
||||||
|
>
|
||||||
|
<option value="USER">USER</option>
|
||||||
|
<option value="ADMIN">ADMIN</option>
|
||||||
|
</select>
|
||||||
|
{isSelf && (
|
||||||
|
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Die eigene Rolle kann nicht geaendert werden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorMessage message={error} />}
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" disabled={isPending} onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isSelf || isPending || selectedRole === user.role}
|
||||||
|
onClick={() => onSave(selectedRole)}
|
||||||
|
>
|
||||||
|
{isPending ? 'Speichern …' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components/auth/LogoutButton.tsx
Normal file
25
components/auth/LogoutButton.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/** Logout-Button: beendet die Session per API und laedt neu. */
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/ui';
|
||||||
|
|
||||||
|
export function LogoutButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
router.push('/login');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={logout}
|
||||||
|
className="rounded-md bg-slate-200 px-4 py-2 text-sm font-medium text-slate-900 hover:bg-slate-300"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
components/calendar/CalendarCard.tsx
Normal file
165
components/calendar/CalendarCard.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Karten-Darstellung eines Kalenders (gemeinsames Design).
|
||||||
|
*
|
||||||
|
* Wird vom Dashboard und von der Kalenderverwaltung verwendet, damit
|
||||||
|
* beide Stellen identische Karten ausgeben (Designvorlage: Farbverlauf,
|
||||||
|
* Titel unten links, Mitgliederzahl mit Gruppen-Icon unten rechts).
|
||||||
|
* Die ganze Karte ist ein Link; optional kann ein Bearbeiten-Button
|
||||||
|
* (Stift) eingeblendet werden.
|
||||||
|
*
|
||||||
|
* Bewusst ohne 'use client': Die Komponente ist rein darstellend und
|
||||||
|
* funktioniert sowohl in Server- als auch in Client-Komponenten. Der
|
||||||
|
* optionale onEdit-Handler wird nur von Client-Komponenten uebergeben.
|
||||||
|
*/
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
/** Graue Verlaufspalette fuer die Karten (Designvorgabe: Grautoene). */
|
||||||
|
const CARD_GRADIENTS = [
|
||||||
|
'from-slate-600 to-slate-800',
|
||||||
|
'from-slate-500 to-slate-700',
|
||||||
|
'from-zinc-600 to-zinc-800',
|
||||||
|
'from-neutral-600 to-neutral-800',
|
||||||
|
'from-stone-600 to-stone-800',
|
||||||
|
'from-gray-600 to-gray-800',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den Gradienten fuer den Karten-Index (zyklisch).
|
||||||
|
* Fallback auf den ersten Gradienten, falls die Palette leer ist
|
||||||
|
* (noUncheckedIndexedAccess-sicher).
|
||||||
|
*/
|
||||||
|
export function getCardGradient(index: number): string {
|
||||||
|
return CARD_GRADIENTS[index % CARD_GRADIENTS.length] ?? CARD_GRADIENTS[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Kalender-Karte. */
|
||||||
|
export interface CalendarCardProps {
|
||||||
|
title: string;
|
||||||
|
/** Kleine Zeile ueber dem Titel (z. B. Besitzer-E-Mail). */
|
||||||
|
subtitle?: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
memberCount: number;
|
||||||
|
/** Ziel des Karten-Links (die ganze Karte ist klickbar). */
|
||||||
|
href: string;
|
||||||
|
/** Accessibility-Beschriftung des Karten-Links. */
|
||||||
|
linkLabel: string;
|
||||||
|
/** Gradient-Klassen (siehe getCardGradient). */
|
||||||
|
gradient: string;
|
||||||
|
/** Optionales title-Attribut am Mitglieder-Icon (z. B. mit Reservierungen). */
|
||||||
|
iconTitle?: string;
|
||||||
|
/** Optional: Bearbeiten-Button (Stift) mit Handler. */
|
||||||
|
editLabel?: string;
|
||||||
|
onEdit?: () => void;
|
||||||
|
/** Optional: Karten-Klick oeffnet ein Modal statt dem Link. */
|
||||||
|
onOpen?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einzige Darstellungslogik fuer Kalender-Karten (DRY). */
|
||||||
|
export function CalendarCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
isActive,
|
||||||
|
memberCount,
|
||||||
|
href,
|
||||||
|
linkLabel,
|
||||||
|
gradient,
|
||||||
|
iconTitle,
|
||||||
|
editLabel,
|
||||||
|
onEdit,
|
||||||
|
onOpen,
|
||||||
|
}: CalendarCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative flex h-36 flex-col justify-end overflow-hidden rounded-xl bg-gradient-to-br p-4 text-white shadow-sm transition-shadow hover:shadow-md ${gradient} ${
|
||||||
|
isActive ? '' : 'opacity-70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Karten-Klick: entweder Modal oeffnen oder zum Link-Ziel navigieren. */}
|
||||||
|
{onOpen ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="absolute inset-0 rounded-xl text-left focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||||
|
aria-label={linkLabel}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="absolute inset-0 rounded-xl focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||||
|
aria-label={linkLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isActive && (
|
||||||
|
<span className="absolute left-4 top-3 rounded-full bg-slate-950/50 px-2 py-0.5 text-xs">
|
||||||
|
Inaktiv
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onEdit && editLabel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
aria-label={editLabel}
|
||||||
|
title="Bearbeiten"
|
||||||
|
className="absolute right-2 top-2 z-10 rounded-md p-2 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
<PencilIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{subtitle && <p className="truncate text-xs text-white/70">{subtitle}</p>}
|
||||||
|
<div className="mt-1 flex items-end justify-between gap-2">
|
||||||
|
<h3 className="font-semibold leading-snug">{title}</h3>
|
||||||
|
<span
|
||||||
|
className="flex shrink-0 items-center gap-1 text-xs text-white/80"
|
||||||
|
title={iconTitle ?? `${memberCount} Mitglieder`}
|
||||||
|
>
|
||||||
|
<MembersIcon />
|
||||||
|
{memberCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stift-Icon (2D, Outline-Stil). */
|
||||||
|
function PencilIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M17 3a2.8 2.8 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gruppen-Icon (2D, gefuellt) fuer die Mitgliederzahl auf der Karte. */
|
||||||
|
function MembersIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="7.5" r="3" />
|
||||||
|
<circle cx="5" cy="9" r="2.3" />
|
||||||
|
<circle cx="19" cy="9" r="2.3" />
|
||||||
|
<path d="M12 12c-3.3 0-6 1.7-6 4v1.5h12V16c0-2.3-2.7-4-6-4z" />
|
||||||
|
<path d="M5 12.6c-2 .3-3.5 1.5-3.5 3.2v1.7h3.3V16c0-1.3.5-2.5 1.4-3.3-.4-.1-.8-.1-1.2-.1z" />
|
||||||
|
<path d="M19 12.6c2 .3 3.5 1.5 3.5 3.2v1.7h-3.3V16c0-1.3-.5-2.5-1.4-3.3.4-.1.8-.1 1.2-.1z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
875
components/calendar/CalendarModal.tsx
Normal file
875
components/calendar/CalendarModal.tsx
Normal file
@@ -0,0 +1,875 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kalender-Modal: echte Kalenderansicht (Monat/Woche/Tag) im Dialog.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Ansichten Monat (Standard) / Woche / Tag mit Vor/Zurueck und "Heute"
|
||||||
|
* - Reservierungen werden pro Ansicht dargestellt (eigene hervorgehoben)
|
||||||
|
* - Reservierung anlegen mit Start- UND Endzeit; die maximale Dauer
|
||||||
|
* wird live gegen die Kalender-Einstellung geprueft und der Benutzer
|
||||||
|
* bei Ueberschreitung informiert ("Maximale Reservierungsdauer ist X")
|
||||||
|
* - Loeschen ausschliesslich durch Admins (Server prueft final)
|
||||||
|
* - Legende: eigene vs. fremde Reservierungen
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ModalShell } from '@/components/ui/ModalShell';
|
||||||
|
import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
|
|
||||||
|
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const;
|
||||||
|
|
||||||
|
type ViewMode = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
/** Reservierung, wie sie vom API-Endpoint geliefert wird. */
|
||||||
|
interface ReservationItem {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string;
|
||||||
|
notes: string | null;
|
||||||
|
/** Anzeigename des Erstellers (Username oder maskierte E-Mail). */
|
||||||
|
ownerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeitfenster (ISO-Grenzen) der aktuell gewaehlten Ansicht. */
|
||||||
|
interface Window {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false, // Deutsches 24h-Format (kein am/pm).
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLong(date: Date): string {
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formatiert ein Datum kurz (z. B. "21.09."). */
|
||||||
|
function formatDateShort(date: Date): string {
|
||||||
|
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft, ob `date` (00:00) innerhalb des Reservierungszeitraums liegt
|
||||||
|
* (Mehrtages-Reservierungen erscheinen auf allen Folgetagen).
|
||||||
|
*/
|
||||||
|
function isDayWithinReservation(date: Date, startAt: string, endAt: string): boolean {
|
||||||
|
const dayStart = startOfDay(date).getTime();
|
||||||
|
const dayEnd = dayStart + 24 * 60 * 60 * 1000;
|
||||||
|
return new Date(startAt).getTime() < dayEnd && dayStart < new Date(endAt).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vergleicht zwei Daten auf denselben Kalendertag (lokal). */
|
||||||
|
function isSameDay(a: Date, b: Date): boolean {
|
||||||
|
return a.toDateString() === b.toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start des Tages (00:00 lokal). */
|
||||||
|
function startOfDay(date: Date): Date {
|
||||||
|
const copy = new Date(date);
|
||||||
|
copy.setHours(0, 0, 0, 0);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Montag der Woche, in der `date` liegt. */
|
||||||
|
function startOfWeek(date: Date): Date {
|
||||||
|
const monday = startOfDay(date);
|
||||||
|
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
||||||
|
return monday;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erster Tag des Monats (00:00 lokal). */
|
||||||
|
function startOfMonth(date: Date): Date {
|
||||||
|
const first = startOfDay(date);
|
||||||
|
first.setDate(1);
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet das Zeitfenster der gewaehlten Ansicht (lokal, inklusive).
|
||||||
|
* `to` ist exklusives Ende (Start des Folgetags/Folgewoche/Folgmonat).
|
||||||
|
*/
|
||||||
|
function computeWindow(view: ViewMode, reference: Date): Window {
|
||||||
|
if (view === 'day') {
|
||||||
|
const from = startOfDay(reference);
|
||||||
|
const to = new Date(from);
|
||||||
|
to.setDate(to.getDate() + 1);
|
||||||
|
return { from: from.toISOString(), to: to.toISOString() };
|
||||||
|
}
|
||||||
|
if (view === 'week') {
|
||||||
|
const from = startOfWeek(reference);
|
||||||
|
const to = new Date(from);
|
||||||
|
to.setDate(to.getDate() + 7);
|
||||||
|
return { from: from.toISOString(), to: to.toISOString() };
|
||||||
|
}
|
||||||
|
const from = startOfMonth(reference);
|
||||||
|
const to = new Date(from);
|
||||||
|
to.setMonth(to.getMonth() + 1);
|
||||||
|
return { from: from.toISOString(), to: to.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verschiebt das Referenzdatum um eine Ansichtseinheit. */
|
||||||
|
function shiftReference(view: ViewMode, reference: Date, amount: number): Date {
|
||||||
|
const next = new Date(reference);
|
||||||
|
if (view === 'day') {
|
||||||
|
next.setDate(next.getDate() + amount);
|
||||||
|
} else if (view === 'week') {
|
||||||
|
next.setDate(next.getDate() + amount * 7);
|
||||||
|
} else {
|
||||||
|
next.setMonth(next.getMonth() + amount, 1);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kopfzeile der Ansicht (z. B. "September 2026" oder "KW-Datum"). */
|
||||||
|
function formatPeriodTitle(view: ViewMode, reference: Date): string {
|
||||||
|
if (view === 'month') {
|
||||||
|
return reference.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
if (view === 'day') {
|
||||||
|
return formatDateLong(reference);
|
||||||
|
}
|
||||||
|
const monday = startOfWeek(reference);
|
||||||
|
const sunday = new Date(monday);
|
||||||
|
sunday.setDate(sunday.getDate() + 6);
|
||||||
|
return `${monday.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} – ${sunday.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formatiert Minuten als lesbare Angabe (z. B. "4 Stunden"). */
|
||||||
|
function formatDuration(minutes: number): string {
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes} Minuten`;
|
||||||
|
}
|
||||||
|
const hours = minutes / 60;
|
||||||
|
return Number.isInteger(hours)
|
||||||
|
? `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
|
||||||
|
: `${hours.toFixed(1).replace('.', ',')} Stunden`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarModal({
|
||||||
|
calendarId,
|
||||||
|
calendarTitle,
|
||||||
|
currentUserId,
|
||||||
|
isAdmin,
|
||||||
|
maxReservationMinutes,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
calendarId: string;
|
||||||
|
calendarTitle: string;
|
||||||
|
currentUserId: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
/** Maximale Reservierungsdauer in Minuten; 0 = unbegrenzt. */
|
||||||
|
maxReservationMinutes: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [view, setView] = useState<ViewMode>('month');
|
||||||
|
const [reference, setReference] = useState(() => new Date());
|
||||||
|
const [reservations, setReservations] = useState<ReservationItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const window = useMemo(() => computeWindow(view, reference), [view, reference]);
|
||||||
|
|
||||||
|
/** Laedt Reservierungen fuer das aktuelle Zeitfenster. */
|
||||||
|
const loadReservations = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/calendars/${calendarId}/reservations?from=${encodeURIComponent(window.from)}&to=${encodeURIComponent(window.to)}`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||||
|
setError(data.error ?? 'Reservierungen konnten nicht geladen werden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { reservations: ReservationItem[] };
|
||||||
|
setReservations(data.reservations);
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [calendarId, window.from, window.to]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadReservations();
|
||||||
|
}, [loadReservations]);
|
||||||
|
|
||||||
|
/** Legt eine neue Reservierung an und laedt die Ansicht neu. */
|
||||||
|
async function createReservation(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const startDate = String(formData.get('startDate'));
|
||||||
|
const endDate = String(formData.get('endDate'));
|
||||||
|
const startTime = String(formData.get('startTime'));
|
||||||
|
const endTime = String(formData.get('endTime'));
|
||||||
|
const startAt = new Date(`${startDate}T${startTime}:00`);
|
||||||
|
const endAt = new Date(`${endDate}T${endTime}:00`);
|
||||||
|
|
||||||
|
// Sicherheitsnetz: Falls End- vor Startzeit liegt, eine Tagesdauer
|
||||||
|
// annehmen (sollte durch die Live-Pruefung verhindert werden).
|
||||||
|
if (endAt <= startAt) {
|
||||||
|
endAt.setDate(endAt.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/calendars/${calendarId}/reservations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
calendarId,
|
||||||
|
startAt: startAt.toISOString(),
|
||||||
|
endAt: endAt.toISOString(),
|
||||||
|
title: formData.get('title'),
|
||||||
|
notes: String(formData.get('notes') ?? '').trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||||
|
setError(data.error ?? 'Reservierung fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsCreating(false);
|
||||||
|
await loadReservations();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loescht eine Reservierung (ausschliesslich Admins). */
|
||||||
|
async function deleteReservation(reservationId: string) {
|
||||||
|
setDeletingId(reservationId);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/reservations/${reservationId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||||
|
setError(data.error ?? 'Loeschen fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadReservations();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalShell
|
||||||
|
title={calendarTitle}
|
||||||
|
ariaLabel={`Kalender ${calendarTitle} oeffnen`}
|
||||||
|
size="xl"
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['day', 'week', 'month'] as const).map((nextView) => (
|
||||||
|
<button
|
||||||
|
key={nextView}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView(nextView)}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm font-medium ${
|
||||||
|
view === nextView
|
||||||
|
? 'bg-slate-800 text-white dark:bg-slate-100 dark:text-slate-900'
|
||||||
|
: 'bg-slate-200 text-slate-700 hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{nextView === 'day' ? 'Tag' : nextView === 'week' ? 'Woche' : 'Monat'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReference((previous) => shiftReference(view, previous, -1))}
|
||||||
|
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
||||||
|
aria-label="Zurueck"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReference(new Date())}
|
||||||
|
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
||||||
|
>
|
||||||
|
Heute
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReference((previous) => shiftReference(view, previous, 1))}
|
||||||
|
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
|
||||||
|
aria-label="Weiter"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<span className="ml-2 text-sm font-medium">{formatPeriodTitle(view, reference)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block h-3 w-3 rounded bg-slate-300 dark:bg-slate-600" />
|
||||||
|
Eigene
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block h-3 w-3 rounded bg-slate-100 ring-1 ring-slate-300 dark:bg-slate-800 dark:ring-slate-600" />
|
||||||
|
Andere
|
||||||
|
</span>
|
||||||
|
{maxReservationMinutes > 0 && (
|
||||||
|
<span>· Max. Dauer: {formatDuration(maxReservationMinutes)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isCreating}
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setIsCreating(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Reservierung
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
|
||||||
|
{isCreating && (
|
||||||
|
<ReservationForm
|
||||||
|
reference={reference}
|
||||||
|
maxReservationMinutes={maxReservationMinutes}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
onCancel={() => setIsCreating(false)}
|
||||||
|
onSubmit={createReservation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="py-8 text-center text-sm text-slate-500">Reservierungen werden geladen …</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{view === 'day' && (
|
||||||
|
<DayView
|
||||||
|
reference={reference}
|
||||||
|
reservations={reservations}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
deletingId={deletingId}
|
||||||
|
onDelete={deleteReservation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{view === 'week' && (
|
||||||
|
<WeekView
|
||||||
|
reference={reference}
|
||||||
|
reservations={reservations}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
deletingId={deletingId}
|
||||||
|
onDelete={deleteReservation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{view === 'month' && (
|
||||||
|
<MonthView
|
||||||
|
reference={reference}
|
||||||
|
reservations={reservations}
|
||||||
|
onSelectDay={(day) => {
|
||||||
|
setReference(day);
|
||||||
|
setView('day');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formular fuer neue Reservierungen mit Start-/Endzeit und Live-Pruefung. */
|
||||||
|
function ReservationForm({
|
||||||
|
reference,
|
||||||
|
maxReservationMinutes,
|
||||||
|
isSubmitting,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
reference: Date;
|
||||||
|
maxReservationMinutes: number;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
}) {
|
||||||
|
// Live-Pruefung: Sobald Start- und Enddatum/-zeit gesetzt sind,
|
||||||
|
// wird die Dauer berechnet und gegen die Kalender-Einstellung
|
||||||
|
// geprueft. Die Dauer kann ueber Tagesgrenzen hinausgehen.
|
||||||
|
const [startTime, setStartTime] = useState('09:00');
|
||||||
|
const [endTime, setEndTime] = useState('10:00');
|
||||||
|
const [startDate, setStartDate] = useState(
|
||||||
|
reference.toISOString().slice(0, 10),
|
||||||
|
);
|
||||||
|
const [endDate, setEndDate] = useState(
|
||||||
|
reference.toISOString().slice(0, 10),
|
||||||
|
);
|
||||||
|
const durationMinutes = useMemo(() => {
|
||||||
|
const start = new Date(`${startDate}T${startTime}:00`);
|
||||||
|
const end = new Date(`${endDate}T${endTime}:00`);
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const diff = (end.getTime() - start.getTime()) / 60_000;
|
||||||
|
return diff > 0 ? diff : null;
|
||||||
|
}, [startDate, startTime, endDate, endTime]);
|
||||||
|
|
||||||
|
const exceedsMax =
|
||||||
|
durationMinutes !== null &&
|
||||||
|
maxReservationMinutes > 0 &&
|
||||||
|
durationMinutes > maxReservationMinutes;
|
||||||
|
|
||||||
|
const isInvalidRange = durationMinutes === null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="mb-4 grid gap-3 rounded-md border border-slate-200 p-4 dark:border-slate-700 sm:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="reservation-start-date">Startdatum</Label>
|
||||||
|
<Input
|
||||||
|
id="reservation-start-date"
|
||||||
|
name="startDate"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="reservation-end-date">Enddatum</Label>
|
||||||
|
<Input
|
||||||
|
id="reservation-end-date"
|
||||||
|
name="endDate"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="reservation-title">Titel</Label>
|
||||||
|
<Input id="reservation-title" name="title" type="text" required maxLength={120} />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2" />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="reservation-start-time">Startzeit</Label>
|
||||||
|
<TimeSelect
|
||||||
|
id="reservation-start-time"
|
||||||
|
name="startTime"
|
||||||
|
value={startTime}
|
||||||
|
onChange={setStartTime}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="reservation-end-time">Endzeit</Label>
|
||||||
|
<TimeSelect
|
||||||
|
id="reservation-end-time"
|
||||||
|
name="endTime"
|
||||||
|
value={endTime}
|
||||||
|
onChange={setEndTime}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label htmlFor="reservation-notes">Notiz (optional)</Label>
|
||||||
|
<Input id="reservation-notes" name="notes" type="text" maxLength={2000} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{durationMinutes !== null && (
|
||||||
|
<p
|
||||||
|
className={`sm:col-span-2 text-sm ${
|
||||||
|
exceedsMax
|
||||||
|
? 'font-medium text-red-700 dark:text-red-300'
|
||||||
|
: 'text-slate-500 dark:text-slate-400'
|
||||||
|
}`}
|
||||||
|
role={exceedsMax ? 'alert' : undefined}
|
||||||
|
>
|
||||||
|
Dauer: {formatDuration(durationMinutes)}
|
||||||
|
{exceedsMax &&
|
||||||
|
` — Maximale Reservierungsdauer ist ${formatDuration(maxReservationMinutes)}.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isInvalidRange && (
|
||||||
|
<p
|
||||||
|
className="sm:col-span-2 text-sm font-medium text-red-700 dark:text-red-300"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
Enddatum/-zeit muss nach dem Start liegen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 sm:col-span-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting || exceedsMax || isInvalidRange}>
|
||||||
|
{isSubmitting ? 'Reservieren …' : 'Reservieren'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stunden 00-23 (deutsches 24h-Format, kein am/pm). */
|
||||||
|
const HOURS: readonly number[] = Array.from({ length: 24 }, (_, index) => index);
|
||||||
|
|
||||||
|
/** Minuten in 15-Minuten-Schritten. */
|
||||||
|
const MINUTES = [0, 15, 30, 45] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigener Zeit-Auswahl-Dialog (zwei Dropdowns: Stunde + Minute).
|
||||||
|
*
|
||||||
|
* Bewusst KEIN <input type="time">: Der native Timepicker folgt der
|
||||||
|
* Systemsprache des Browsers und zeigt auf englischen Systemen
|
||||||
|
* "AM/PM" an. Diese Komponente erzwingt das deutsche 24h-Format
|
||||||
|
* unabhaengig von der Systemeinstellung.
|
||||||
|
*/
|
||||||
|
function TimeSelect({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
/** Hidden Input tragt den Wert im Format "HH:MM". */
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (nextValue: string) => void;
|
||||||
|
}) {
|
||||||
|
const [hourPart, minutePart] = value.split(':');
|
||||||
|
const hour = hourPart ?? '09';
|
||||||
|
const minute = minutePart ?? '00';
|
||||||
|
|
||||||
|
const selectClasses =
|
||||||
|
'rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input type="hidden" name={name} value={value} />
|
||||||
|
<select
|
||||||
|
aria-label={`${id === 'reservation-start-time' ? 'Start' : 'End'}stunde`}
|
||||||
|
value={hour}
|
||||||
|
onChange={(event) => onChange(`${event.target.value}:${minute}`)}
|
||||||
|
className={selectClasses}
|
||||||
|
>
|
||||||
|
{HOURS.map((hourOption) => (
|
||||||
|
<option key={hourOption} value={String(hourOption).padStart(2, '0')}>
|
||||||
|
{String(hourOption).padStart(2, '0')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span aria-hidden="true">:</span>
|
||||||
|
<select
|
||||||
|
aria-label={`${id === 'reservation-start-time' ? 'Start' : 'End'}minuten`}
|
||||||
|
value={minute}
|
||||||
|
onChange={(event) => onChange(`${hour}:${event.target.value}`)}
|
||||||
|
className={selectClasses}
|
||||||
|
>
|
||||||
|
{MINUTES.map((minuteOption) => (
|
||||||
|
<option key={minuteOption} value={String(minuteOption).padStart(2, '0')}>
|
||||||
|
{String(minuteOption).padStart(2, '0')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften einer einzelnen Reservierungszeile. */
|
||||||
|
interface ReservationRowProps {
|
||||||
|
reservation: ReservationItem;
|
||||||
|
currentUserId: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
deletingId: string | null;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Listen-basierten Ansichten (Tag/Woche). */
|
||||||
|
interface ReservationListProps {
|
||||||
|
reservations: ReservationItem[];
|
||||||
|
currentUserId: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
deletingId: string | null;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine einzelne Reservierungszeile; Loeschen nur fuer Admins. */
|
||||||
|
function ReservationRow({
|
||||||
|
reservation,
|
||||||
|
currentUserId,
|
||||||
|
isAdmin,
|
||||||
|
deletingId,
|
||||||
|
onDelete,
|
||||||
|
}: ReservationRowProps) {
|
||||||
|
const isOwn = reservation.userId === currentUserId;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={`flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm ${
|
||||||
|
isOwn
|
||||||
|
? 'bg-slate-200 text-slate-900 dark:bg-slate-700 dark:text-slate-100'
|
||||||
|
: 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatTime(reservation.startAt)} – {formatTime(reservation.endAt)}
|
||||||
|
</span>{' '}
|
||||||
|
{reservation.title}
|
||||||
|
<span className="ml-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{reservation.ownerName}
|
||||||
|
</span>
|
||||||
|
{reservation.notes && (
|
||||||
|
<p className="truncate text-xs text-slate-500 dark:text-slate-400">{reservation.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(reservation.id)}
|
||||||
|
disabled={deletingId === reservation.id}
|
||||||
|
aria-label={`Reservierung ${reservation.title} loeschen`}
|
||||||
|
title="Loeschen (nur Admin)"
|
||||||
|
className="shrink-0 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-300 hover:text-slate-800 disabled:opacity-50 dark:text-slate-400 dark:hover:bg-slate-600 dark:hover:text-slate-100"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tagesansicht: alle Reservierungen, die diesen Tag ueberdecken. */
|
||||||
|
function DayView({
|
||||||
|
reference,
|
||||||
|
reservations,
|
||||||
|
currentUserId,
|
||||||
|
isAdmin,
|
||||||
|
deletingId,
|
||||||
|
onDelete,
|
||||||
|
}: ReservationListProps & { reference: Date }) {
|
||||||
|
const dayReservations = reservations.filter((reservation) =>
|
||||||
|
isDayWithinReservation(reference, reservation.startAt, reservation.endAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{dayReservations.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-slate-500">
|
||||||
|
Keine Reservierungen an diesem Tag.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{dayReservations.map((reservation) => (
|
||||||
|
<ReservationRow
|
||||||
|
key={reservation.id}
|
||||||
|
reservation={reservation}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
deletingId={deletingId}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wochenansicht: 7 Spalten; Mehrtages-Reservierungen auf Folgetagen. */
|
||||||
|
function WeekView({
|
||||||
|
reference,
|
||||||
|
reservations,
|
||||||
|
currentUserId,
|
||||||
|
isAdmin,
|
||||||
|
deletingId,
|
||||||
|
onDelete,
|
||||||
|
}: ReservationListProps & { reference: Date }) {
|
||||||
|
const monday = startOfWeek(reference);
|
||||||
|
const days = Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const day = new Date(monday);
|
||||||
|
day.setDate(day.getDate() + index);
|
||||||
|
return day;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-7">
|
||||||
|
{days.map((day) => {
|
||||||
|
const dayReservations = reservations.filter((reservation) =>
|
||||||
|
isDayWithinReservation(day, reservation.startAt, reservation.endAt),
|
||||||
|
);
|
||||||
|
const isToday = day.toDateString() === new Date().toDateString();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={day.toISOString()}
|
||||||
|
className={`rounded border p-2 ${
|
||||||
|
isToday
|
||||||
|
? 'border-slate-500 dark:border-slate-300'
|
||||||
|
: 'border-slate-200 dark:border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-2 text-center text-xs font-semibold text-slate-500 dark:text-slate-400">
|
||||||
|
{WEEKDAYS[(day.getDay() + 6) % 7]} {day.getDate()}.
|
||||||
|
</div>
|
||||||
|
{dayReservations.length === 0 ? (
|
||||||
|
<p className="text-center text-xs text-slate-400">–</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{dayReservations.map((reservation) => {
|
||||||
|
const isOwn = reservation.userId === currentUserId;
|
||||||
|
// Bei Mehrtages-Reservierungen das Startdatum mit angeben,
|
||||||
|
// damit Folgetage klar zugeordnet sind.
|
||||||
|
const startsOnOtherDay =
|
||||||
|
!isSameDay(new Date(reservation.startAt), day);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={reservation.id}
|
||||||
|
className={`rounded px-2 py-1 text-xs ${
|
||||||
|
isOwn
|
||||||
|
? 'bg-slate-300 text-slate-900 dark:bg-slate-600 dark:text-slate-100'
|
||||||
|
: 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200'
|
||||||
|
}`}
|
||||||
|
title={`${formatTime(reservation.startAt)}–${formatTime(reservation.endAt)} · ${reservation.title} (${reservation.ownerName})`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{startsOnOtherDay && (
|
||||||
|
<span className="mr-1">{formatDateShort(new Date(reservation.startAt))},</span>
|
||||||
|
)}
|
||||||
|
{formatTime(reservation.startAt)}–{formatTime(reservation.endAt)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate">{reservation.title}</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monatsansicht: Tage mit Anzahl; Klick springt zur Tagesansicht. */
|
||||||
|
function MonthView({
|
||||||
|
reference,
|
||||||
|
reservations,
|
||||||
|
onSelectDay,
|
||||||
|
}: {
|
||||||
|
reference: Date;
|
||||||
|
reservations: ReservationItem[];
|
||||||
|
onSelectDay: (day: Date) => void;
|
||||||
|
}) {
|
||||||
|
const year = reference.getFullYear();
|
||||||
|
const month = reference.getMonth();
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const leadingEmpty = (firstDay.getDay() + 6) % 7;
|
||||||
|
|
||||||
|
const countPerDay = new Map<number, number>();
|
||||||
|
for (const reservation of reservations) {
|
||||||
|
// Mehrtages-Reservierungen auf alle betroffenen Tage verteilen.
|
||||||
|
const cursor = startOfDay(new Date(reservation.startAt));
|
||||||
|
const end = new Date(reservation.endAt);
|
||||||
|
while (cursor < end) {
|
||||||
|
if (cursor.getFullYear() === year && cursor.getMonth() === month) {
|
||||||
|
const day = cursor.getDate();
|
||||||
|
countPerDay.set(day, (countPerDay.get(day) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 grid grid-cols-7 gap-1 text-center text-xs font-semibold text-slate-500 dark:text-slate-400">
|
||||||
|
{WEEKDAYS.map((weekday) => (
|
||||||
|
<span key={weekday}>{weekday}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{Array.from({ length: leadingEmpty }, (_, index) => (
|
||||||
|
<div key={`empty-${index}`} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: daysInMonth }, (_, index) => {
|
||||||
|
const day = index + 1;
|
||||||
|
const count = countPerDay.get(day) ?? 0;
|
||||||
|
const dayDate = new Date(year, month, day);
|
||||||
|
const isToday = dayDate.toDateString() === new Date().toDateString();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectDay(dayDate)}
|
||||||
|
className={`min-h-16 rounded border p-1 text-left text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-800 ${
|
||||||
|
isToday
|
||||||
|
? 'border-slate-500 dark:border-slate-300'
|
||||||
|
: 'border-slate-200 dark:border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{day}</div>
|
||||||
|
{count > 0 && (
|
||||||
|
<div className="mt-1 rounded bg-slate-200 px-1 text-slate-800 dark:bg-slate-700 dark:text-slate-200">
|
||||||
|
{count} Res.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Muelltonnen-Icon (2D, Outline-Stil) zum Loeschen. */
|
||||||
|
function TrashIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m3 0v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
314
components/calendar/CalendarView.tsx
Normal file
314
components/calendar/CalendarView.tsx
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kalenderansicht-Komponente (plan.md Abschnitt 7):
|
||||||
|
* Monat / Woche / Tag mit klickbaren Reservierungen.
|
||||||
|
* Die Navigation laeuft ueber URL-Parameter (sharebar, servergerendert).
|
||||||
|
*/
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Card } from '@/components/ui/ui';
|
||||||
|
|
||||||
|
export interface CalendarReservation {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string;
|
||||||
|
/** Anzeigename des Erstellers (Username oder maskierte E-Mail). */
|
||||||
|
ownerName: string;
|
||||||
|
isOwn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const;
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verschibt das Referenzdatum um `amount` Einheiten der Ansicht. */
|
||||||
|
function shiftDate(view: string, isoDate: string, amount: number): string {
|
||||||
|
const date = new Date(isoDate);
|
||||||
|
switch (view) {
|
||||||
|
case 'day':
|
||||||
|
date.setDate(date.getDate() + amount);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
date.setMonth(date.getMonth() + amount, 1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
date.setDate(date.getDate() + amount * 7);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarView({
|
||||||
|
view,
|
||||||
|
referenceDate,
|
||||||
|
calendarId,
|
||||||
|
reservations,
|
||||||
|
}: {
|
||||||
|
view: string;
|
||||||
|
referenceDate: string;
|
||||||
|
calendarId: string;
|
||||||
|
reservations: CalendarReservation[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const reference = new Date(referenceDate);
|
||||||
|
|
||||||
|
function navigate(nextView: string, nextDate: string) {
|
||||||
|
router.push(
|
||||||
|
`/calendar/${calendarId}?view=${nextView}&date=${encodeURIComponent(nextDate)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navButtons = (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['day', 'week', 'month'] as const).map((nextView) => (
|
||||||
|
<button
|
||||||
|
key={nextView}
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(nextView, referenceDate)}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm font-medium ${
|
||||||
|
view === nextView
|
||||||
|
? 'bg-slate-800 text-white dark:bg-slate-100 dark:text-slate-900'
|
||||||
|
: 'bg-slate-200 text-slate-700 hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{nextView === 'day' ? 'Tag' : nextView === 'week' ? 'Woche' : 'Monat'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(view, shiftDate(view, referenceDate, -1))}
|
||||||
|
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300"
|
||||||
|
aria-label="Zurueck"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-medium">{formatDate(reference)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(view, shiftDate(view, referenceDate, 1))}
|
||||||
|
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300"
|
||||||
|
aria-label="Weiter"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (view === 'month') {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{navButtons}
|
||||||
|
<MonthGrid reference={reference} reservations={reservations} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === 'day') {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{navButtons}
|
||||||
|
<DayTimeline reference={reference} reservations={reservations} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{navButtons}
|
||||||
|
<WeekGrid reference={reference} reservations={reservations} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Monatsraster: Tage mit Anzahl der Reservierungen. */
|
||||||
|
function MonthGrid({
|
||||||
|
reference,
|
||||||
|
reservations,
|
||||||
|
}: {
|
||||||
|
reference: Date;
|
||||||
|
reservations: CalendarReservation[];
|
||||||
|
}) {
|
||||||
|
const year = reference.getFullYear();
|
||||||
|
const month = reference.getMonth();
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const leadingEmpty = (firstDay.getDay() + 6) % 7;
|
||||||
|
|
||||||
|
const countPerDay = new Map<number, number>();
|
||||||
|
for (const reservation of reservations) {
|
||||||
|
const day = new Date(reservation.startAt).getDate();
|
||||||
|
if (new Date(reservation.startAt).getMonth() === month) {
|
||||||
|
countPerDay.set(day, (countPerDay.get(day) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 grid grid-cols-7 gap-1 text-center text-xs font-semibold text-slate-500">
|
||||||
|
{WEEKDAYS.map((weekday) => (
|
||||||
|
<span key={weekday}>{weekday}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{Array.from({ length: leadingEmpty }, (_, index) => (
|
||||||
|
<div key={`empty-${index}`} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: daysInMonth }, (_, index) => {
|
||||||
|
const day = index + 1;
|
||||||
|
const count = countPerDay.get(day) ?? 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className="min-h-16 rounded border border-slate-200 p-1 text-xs"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{day}</div>
|
||||||
|
{count > 0 && (
|
||||||
|
<div className="mt-1 rounded bg-slate-200 px-1 text-slate-800 dark:bg-slate-700 dark:text-slate-200">
|
||||||
|
{count} Res.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tages-Timeline: Stundenraster mit belegten Bloecken. */
|
||||||
|
function DayTimeline({
|
||||||
|
reference,
|
||||||
|
reservations,
|
||||||
|
}: {
|
||||||
|
reference: Date;
|
||||||
|
reservations: CalendarReservation[];
|
||||||
|
}) {
|
||||||
|
const dayReservations = reservations.filter(
|
||||||
|
(reservation) =>
|
||||||
|
new Date(reservation.startAt).toDateString() === reference.toDateString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold">
|
||||||
|
{reference.toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
})}
|
||||||
|
</h2>
|
||||||
|
{dayReservations.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Keine Reservierungen an diesem Tag.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{dayReservations.map((reservation) => (
|
||||||
|
<li
|
||||||
|
key={reservation.id}
|
||||||
|
className={`rounded-md px-3 py-2 text-sm ${
|
||||||
|
reservation.isOwn
|
||||||
|
? 'bg-slate-200 dark:bg-slate-700'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatTime(reservation.startAt)} – {formatTime(reservation.endAt)}
|
||||||
|
</span>{' '}
|
||||||
|
{reservation.title}
|
||||||
|
<span className="ml-2 text-xs text-slate-500">
|
||||||
|
{reservation.ownerName}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wochenraster: 7 Tage mit Reservierungslisten. */
|
||||||
|
function WeekGrid({
|
||||||
|
reference,
|
||||||
|
reservations,
|
||||||
|
}: {
|
||||||
|
reference: Date;
|
||||||
|
reservations: CalendarReservation[];
|
||||||
|
}) {
|
||||||
|
const monday = new Date(reference);
|
||||||
|
monday.setHours(0, 0, 0, 0);
|
||||||
|
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
||||||
|
|
||||||
|
const days = Array.from({ length: 7 }, (_, index) => {
|
||||||
|
const day = new Date(monday);
|
||||||
|
day.setDate(day.getDate() + index);
|
||||||
|
return day;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-7">
|
||||||
|
{days.map((day) => {
|
||||||
|
const dayReservations = reservations.filter(
|
||||||
|
(reservation) =>
|
||||||
|
new Date(reservation.startAt).toDateString() === day.toDateString(),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={day.toISOString()} className="rounded border border-slate-200 p-2">
|
||||||
|
<div className="mb-2 text-center text-xs font-semibold text-slate-500">
|
||||||
|
{WEEKDAYS[(day.getDay() + 6) % 7]} {day.getDate()}.
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{dayReservations.map((reservation) => (
|
||||||
|
<li
|
||||||
|
key={reservation.id}
|
||||||
|
className={`rounded px-2 py-1 text-xs ${
|
||||||
|
reservation.isOwn
|
||||||
|
? 'bg-slate-200 text-slate-900 dark:bg-slate-700 dark:text-slate-100'
|
||||||
|
: 'bg-slate-100 dark:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatTime(reservation.startAt)}–{formatTime(reservation.endAt)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate">{reservation.title}</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrapper, damit die drei Ansichten einheitlich benutzt werden koennen. */
|
||||||
|
function MonthGridWrapper(props: {
|
||||||
|
reference: Date;
|
||||||
|
reservations: CalendarReservation[];
|
||||||
|
}) {
|
||||||
|
return <MonthGrid {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias-Exporte fuer lesbare Verwendung oben.
|
||||||
|
const MonthGrid_ = MonthGridWrapper;
|
||||||
|
void MonthGrid_;
|
||||||
|
const DayTimeline_ = DayTimeline;
|
||||||
|
void DayTimeline_;
|
||||||
|
const WeekGrid_ = WeekGrid;
|
||||||
|
void WeekGrid_;
|
||||||
87
components/dashboard/DashboardCalendarGrid.tsx
Normal file
87
components/dashboard/DashboardCalendarGrid.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard-Kalenderraster (Client): Karten mit Kalender-Modal und
|
||||||
|
* Bearbeiten-Modal.
|
||||||
|
*
|
||||||
|
* - Klick auf die Karte oeffnet das Kalender-Modal (Uebersicht mit
|
||||||
|
* Tag/Woche/Monat und Reservierungen).
|
||||||
|
* - Stift-Icon oeffnet das Bearbeiten-Modal (Einstellungen) - nur
|
||||||
|
* fuer berechtigte Benutzer (Admins, siehe canManageCalendar).
|
||||||
|
* - Beide Modals sind per X-Icon, ESC und Hintergrund-Klick schliessbar.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { CalendarCard, getCardGradient } from '@/components/calendar/CalendarCard';
|
||||||
|
import { CalendarModal } from '@/components/calendar/CalendarModal';
|
||||||
|
import { EditCalendarModal } from '@/components/admin/EditCalendarModal';
|
||||||
|
import type { ManagedCalendar } from '@/components/admin/CalendarManager';
|
||||||
|
|
||||||
|
export function DashboardCalendarGrid({
|
||||||
|
calendars,
|
||||||
|
currentUserId,
|
||||||
|
isAdmin,
|
||||||
|
}: {
|
||||||
|
calendars: ManagedCalendar[];
|
||||||
|
currentUserId: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}) {
|
||||||
|
/** Kalender, dessen Uebersicht gerade offen ist (null = geschlossen). */
|
||||||
|
const [openCalendar, setOpenCalendar] = useState<ManagedCalendar | null>(null);
|
||||||
|
/** Kalender, dessen Einstellungen gerade bearbeitet werden. */
|
||||||
|
const [editingCalendar, setEditingCalendar] = useState<ManagedCalendar | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{calendars.map((calendar, index) => (
|
||||||
|
<li key={calendar.id}>
|
||||||
|
<CalendarCard
|
||||||
|
title={calendar.title}
|
||||||
|
subtitle={calendar.isOwn ? 'Mein Kalender' : calendar.ownerEmail}
|
||||||
|
isActive={calendar.isActive}
|
||||||
|
memberCount={calendar.memberCount}
|
||||||
|
href={`/calendar/${calendar.id}`}
|
||||||
|
linkLabel={`Kalender ${calendar.title} oeffnen`}
|
||||||
|
gradient={getCardGradient(index)}
|
||||||
|
editLabel={
|
||||||
|
isAdmin ? `Kalender ${calendar.title} bearbeiten` : undefined
|
||||||
|
}
|
||||||
|
onEdit={
|
||||||
|
isAdmin
|
||||||
|
? () => {
|
||||||
|
setOpenCalendar(null);
|
||||||
|
setEditingCalendar(calendar);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onOpen={() => {
|
||||||
|
setEditingCalendar(null);
|
||||||
|
setOpenCalendar(calendar);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{openCalendar && (
|
||||||
|
<CalendarModal
|
||||||
|
key={openCalendar.id}
|
||||||
|
calendarId={openCalendar.id}
|
||||||
|
calendarTitle={openCalendar.title}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
maxReservationMinutes={openCalendar.maxReservationMinutes}
|
||||||
|
onClose={() => setOpenCalendar(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingCalendar && (
|
||||||
|
<EditCalendarModal
|
||||||
|
key={editingCalendar.id}
|
||||||
|
calendar={editingCalendar}
|
||||||
|
onClose={() => setEditingCalendar(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
components/layout/AppShell.tsx
Normal file
33
components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-Shell fuer angemeldete Bereiche: einklappbare Sidebar links,
|
||||||
|
* Inhalt dynamisch daneben (passt sich der Sidebar-Breite an).
|
||||||
|
* Theme-Toggle, Profil-Badge und Abmelden befinden sich in der Sidebar.
|
||||||
|
*/
|
||||||
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
|
|
||||||
|
export function AppShell({
|
||||||
|
role,
|
||||||
|
userEmail,
|
||||||
|
username,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
role: 'ADMIN' | 'USER';
|
||||||
|
/** E-Mail des angemeldeten Benutzers (Fallback fuer das Profil-Badge). */
|
||||||
|
userEmail: string;
|
||||||
|
/** Frei waehlbarer Anzeigename; wird im Badge bevorzugt. */
|
||||||
|
username?: string | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar role={role} userEmail={userEmail} username={username} />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<main className="flex-1 overflow-x-hidden px-4 py-10">
|
||||||
|
<div className="mx-auto max-w-3xl">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
components/layout/Sidebar.tsx
Normal file
234
components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einklappbare Sidebar fuer angemeldete Bereiche.
|
||||||
|
*
|
||||||
|
* Der Inhalt passt sich dynamisch an:
|
||||||
|
* - eingeklappt: nur Icons (mit Tooltip via title)
|
||||||
|
* - ausgeklappt: Icons + Beschriftung
|
||||||
|
* - Rolle: ADMIN sieht zusaetzlich "Benutzerverwaltung"
|
||||||
|
*
|
||||||
|
* Der Zustand wird in localStorage persistiert, damit die Wahl
|
||||||
|
* ueber Seitenwechsel hinweg erhalten bleibt.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { ThemeToggle } from '@/components/layout/ThemeToggle';
|
||||||
|
import { ProfileModal } from '@/components/profile/ProfileModal';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'calendar-sidebar-collapsed';
|
||||||
|
|
||||||
|
/** Flache 2D-Icons (Outline-Stil, Faerbung via currentColor). */
|
||||||
|
function NavIcon({ name }: { name: 'dashboard' | 'plus' | 'users' }) {
|
||||||
|
const common = {
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.8,
|
||||||
|
strokeLinecap: 'round' as const,
|
||||||
|
strokeLinejoin: 'round' as const,
|
||||||
|
'aria-hidden': true,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'dashboard':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'plus':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<rect x="3" y="4" width="18" height="17" rx="2" />
|
||||||
|
<path d="M12 9v6M9 12h6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'users':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="9" cy="8" r="3.5" />
|
||||||
|
<path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6" />
|
||||||
|
<path d="M16 4.6a3.5 3.5 0 0 1 0 6.8" />
|
||||||
|
<path d="M18.5 14.4c1.8.9 3 2.6 3 5.6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: 'dashboard' | 'plus' | 'users';
|
||||||
|
adminOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_ITEMS: NavItem[] = [
|
||||||
|
{ href: '/dashboard', label: 'Dashboard', icon: 'dashboard' },
|
||||||
|
{ href: '/admin/calendars', label: 'Kalender verwalten', icon: 'plus', adminOnly: true },
|
||||||
|
{ href: '/admin/users', label: 'Benutzerverwaltung', icon: 'users', adminOnly: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
role,
|
||||||
|
userEmail,
|
||||||
|
username,
|
||||||
|
}: {
|
||||||
|
role: 'ADMIN' | 'USER';
|
||||||
|
/** E-Mail des angemeldeten Benutzers (Fallback fuer das Profil-Badge). */
|
||||||
|
userEmail: string;
|
||||||
|
/** Frei waehlbarer Anzeigename; wird im Badge bevorzugt. */
|
||||||
|
username?: string | null;
|
||||||
|
}) {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
|
/** Profil-Modal offen (geoeffnet via Klick auf das Profil-Badge). */
|
||||||
|
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Gespeicherten Zustand wiederherstellen (nur clientseitig, kein
|
||||||
|
// Hydration-Mismatch, da der erste Render konsistent eingeklappt-los ist).
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'true') {
|
||||||
|
setIsCollapsed(true);
|
||||||
|
}
|
||||||
|
setIsInitialized(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleCollapsed() {
|
||||||
|
setIsCollapsed((previous) => {
|
||||||
|
const next = !previous;
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, String(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
setIsLoggingOut(true);
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
router.push('/login');
|
||||||
|
router.refresh();
|
||||||
|
} finally {
|
||||||
|
setIsLoggingOut(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleItems = NAV_ITEMS.filter(
|
||||||
|
(item) => !item.adminOnly || role === 'ADMIN',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`sticky top-0 flex h-screen shrink-0 flex-col border-r border-slate-200 bg-white transition-all duration-200 dark:border-slate-700 dark:bg-slate-900 ${
|
||||||
|
isCollapsed ? 'w-16' : 'w-60'
|
||||||
|
}`}
|
||||||
|
aria-label="Hauptnavigation"
|
||||||
|
>
|
||||||
|
<div className="flex h-14 items-center justify-between border-b border-slate-200 px-3 dark:border-slate-700">
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className="truncate text-sm font-bold text-slate-900 dark:text-slate-100">
|
||||||
|
Kalendartool
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
aria-expanded={!isCollapsed}
|
||||||
|
aria-label={isCollapsed ? 'Sidebar ausklappen' : 'Sidebar einklappen'}
|
||||||
|
className="rounded-md p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||||
|
>
|
||||||
|
{isCollapsed ? '»' : '«'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
|
||||||
|
{visibleItems.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
title={isCollapsed ? item.label : undefined}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-slate-200 font-medium text-slate-900 dark:bg-slate-700 dark:text-slate-100'
|
||||||
|
: 'text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
|
||||||
|
} ${isCollapsed ? 'justify-center' : ''}`}
|
||||||
|
>
|
||||||
|
<NavIcon name={item.icon} />
|
||||||
|
{!isCollapsed && <span className="truncate">{item.label}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="space-y-1 border-t border-slate-200 p-2 dark:border-slate-700">
|
||||||
|
<div className={`flex items-center gap-2 ${isCollapsed ? 'justify-center' : 'px-1'}`}>
|
||||||
|
<ThemeToggle />
|
||||||
|
{/* Profil-Badge: zeigt den Username (falls gesetzt), sonst die
|
||||||
|
E-Mail des angemeldeten Benutzers; eingeklappt nur der
|
||||||
|
Anfangsbuchstabe. Klick oeffnet das Profil-Modal. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsProfileOpen(true)}
|
||||||
|
title={username ?? userEmail}
|
||||||
|
aria-label="Profil oeffnen"
|
||||||
|
className={`inline-flex min-w-0 items-center rounded-full bg-slate-200 font-medium text-slate-800 transition-colors hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600 ${
|
||||||
|
isCollapsed
|
||||||
|
? 'h-9 w-9 justify-center text-sm'
|
||||||
|
: 'max-w-full px-3.5 py-1.5 text-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
(username ?? userEmail).charAt(0).toUpperCase()
|
||||||
|
) : (
|
||||||
|
<span className="truncate">{username ?? userEmail}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={logout}
|
||||||
|
disabled={isLoggingOut}
|
||||||
|
title={isCollapsed ? 'Abmelden' : undefined}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-slate-700 transition-colors hover:bg-slate-100 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100 ${
|
||||||
|
isCollapsed ? 'justify-center' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3" />
|
||||||
|
<path d="M10 17l-5-5 5-5" />
|
||||||
|
<path d="M15 12H5" />
|
||||||
|
</svg>
|
||||||
|
{!isCollapsed && <span>{isLoggingOut ? 'Abmelden …' : 'Abmelden'}</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isProfileOpen && (
|
||||||
|
<ProfileModal onClose={() => setIsProfileOpen(false)} />
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
components/layout/ThemeToggle.tsx
Normal file
94
components/layout/ThemeToggle.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dark-Mode-Umschalter als Icon-Button.
|
||||||
|
*
|
||||||
|
* - Persistiert die Wahl in localStorage ("light" | "dark" | null=System).
|
||||||
|
* - Schaltet die Klasse "dark" auf <html> (Tailwind class-Strategie).
|
||||||
|
* - Lauscht auf Systemwechsel, solange der User nichts gewaehlt hat.
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'calendar-theme';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
function applyTheme(theme: Theme) {
|
||||||
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [theme, setTheme] = useState<Theme>('light');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const initial: Theme = stored === 'dark' || (!stored && prefersDark) ? 'dark' : 'light';
|
||||||
|
setTheme(initial);
|
||||||
|
applyTheme(initial);
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Folgt Systemwechsel, solange der User nichts explizit gewaehlt hat.
|
||||||
|
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const listener = (event: MediaQueryListEvent) => {
|
||||||
|
const next: Theme = event.matches ? 'dark' : 'light';
|
||||||
|
setTheme(next);
|
||||||
|
applyTheme(next);
|
||||||
|
};
|
||||||
|
media.addEventListener('change', listener);
|
||||||
|
return () => media.removeEventListener('change', listener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setTheme((previous) => {
|
||||||
|
const next: Theme = previous === 'dark' ? 'light' : 'dark';
|
||||||
|
applyTheme(next);
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label={theme === 'dark' ? 'Zu hellem Design wechseln' : 'Zu dunklem Design wechseln'}
|
||||||
|
title={theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
||||||
|
className="rounded-md p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||||
|
>
|
||||||
|
{/* Flaches 2D-Icon: Sonne im Dark-Mode, Mond im Light-Mode. */}
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<svg
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
components/profile/ProfileModal.tsx
Normal file
163
components/profile/ProfileModal.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profil-Modal: der angemeldete Benutzer kann seinen Anzeigenamen
|
||||||
|
* (Username) aendern. E-Mail und Rolle sind nur lesbar dargestellt.
|
||||||
|
*
|
||||||
|
* Der Username ist optional und eindeutig; beim Speichern prueft der
|
||||||
|
* Server das Format und die Eindeutigkeit. Ein leerer Wert loescht
|
||||||
|
* den Username (das Badge faellt auf die E-Mail zurueck).
|
||||||
|
*
|
||||||
|
* Wird aus der Sidebar ueber das Profil-Badge geoeffnet; nach dem
|
||||||
|
* Speichern aktualisiert router.refresh() die Sidebar sofort.
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button, ErrorMessage, Input, Label, Badge } from '@/components/ui/ui';
|
||||||
|
import { ModalShell } from '@/components/ui/ModalShell';
|
||||||
|
|
||||||
|
interface Profile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
username: string | null;
|
||||||
|
role: 'ADMIN' | 'USER';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileModal({ onClose }: { onClose: () => void }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const loadProfile = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/me/profile', { cache: 'no-store' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Profil konnte nicht geladen werden.');
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { user: Profile };
|
||||||
|
setProfile(data.user);
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(
|
||||||
|
loadError instanceof Error
|
||||||
|
? loadError.message
|
||||||
|
: 'Profil konnte nicht geladen werden.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadProfile();
|
||||||
|
}, [loadProfile]);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const username = String(formData.get('username') ?? '').trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/me/profile', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: username || null }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||||
|
setError(data.error ?? 'Speichern fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { user: Profile };
|
||||||
|
setProfile(data.user);
|
||||||
|
setSuccess(true);
|
||||||
|
// Sidebar + Seite aktualisieren, damit der neue Name ueberall
|
||||||
|
// sofort sichtbar wird.
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalShell
|
||||||
|
title="Profil"
|
||||||
|
ariaLabel="Profil bearbeiten"
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="py-4 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Profil wird geladen …
|
||||||
|
</p>
|
||||||
|
) : !profile ? (
|
||||||
|
<ErrorMessage message={error ?? 'Profil konnte nicht geladen werden.'} />
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
{success && (
|
||||||
|
<p className="rounded-md bg-slate-100 px-3 py-2 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||||
|
Profil wurde gespeichert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="profile-email">E-Mail</Label>
|
||||||
|
<Input
|
||||||
|
id="profile-email"
|
||||||
|
type="email"
|
||||||
|
value={profile.email}
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
|
aria-readonly
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Die E-Mail-Adresse kann nicht geaendert werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="profile-username">Benutzername</Label>
|
||||||
|
<Input
|
||||||
|
id="profile-username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
maxLength={32}
|
||||||
|
defaultValue={profile.username ?? ''}
|
||||||
|
placeholder="z. B. max_mustermann"
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
2-32 Zeichen; Buchstaben, Ziffern, _ - . erlaubt. Leer lassen, um
|
||||||
|
den Namen zu entfernen (es wird dann die E-Mail angezeigt).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Rolle</Label>
|
||||||
|
<div className="pt-1">
|
||||||
|
<Badge variant={profile.role === 'ADMIN' ? 'accent' : 'neutral'}>
|
||||||
|
{profile.role === 'ADMIN' ? 'Administrator' : 'Benutzer'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={isSaving}>
|
||||||
|
{isSaving ? 'Speichern …' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
components/reservation/NewReservationButton.tsx
Normal file
271
components/reservation/NewReservationButton.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formular fuer neue Reservierungen (plan.md Abschnitt 15):
|
||||||
|
* Startdatum, Startzeit, Enddatum, Endzeit (15-Minuten-Schritte),
|
||||||
|
* Titel, Notiz.
|
||||||
|
*
|
||||||
|
* Der Server erwartet Start- UND Endzeit (createReservationSchema).
|
||||||
|
* Die maximale Reservierungsdauer wird live gegen die Kalender-Einstellung
|
||||||
|
* geprueft und der Benutzer bei Ueberschreitung informiert.
|
||||||
|
* Der Server prueft Kollisionen; 409 wird verstaendlich angezeigt.
|
||||||
|
*/
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
|
|
||||||
|
/** Stunden 00-23 (deutsches 24h-Format). */
|
||||||
|
const HOURS: readonly number[] = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
/** Minuten in 15-Minuten-Schritten. */
|
||||||
|
const MINUTES = [0, 15, 30, 45] as const;
|
||||||
|
|
||||||
|
/** Formatiert Minuten als lesbare Angabe (z. B. "4 Stunden"). */
|
||||||
|
function formatDuration(minutes: number): string {
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes} Minuten`;
|
||||||
|
}
|
||||||
|
const hours = minutes / 60;
|
||||||
|
return Number.isInteger(hours)
|
||||||
|
? `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
|
||||||
|
: `${hours.toFixed(1).replace('.', ',')} Stunden`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zwei-Dropdown-Zeitwaehler (Stunde + Minute), 24h-Format. */
|
||||||
|
function TimeSelect({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
}) {
|
||||||
|
const [hourPart, minutePart] = value.split(':');
|
||||||
|
const hour = hourPart ?? '09';
|
||||||
|
const minute = minutePart ?? '00';
|
||||||
|
const selectClasses =
|
||||||
|
'rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input type="hidden" name={name} value={value} />
|
||||||
|
<select
|
||||||
|
aria-label={`${id === 'start-time' ? 'Start' : 'End'}stunde`}
|
||||||
|
value={hour}
|
||||||
|
onChange={(e) => onChange(`${e.target.value}:${minute}`)}
|
||||||
|
className={selectClasses}
|
||||||
|
>
|
||||||
|
{HOURS.map((h) => (
|
||||||
|
<option key={h} value={String(h).padStart(2, '0')}>
|
||||||
|
{String(h).padStart(2, '0')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span aria-hidden="true">:</span>
|
||||||
|
<select
|
||||||
|
aria-label={`${id === 'start-time' ? 'Start' : 'End'}minuten`}
|
||||||
|
value={minute}
|
||||||
|
onChange={(e) => onChange(`${hour}:${e.target.value}`)}
|
||||||
|
className={selectClasses}
|
||||||
|
>
|
||||||
|
{MINUTES.map((m) => (
|
||||||
|
<option key={m} value={String(m).padStart(2, '0')}>
|
||||||
|
{String(m).padStart(2, '0')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewReservationButton({
|
||||||
|
calendarId,
|
||||||
|
maxReservationMinutes = 0,
|
||||||
|
}: {
|
||||||
|
calendarId: string;
|
||||||
|
/** Maximale Reservierungsdauer in Minuten; 0 = unbegrenzt. */
|
||||||
|
maxReservationMinutes?: number;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [startTime, setStartTime] = useState('09:00');
|
||||||
|
const [endTime, setEndTime] = useState('10:00');
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const [startDate, setStartDate] = useState(today);
|
||||||
|
const [endDate, setEndDate] = useState(today);
|
||||||
|
|
||||||
|
/** Live-Berechnung der Dauer aus Start- und Enddatum/-zeit. */
|
||||||
|
const durationMinutes = useMemo(() => {
|
||||||
|
const start = new Date(`${startDate}T${startTime}:00`);
|
||||||
|
const end = new Date(`${endDate}T${endTime}:00`);
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const diff = (end.getTime() - start.getTime()) / 60_000;
|
||||||
|
return diff > 0 ? diff : null;
|
||||||
|
}, [startDate, startTime, endDate, endTime]);
|
||||||
|
|
||||||
|
const exceedsMax =
|
||||||
|
durationMinutes !== null &&
|
||||||
|
maxReservationMinutes > 0 &&
|
||||||
|
durationMinutes > maxReservationMinutes;
|
||||||
|
|
||||||
|
const isInvalidRange = durationMinutes === null;
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const startAt = new Date(`${startDate}T${startTime}:00`);
|
||||||
|
const endAt = new Date(`${endDate}T${endTime}:00`);
|
||||||
|
|
||||||
|
if (endAt <= startAt) {
|
||||||
|
setError('Enddatum/-zeit muss nach dem Start liegen.');
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/calendars/${calendarId}/reservations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
calendarId,
|
||||||
|
startAt: startAt.toISOString(),
|
||||||
|
endAt: endAt.toISOString(),
|
||||||
|
title: formData.get('title'),
|
||||||
|
notes: formData.get('notes') || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json()) as { error?: string };
|
||||||
|
setError(data.error ?? 'Reservierung fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError('Netzwerkfehler. Bitte erneut versuchen.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
||||||
|
>
|
||||||
|
+ Neue Reservierung
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Neue Reservierung</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="start-date">Startdatum</Label>
|
||||||
|
<Input
|
||||||
|
id="start-date"
|
||||||
|
name="startDate"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="end-date">Enddatum</Label>
|
||||||
|
<Input
|
||||||
|
id="end-date"
|
||||||
|
name="endDate"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="title">Titel</Label>
|
||||||
|
<Input id="title" name="title" type="text" required maxLength={120} />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2" />
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="start-time">Startzeit</Label>
|
||||||
|
<TimeSelect
|
||||||
|
id="start-time"
|
||||||
|
name="startTime"
|
||||||
|
value={startTime}
|
||||||
|
onChange={setStartTime}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="end-time">Endzeit</Label>
|
||||||
|
<TimeSelect
|
||||||
|
id="end-time"
|
||||||
|
name="endTime"
|
||||||
|
value={endTime}
|
||||||
|
onChange={setEndTime}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label htmlFor="notes">Notiz (optional)</Label>
|
||||||
|
<Input id="notes" name="notes" type="text" maxLength={2000} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{durationMinutes !== null && (
|
||||||
|
<p
|
||||||
|
className={`sm:col-span-2 text-sm ${
|
||||||
|
exceedsMax
|
||||||
|
? 'font-medium text-red-700 dark:text-red-300'
|
||||||
|
: 'text-slate-500 dark:text-slate-400'
|
||||||
|
}`}
|
||||||
|
role={exceedsMax ? 'alert' : undefined}
|
||||||
|
>
|
||||||
|
Dauer: {formatDuration(durationMinutes)}
|
||||||
|
{exceedsMax &&
|
||||||
|
` — Maximale Reservierungsdauer ist ${formatDuration(maxReservationMinutes)}.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isInvalidRange && (
|
||||||
|
<p
|
||||||
|
className="sm:col-span-2 text-sm font-medium text-red-700 dark:text-red-300"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
Enddatum/-zeit muss nach dem Start liegen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 sm:col-span-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || exceedsMax || isInvalidRange}
|
||||||
|
className="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 disabled:opacity-50 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
||||||
|
>
|
||||||
|
{busy ? 'Reservieren …' : 'Reservieren'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="rounded-md bg-slate-200 px-4 py-2 text-sm font-medium hover:bg-slate-300"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
components/ui/ModalShell.tsx
Normal file
133
components/ui/ModalShell.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsamer Modal-Rahmen fuer alle Dialoge der App.
|
||||||
|
*
|
||||||
|
* Schliessen moeglich via:
|
||||||
|
* - X-Button (immer, auch waehrend einer laufenden Aktion)
|
||||||
|
* - ESC-Taste (ausser waehrend isPending, um Abbrueche zu vermeiden)
|
||||||
|
* - Klick auf den dunklen Hintergrund (ausser waehrend isPending)
|
||||||
|
*
|
||||||
|
* Alle Modals der App MUESSEN diesen Rahmen verwenden, damit das
|
||||||
|
* Schliess-Verhalten einheitlich ist (DRY).
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
/** Verfuegbare Modal-Groessen. */
|
||||||
|
export type ModalSize = 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
|
const SIZE_CLASSES: Record<ModalSize, string> = {
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-2xl',
|
||||||
|
xl: 'max-w-5xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ModalShell({
|
||||||
|
title,
|
||||||
|
ariaLabel,
|
||||||
|
isPending = false,
|
||||||
|
size = 'md',
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
/** Accessibility-Beschriftung (faellt auf title zurueck). */
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** Blockiert ESC/Backdrop-Schliessen waehrend einer laufenden Aktion. */
|
||||||
|
isPending?: boolean;
|
||||||
|
size?: ModalSize;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
// ESC schliesst das Modal. Der Listener wird bei jedem Render von
|
||||||
|
// onClose/isPending neu registriert (guenstig und immer aktuell).
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && !isPending) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isPending, onClose]);
|
||||||
|
|
||||||
|
// Merkt sich, ob der mousedown auf dem Backdrop (ausserhalb des
|
||||||
|
// Modal-Inhalts) begann. Nur dann ist ein click ein echtes
|
||||||
|
// "Klick ausserhalb". Grund: Bei einer Textauswahl, die IM Modal
|
||||||
|
// beginnt und ausserhalb endet, feuert der Browser das click-Event
|
||||||
|
// auf dem gemeinsamen Vorfahren beider Ziele - dem Backdrop. Ohne
|
||||||
|
// diese Pruefung wuerde das Modal beim Markieren von Text
|
||||||
|
// fälschlich geschlossen.
|
||||||
|
const mouseDownOnBackdropRef = useRef(false);
|
||||||
|
|
||||||
|
// Portal: Das Modal wird auf document.body gerendert, nicht innerhalb
|
||||||
|
// der aufrufenden Komponente. Grund: Wird ein Modal z. B. in der
|
||||||
|
// Sidebar (sticky, eigener Stacking-Kontext) gerendert, wuerde sein
|
||||||
|
// z-50 nur innerhalb der Sidebar gelten - Hauptinhalte daneben
|
||||||
|
// wuerden es überlappen. Auf body-Ebene gilt z-50 global.
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={ariaLabel ?? title}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
mouseDownOnBackdropRef.current =
|
||||||
|
event.target === event.currentTarget;
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (
|
||||||
|
event.target === event.currentTarget &&
|
||||||
|
mouseDownOnBackdropRef.current &&
|
||||||
|
!isPending
|
||||||
|
) {
|
||||||
|
mouseDownOnBackdropRef.current = false;
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-full ${SIZE_CLASSES[size]} max-h-[90vh] overflow-y-auto rounded-lg border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-700 dark:bg-slate-900`}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between gap-4">
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Schliessen"
|
||||||
|
className="rounded-md p-1 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** X-Icon (2D, Outline-Stil) zum Schliessen des Modals. */
|
||||||
|
function XIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
components/ui/ui.tsx
Normal file
120
components/ui/ui.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/** Gemeinsame UI-Bausteine (Forms, Buttons, Badges) im einheitlichen Look. */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einheitliches Badge fuer Status/Rollen-Labels.
|
||||||
|
* variant: neutral (Standard), success (aktiv), warning (Hinweis),
|
||||||
|
* danger (Fehler), accent (hervorgehoben, z. B. eigene Rolle).
|
||||||
|
*/
|
||||||
|
export function Badge({
|
||||||
|
children,
|
||||||
|
variant = 'neutral',
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: 'neutral' | 'success' | 'warning' | 'danger' | 'accent';
|
||||||
|
}) {
|
||||||
|
const styles = {
|
||||||
|
neutral:
|
||||||
|
'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
|
||||||
|
success:
|
||||||
|
'bg-slate-200 text-slate-800 ring-1 ring-slate-400 dark:bg-slate-600 dark:text-slate-100 dark:ring-slate-500',
|
||||||
|
warning:
|
||||||
|
'bg-slate-100 text-slate-700 ring-1 ring-slate-300 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-600',
|
||||||
|
danger:
|
||||||
|
'bg-slate-800 text-slate-100 dark:bg-slate-100 dark:text-slate-900',
|
||||||
|
accent:
|
||||||
|
'bg-slate-700 text-slate-50 dark:bg-slate-300 dark:text-slate-900',
|
||||||
|
}[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${styles}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900 ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
children,
|
||||||
|
type = 'submit',
|
||||||
|
variant = 'primary',
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
type?: 'submit' | 'button';
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger';
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type' | 'onClick' | 'disabled'>) {
|
||||||
|
const styles = {
|
||||||
|
primary: 'bg-slate-800 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200',
|
||||||
|
secondary:
|
||||||
|
'bg-slate-200 text-slate-900 hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600',
|
||||||
|
danger: 'bg-red-600 text-white hover:bg-red-700',
|
||||||
|
}[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
{...rest}
|
||||||
|
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${styles}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
const { className = '', ...rest } = props;
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...rest}
|
||||||
|
className={`w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500 ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Label({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
htmlFor={htmlFor}
|
||||||
|
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorMessage({ message }: { message: string | null }) {
|
||||||
|
if (!message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-900/40 dark:text-red-300"
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
docker-compose.yml
Normal file
78
docker-compose.yml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: calendar
|
||||||
|
POSTGRES_PASSWORD: calendar
|
||||||
|
POSTGRES_DB: calendar
|
||||||
|
# KEIN Port-Mapping: Die DB ist nur im internen Compose-Netz
|
||||||
|
# erreichbar (App verbindet ueber den Hostnamen "db"). Fuer
|
||||||
|
# lokalen Zugriff bei Bedarf: docker compose exec db psql ...
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
# Legt den least-privilege-App-User an (nur bei initialer
|
||||||
|
# Volume-Initialisierung; siehe Script fuer Bestands-Volumes).
|
||||||
|
- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U calendar -d calendar"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
# Taeglicher DB-Backup-Service: erstellt um 02:00 Uhr ein
|
||||||
|
# pg_dump-Backup im Named Volume "pgbackups". Aeltere Backups werden
|
||||||
|
# automatisch nach 7 Tagen geloescht (Retention). Die Backups
|
||||||
|
# ueberleben Neustarts des Stacks, da sie im Volume liegen.
|
||||||
|
db-backup:
|
||||||
|
image: prodrigestivill/postgres-backup-local:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST: db
|
||||||
|
POSTGRES_DB: calendar
|
||||||
|
POSTGRES_USER: calendar
|
||||||
|
POSTGRES_PASSWORD: calendar
|
||||||
|
# Taeglich um 02:00 Uhr (Cron).
|
||||||
|
SCHEDULE: "@daily"
|
||||||
|
# Backups nach 7 Tagen automatisch loeschen.
|
||||||
|
BACKUP_KEEP_DAYS: 7
|
||||||
|
# Zusaetzlich die letzten 4 Wochensicherungen behalten.
|
||||||
|
BACKUP_KEEP_WEEKS: 4
|
||||||
|
# Monats-Sicherungen der letzten 3 Monate behalten.
|
||||||
|
BACKUP_KEEP_MONTHS: 3
|
||||||
|
# Kompression aktivieren (platzsparend).
|
||||||
|
POSTGRES_EXTRA_OPTS: "-Z6"
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
volumes:
|
||||||
|
- pgbackups:/backups
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://calendar_app:calendar_app@db:5432/calendar?schema=public
|
||||||
|
# KEIN Fallback-Secret: Ohne gesetztes SESSION_SECRET failt die
|
||||||
|
# App hart beim Start (lib/config.ts), statt JWTs mit einem
|
||||||
|
# oeffentlich bekannten Secret zu signieren.
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET muss gesetzt sein (z. B. in .env, siehe .env.example)}
|
||||||
|
APP_URL: ${APP_URL:-http://localhost:3000}
|
||||||
|
ALLOW_OPEN_REGISTRATION: ${ALLOW_OPEN_REGISTRATION:-true}
|
||||||
|
# Optional: Admin-Account beim ersten Start anlegen (First-Run).
|
||||||
|
# Nicht-destruktiv: Legt den Admin nur an, wenn noch keiner
|
||||||
|
# existiert. Bestehende User werden niemals geloescht.
|
||||||
|
# Wenn NICHT gesetzt, laeuft das Seed-Script ohne Nebeneffekte durch.
|
||||||
|
SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-}
|
||||||
|
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-}
|
||||||
|
NODE_ENV: production
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
pgbackups:
|
||||||
26
docker/postgres/init/01-app-user.sql
Normal file
26
docker/postgres/init/01-app-user.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- Least-Privilege-DB-User fuer die App (wird nur bei INITIALISIERUNG
|
||||||
|
-- eines leeren pgdata-Volumes automatisch ausgefuehrt).
|
||||||
|
--
|
||||||
|
-- Bei BESTEHENDem Volume den User einmalig manuell anlegen:
|
||||||
|
-- docker compose exec db psql -U calendar -d calendar -f - <<'SQL'
|
||||||
|
-- ... (Inhalt dieses Scripts)
|
||||||
|
-- SQL
|
||||||
|
--
|
||||||
|
-- Der App-User darf nur in seiner Datenbank arbeiten und besitzt
|
||||||
|
-- keine Superuser-Rechte (im Gegensatz zum Owner "calendar").
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'calendar_app') THEN
|
||||||
|
CREATE ROLE calendar_app LOGIN PASSWORD 'calendar_app';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT CONNECT ON DATABASE calendar TO calendar_app;
|
||||||
|
GRANT USAGE ON SCHEMA public TO calendar_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO calendar_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO calendar_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO calendar_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO calendar_app;
|
||||||
101
lib/api/context.ts
Normal file
101
lib/api/context.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Hilfsfunktionen fuer API-Routen: laedt den Benutzer samt relevanten
|
||||||
|
* Beziehungen aus der Datenbank und stellt den Kontext fuer die
|
||||||
|
* Berechtigungspruefungen bereit.
|
||||||
|
*
|
||||||
|
* Wichtig (plan.md Abschnitt 2): Berechtigungen werden IMMER
|
||||||
|
* serverseitig geprueft, nie nur im Frontend versteckt.
|
||||||
|
*/
|
||||||
|
import { prisma } from '@/lib/db/client';
|
||||||
|
import { getAuthenticatedUser } from '@/lib/auth/session';
|
||||||
|
import { UnauthorizedError } from '@/lib/auth/session';
|
||||||
|
import type { AuthenticatedUser } from '@/lib/permissions/permissions';
|
||||||
|
|
||||||
|
export interface CalendarContext {
|
||||||
|
user: AuthenticatedUser;
|
||||||
|
calendar: {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
isActive: boolean;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
timezone: string;
|
||||||
|
/** Maximale Reservierungsdauer in Minuten; 0 = unbegrenzt. */
|
||||||
|
maxReservationMinutes: number;
|
||||||
|
/** Duerven Nicht-Admins die Titel fremder Reservierungen sehen? */
|
||||||
|
showTitleToMembers: boolean;
|
||||||
|
/** Duerven Nicht-Admins die Notizen fremder Reservierungen sehen? */
|
||||||
|
showNotesToMembers: boolean;
|
||||||
|
};
|
||||||
|
/** Ist der angemeldete User Mitglied dieses Kalenders? */
|
||||||
|
isMember: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt den vollstaendigen DB-Benutzer zur aktuellen Session. */
|
||||||
|
export async function requireDbUser() {
|
||||||
|
const authUser = await getAuthenticatedUser();
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: authUser.id },
|
||||||
|
select: { id: true, email: true, username: true, role: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
// Session gueltig, aber Benutzer geloescht -> Session ist ungueltig.
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laedt einen Kalender samt Mitgliedschaft des Users.
|
||||||
|
* Wirft UnauthorizedError, wenn der User den Kalender ueberhaupt
|
||||||
|
* nicht sehen darf - so werden Daten anderer Kalender niemals ueber
|
||||||
|
* die API preisgegeben.
|
||||||
|
*
|
||||||
|
* Zugriff hat (konsistent mit canViewCalendar in der zentralen
|
||||||
|
* Berechtigungsschicht):
|
||||||
|
* - ADMIN: alle Kalender (Verwaltungsrolle, auch die anderer User)
|
||||||
|
* - USER: nur der eine Kalender, in dem er Mitglied ist
|
||||||
|
*/
|
||||||
|
export async function requireCalendarContext(
|
||||||
|
calendarId: string,
|
||||||
|
): Promise<CalendarContext> {
|
||||||
|
const user = await requireDbUser();
|
||||||
|
|
||||||
|
const calendar = await prisma.calendar.findUnique({
|
||||||
|
where: { id: calendarId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
ownerId: true,
|
||||||
|
isActive: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
timezone: true,
|
||||||
|
maxReservationMinutes: true,
|
||||||
|
showTitleToMembers: true,
|
||||||
|
showNotesToMembers: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!calendar) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwner = calendar.ownerId === user.id;
|
||||||
|
const membership = await prisma.calendarMember.findUnique({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { calendarId: true },
|
||||||
|
});
|
||||||
|
const isMember = membership?.calendarId === calendar.id;
|
||||||
|
|
||||||
|
// Admins duerfen alle Kalender sehen und verwalten (canViewCalendar /
|
||||||
|
// canManageCalendar). User nur ihren eigenen (Mitgliedschaft).
|
||||||
|
const canAccess =
|
||||||
|
user.role === 'ADMIN' || isOwner || isMember;
|
||||||
|
|
||||||
|
if (!canAccess) {
|
||||||
|
// Bewusst 401 statt 404: Es wird nicht verraten, dass der
|
||||||
|
// Kalender existiert (plan.md Abschnitt 2).
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, calendar, isMember };
|
||||||
|
}
|
||||||
86
lib/api/responses.ts
Normal file
86
lib/api/responses.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Einheitliche API-Antwort-Helfer (plan.md: "HTTP-Statuscodes korrekt
|
||||||
|
* verwenden", "saubere Fehlerbehandlung").
|
||||||
|
*/
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import { UnauthorizedError } from '@/lib/auth/session';
|
||||||
|
import {
|
||||||
|
ReservationConflictError,
|
||||||
|
ReservationValidationError,
|
||||||
|
} from '@/lib/reservations/conflicts';
|
||||||
|
import {
|
||||||
|
MaxDurationExceededError,
|
||||||
|
CalendarInactiveError,
|
||||||
|
ReservationNotFoundError,
|
||||||
|
} from '@/lib/reservations/service';
|
||||||
|
|
||||||
|
/** 400: Validierungsfehler mit lesbaren Meldungen. */
|
||||||
|
export function validationErrorResponse(error: ZodError): NextResponse {
|
||||||
|
const messages = error.issues.map((issue) => issue.message).join(' ');
|
||||||
|
return NextResponse.json({ error: messages }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 401: Nicht angemeldet. */
|
||||||
|
export function unauthorizedResponse(): NextResponse {
|
||||||
|
return NextResponse.json({ error: 'Nicht angemeldet.' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 403: Angemeldet, aber keine Berechtigung. */
|
||||||
|
export function forbiddenResponse(): NextResponse {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Keine Berechtigung fuer diese Aktion.' },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 404: Ressource nicht gefunden (oder bewusst verschleiert). */
|
||||||
|
export function notFoundResponse(message = 'Nicht gefunden.'): NextResponse {
|
||||||
|
return NextResponse.json({ error: message }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 409: Zeitkollision. */
|
||||||
|
export function conflictResponse(): NextResponse {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Der gewuenschte Zeitraum ist bereits belegt.' },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mappt bekannte Fehler auf korrekte HTTP-Statuscodes.
|
||||||
|
* Unbekannte Fehler werden geloggt und als 500 gemeldet, ohne
|
||||||
|
* interne Details preiszugeben.
|
||||||
|
*/
|
||||||
|
export function errorResponse(error: unknown): NextResponse {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return validationErrorResponse(error);
|
||||||
|
}
|
||||||
|
if (error instanceof UnauthorizedError) {
|
||||||
|
return unauthorizedResponse();
|
||||||
|
}
|
||||||
|
if (error instanceof ReservationConflictError) {
|
||||||
|
return conflictResponse();
|
||||||
|
}
|
||||||
|
// Validierungsfehler (Vergangenheit, Start>=Ende): 400 mit Meldung.
|
||||||
|
if (error instanceof ReservationValidationError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
// Dauer ueberschritten: 400 mit lesbare Meldung (nicht 500).
|
||||||
|
if (error instanceof MaxDurationExceededError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
// Kalender deaktiviert/nicht gefunden: 403 (Berechtigung/Status).
|
||||||
|
if (error instanceof CalendarInactiveError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
// Reservierung nicht gefunden: 404.
|
||||||
|
if (error instanceof ReservationNotFoundError) {
|
||||||
|
return notFoundResponse(error.message);
|
||||||
|
}
|
||||||
|
console.error('[api] Unerwarteter Fehler:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Ein interner Fehler ist aufgetreten.' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
69
lib/auth/hub-provisioning.ts
Normal file
69
lib/auth/hub-provisioning.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* JIT-Provisioning (platform-plan.md §3.2):
|
||||||
|
* Koppelt einen Hub-User an einen lokalen Kalendartool-Account.
|
||||||
|
*
|
||||||
|
* - Existiert bereits ein Account mit dieser hubId → zurückgeben.
|
||||||
|
* - Existiert ein Account mit gleicher E-Mail (lokaler Login) → koppeln
|
||||||
|
* (hubId setzen), Passwort/Rolle bleiben unangetastet.
|
||||||
|
* - Sonst → neuen Account anlegen (Default-Rolle USER, kein Passwort).
|
||||||
|
*
|
||||||
|
* Bewusst KEINE Rollen-Synchronisation: Der Kalendertool-Admin verwaltet
|
||||||
|
* Rollen weiterhin selbst (Hub entscheidet nur über Tool-Zugriff).
|
||||||
|
*/
|
||||||
|
import { prisma } from "@/lib/db/client";
|
||||||
|
import type { AuthenticatedUser } from "@/lib/permissions/permissions";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
export interface HubIdentity {
|
||||||
|
hubId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function provisionHubUser(identity: HubIdentity): Promise<AuthenticatedUser> {
|
||||||
|
// 1. Bereits gekoppelt?
|
||||||
|
const byHubId = await prisma.user.findUnique({ where: { hubId: identity.hubId } });
|
||||||
|
if (byHubId) {
|
||||||
|
return {
|
||||||
|
id: byHubId.id,
|
||||||
|
email: byHubId.email,
|
||||||
|
username: byHubId.username,
|
||||||
|
role: byHubId.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Gleiche E-Mail vorhanden (lokaler Account) → koppeln.
|
||||||
|
const byEmail = await prisma.user.findUnique({ where: { email: identity.email } });
|
||||||
|
if (byEmail) {
|
||||||
|
const linked = await prisma.user.update({
|
||||||
|
where: { id: byEmail.id },
|
||||||
|
data: { hubId: identity.hubId },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: linked.id,
|
||||||
|
email: linked.email,
|
||||||
|
username: linked.username,
|
||||||
|
role: linked.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Neuen Account anlegen (JIT). Kein Passwort – Login nur via Hub.
|
||||||
|
const created = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: identity.email,
|
||||||
|
username: identity.name || null,
|
||||||
|
// passwordHash ist NOT NULL → nicht ratbares Zufallspasswort.
|
||||||
|
passwordHash: crypto.randomUUID() + crypto.randomUUID(),
|
||||||
|
role: "USER" as Role,
|
||||||
|
hubId: identity.hubId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: created.id,
|
||||||
|
email: created.email,
|
||||||
|
username: created.username,
|
||||||
|
role: created.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
79
lib/auth/hub.ts
Normal file
79
lib/auth/hub.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* JWKS-Client: lädt die öffentlichen Hub-Schlüssel und cached sie.
|
||||||
|
* Tools validieren Hub-Tokens gegen diesen Key (RS256) – kein geteiltes Secret.
|
||||||
|
*
|
||||||
|
* platform-plan.md §5.2: Der Hub exposet /.well-known/jwks.json.
|
||||||
|
*/
|
||||||
|
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||||
|
import { getConfig } from "@/lib/config";
|
||||||
|
|
||||||
|
const JWKS_CACHE_TTL_MS = 5 * 60 * 1000; // 5 Minuten (passend zum Hub-Cache-Header)
|
||||||
|
|
||||||
|
interface CachedJwks {
|
||||||
|
jwks: ReturnType<typeof createRemoteJWKSet>;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: CachedJwks | null = null;
|
||||||
|
|
||||||
|
function getHubUrl(): string {
|
||||||
|
const hubUrl = getConfig().hubUrl;
|
||||||
|
if (!hubUrl) {
|
||||||
|
throw new Error("HUB_URL fehlt – Hub-SSO ist nicht konfiguriert.");
|
||||||
|
}
|
||||||
|
return hubUrl.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remote-JWKSet mit einfachem TTL-Cache (jose hat keinen eingebauten). */
|
||||||
|
function getJwksSet(): ReturnType<typeof createRemoteJWKSet> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cache && now - cache.fetchedAt < JWKS_CACHE_TTL_MS) {
|
||||||
|
return cache.jwks;
|
||||||
|
}
|
||||||
|
const jwks = createRemoteJWKSet(new URL(`${getHubUrl()}/.well-known/jwks.json`));
|
||||||
|
cache = { jwks, fetchedAt: now };
|
||||||
|
return jwks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HubTokenResult {
|
||||||
|
hubId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert ein Hub-Token (Signatur, TTL, aud, iss).
|
||||||
|
* Wirft bei jedem Fehler – der Aufrufer entscheidet über die Response.
|
||||||
|
*/
|
||||||
|
export async function verifyHubToken(token: string): Promise<HubTokenResult> {
|
||||||
|
const config = getConfig();
|
||||||
|
const expectedAud = config.hubToolSlug;
|
||||||
|
if (!expectedAud) {
|
||||||
|
throw new Error("HUB_TOOL_SLUG fehlt – Tool-Slug für Hub-SSO nicht konfiguriert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { payload } = await jwtVerify(token, getJwksSet(), {
|
||||||
|
algorithms: ["RS256"],
|
||||||
|
audience: expectedAud,
|
||||||
|
issuer: config.hubIssuer ?? "http://localhost:3001",
|
||||||
|
clockTolerance: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hubId = payload.sub;
|
||||||
|
const email = payload.email;
|
||||||
|
const name = payload.name;
|
||||||
|
if (typeof hubId !== "string" || typeof email !== "string" || typeof name !== "string") {
|
||||||
|
throw new Error("Hub-Token enthält unvollständige Claims.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hubId,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
isAdmin: payload.isAdmin === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur für Typ-Export (jose JWTPayload) – nicht Teil des Contracts. */
|
||||||
|
export type { JWTPayload };
|
||||||
21
lib/auth/password.ts
Normal file
21
lib/auth/password.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Passwort-Hashing mit bcrypt (plan.md Abschnitt 11:
|
||||||
|
* "Passwoerter niemals selbst verschluesseln oder im Klartext speichern").
|
||||||
|
*/
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
/** Kostenfaktor: 12 Runden sind aktueller Standard (ca. 200-300 ms). */
|
||||||
|
const BCRYPT_ROUNDS = 12;
|
||||||
|
|
||||||
|
/** Hasht ein Klartext-Passwort fuer die Speicherung in der Datenbank. */
|
||||||
|
export async function hashPassword(plainPassword: string): Promise<string> {
|
||||||
|
return bcrypt.hash(plainPassword, BCRYPT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prueft ein Klartext-Passwort gegen den gespeicherten Hash. */
|
||||||
|
export async function verifyPassword(
|
||||||
|
plainPassword: string,
|
||||||
|
passwordHash: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return bcrypt.compare(plainPassword, passwordHash);
|
||||||
|
}
|
||||||
101
lib/auth/session.ts
Normal file
101
lib/auth/session.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Session-Management (plan.md Abschnitt 11).
|
||||||
|
*
|
||||||
|
* Signierte, HttpOnly-Cookies auf Basis von JWT (jose). Keine eigene
|
||||||
|
* Kryptografie, keine Klartext-Sessions in der Datenbank.
|
||||||
|
*/
|
||||||
|
import { SignJWT, jwtVerify } from 'jose';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import type { AuthenticatedUser } from '@/lib/permissions/permissions';
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME = 'calendar_session';
|
||||||
|
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 Tage
|
||||||
|
|
||||||
|
/** Fehler bei ungueltiger/abgelaufener Session. */
|
||||||
|
export class UnauthorizedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Nicht angemeldet oder Sitzung abgelaufen.');
|
||||||
|
this.name = 'UnauthorizedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSessionKey(): Promise<Uint8Array> {
|
||||||
|
const secret = getConfig().sessionSecret;
|
||||||
|
return new TextEncoder().encode(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erstellt ein signiertes Session-Token fuer den Benutzer. */
|
||||||
|
export async function createSessionToken(user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const key = await getSessionKey();
|
||||||
|
return new SignJWT({ sub: user.id, email: user.email, role: user.role })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`)
|
||||||
|
.sign(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setzt das Session-Cookie sicher (HttpOnly, SameSite=Lax, Secure in Prod). */
|
||||||
|
export async function setSessionCookie(token: string): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(SESSION_COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loescht das Session-Cookie (Logout). */
|
||||||
|
export async function clearSessionCookie(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete(SESSION_COOKIE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest und verifiziert die aktuelle Session.
|
||||||
|
* Wirft UnauthorizedError, wenn keine gueltige Session existiert.
|
||||||
|
*/
|
||||||
|
export async function getAuthenticatedUser(): Promise<AuthenticatedUser> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, await getSessionKey());
|
||||||
|
if (typeof payload.sub !== 'string' || typeof payload.role !== 'string') {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
}
|
||||||
|
// Alte Tokens (vor E-Mail-Erweiterung) enthalten keine E-Mail;
|
||||||
|
// in dem Fall wird ein Platzhalter verwendet, requireDbUser laedt
|
||||||
|
// ohnehin die frischen Daten aus der DB.
|
||||||
|
const email =
|
||||||
|
typeof payload.email === 'string' ? payload.email : 'unbekannt@lokal';
|
||||||
|
// Username ist nicht im JWT (kann sich aendern); requireDbUser
|
||||||
|
// laedt den aktuellen Wert aus der DB. Das JWT liefert null als
|
||||||
|
// Platzhalter, damit der Typ erfuellt ist.
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
email,
|
||||||
|
username: null,
|
||||||
|
role: payload.role as AuthenticatedUser['role'],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie getAuthenticatedUser, gibt aber null statt zu werfen. */
|
||||||
|
export async function tryGetAuthenticatedUser(): Promise<AuthenticatedUser | null> {
|
||||||
|
try {
|
||||||
|
return await getAuthenticatedUser();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
lib/config.ts
Normal file
60
lib/config.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Zentrale, typsichere Konfiguration aus Umgebungsvariablen.
|
||||||
|
*
|
||||||
|
* Alle Werte werden beim ersten Zugriff validiert, damit Fehlkonfigurationen
|
||||||
|
* sofort (und nicht erst zur Laufzeit an versteckter Stelle) auffallen.
|
||||||
|
* Keine Magic Strings in der restlichen Anwendung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function requireEnv(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Umgebungsvariable ${name} ist nicht gesetzt. Bitte .env anlegen (Vorlage: .env.example).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session-Secret: mindestens 32 Zeichen, sonst ist es nicht sicher. */
|
||||||
|
function requireSessionSecret(): string {
|
||||||
|
const secret = requireEnv('SESSION_SECRET');
|
||||||
|
if (secret.length < 32) {
|
||||||
|
throw new Error(
|
||||||
|
'SESSION_SECRET muss mindestens 32 Zeichen lang sein (z. B. crypto.randomBytes(48).toString("base64url")).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest die Konfiguration einmalig und cached das Ergebnis. */
|
||||||
|
let cachedConfig: AppConfig | undefined;
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
sessionSecret: string;
|
||||||
|
appUrl: string;
|
||||||
|
/** Wenn false, ist die Registrierung nur ueber Invite-Links moeglich. */
|
||||||
|
allowOpenRegistration: boolean;
|
||||||
|
/** MultiToolApp-Plattform: Basis-URL (JWKS-Abruf). null = SSO deaktiviert. */
|
||||||
|
hubUrl: string | null;
|
||||||
|
/** Tool-Slug in der Hub-Registry (Token-`aud`). */
|
||||||
|
hubToolSlug: string | null;
|
||||||
|
/** Erwarteter Token-`iss` (Hub-Basis-URL). */
|
||||||
|
hubIssuer: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfig(): AppConfig {
|
||||||
|
if (cachedConfig) {
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
cachedConfig = {
|
||||||
|
sessionSecret: requireSessionSecret(),
|
||||||
|
appUrl: process.env.APP_URL?.trim() || 'http://localhost:3000',
|
||||||
|
allowOpenRegistration:
|
||||||
|
(process.env.ALLOW_OPEN_REGISTRATION ?? 'true').toLowerCase() === 'true',
|
||||||
|
hubUrl: process.env.HUB_URL?.trim() || null,
|
||||||
|
hubToolSlug: process.env.HUB_TOOL_SLUG?.trim() || null,
|
||||||
|
hubIssuer: process.env.HUB_ISSUER?.trim() || null,
|
||||||
|
};
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
23
lib/db/client.ts
Normal file
23
lib/db/client.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Globaler Prisma-Client.
|
||||||
|
*
|
||||||
|
* In der Entwicklung startet Next.js bei Hot Reload mehrfach Modul-Instanzen;
|
||||||
|
* ohne Caching wuerde jede Instanz eigene Verbindungen aufbauen und die
|
||||||
|
* Datenbank mit Verbindungen ueberfluten. Daher wird die Instanz auf
|
||||||
|
* `globalThis` gecacht (offizielles Prisma-Rezept fuer Next.js).
|
||||||
|
*/
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma: PrismaClient =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
43
lib/db/tx.ts
Normal file
43
lib/db/tx.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Transaktions-Helper: SERIALIZABLE mit Retry bei Serialisierungs-
|
||||||
|
* konflikten (Prisma-Fehlercode P2034).
|
||||||
|
*
|
||||||
|
* Postgres bricht bei Parallelitaets-Anomalien unter SERIALIZABLE eine
|
||||||
|
* der beteiligten Transaktionen ab. Wiederholbare Operationen (z. B.
|
||||||
|
* Registrierung mit Invite-Verbrauch, Rollen-Aenderung) werden dann
|
||||||
|
* einfach erneut ausgefuehrt - der Konflikt loest sich auf, weil die
|
||||||
|
* andere Transaktion inzwischen committet hat.
|
||||||
|
*/
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { prisma } from '@/lib/db/client';
|
||||||
|
|
||||||
|
/** Ist es ein wiederholbarer Serialisierungs-Konflikt? */
|
||||||
|
function isSerializationError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2034'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuehrt die Operation in einer SERIALIZABLE-Transaktion aus und
|
||||||
|
* wiederholt sie bei Serialisierungs-Konflikten (max. 3 Nachlaeufe).
|
||||||
|
*/
|
||||||
|
export async function withSerializableTx<T>(
|
||||||
|
operation: (tx: Prisma.TransactionClient) => Promise<T>,
|
||||||
|
maxRetries = 3,
|
||||||
|
): Promise<T> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(operation, {
|
||||||
|
isolationLevel: 'Serializable',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt >= maxRetries || !isSerializationError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Naechster Versuch: Die abgebrochene Transaktion hat nichts
|
||||||
|
// committet, ein erneuter Lauf sieht den aktuellen Stand.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
lib/invites/tokens.ts
Normal file
45
lib/invites/tokens.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Invite-System (plan.md Abschnitt 3 und 9).
|
||||||
|
*
|
||||||
|
* - Token wird zufaellig und nicht erratbar erzeugt (crypto.randomBytes).
|
||||||
|
* - In der Datenbank liegt NUR der SHA-256-Hash des Tokens.
|
||||||
|
* - Der Klartext-Token wird genau einmal beim Erstellen zurueckgegeben.
|
||||||
|
*/
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
/** Laenge des rohen Tokens in Bytes (256 Bit Entropie -> nicht erratbar). */
|
||||||
|
const TOKEN_BYTE_LENGTH = 32;
|
||||||
|
|
||||||
|
/** Invite-Links laufen standardmaessig nach 7 Tagen ab. */
|
||||||
|
export const INVITE_DEFAULT_TTL_DAYS = 7;
|
||||||
|
|
||||||
|
/** Erzeugt einen neuen, nicht erratbaren Invite-Token (Klartext). */
|
||||||
|
export function generateInviteToken(): string {
|
||||||
|
return randomBytes(TOKEN_BYTE_LENGTH).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Berechnet den SHA-256-Hash eines Tokens fuer die Datenbank. */
|
||||||
|
export function hashInviteToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard-Ablaufzeit fuer neue Invites. */
|
||||||
|
export function defaultInviteExpiry(now: Date = new Date()): Date {
|
||||||
|
const expiry = new Date(now);
|
||||||
|
expiry.setDate(expiry.getDate() + INVITE_DEFAULT_TTL_DAYS);
|
||||||
|
return expiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist ein Invite gueltig (nicht abgelaufen, nicht verwendet)? */
|
||||||
|
export function isInviteUsable(
|
||||||
|
invite: { expiresAt: Date | null; usedAt: Date | null },
|
||||||
|
now: Date = new Date(),
|
||||||
|
): boolean {
|
||||||
|
if (invite.usedAt !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (invite.expiresAt !== null && invite.expiresAt <= now) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
141
lib/permissions/permissions.ts
Normal file
141
lib/permissions/permissions.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Zentrale Definition aller Berechtigungen.
|
||||||
|
*
|
||||||
|
* Aus plan.md Abschnitt 20: Eine zentrale Berechtigungsschicht, damit
|
||||||
|
* Berechtigungslogik nicht ueber den ganzen Code verteilt wird.
|
||||||
|
* Alle API-Routen und Server-Komponenten MUSSSEN diese Funktionen
|
||||||
|
* verwenden statt eigene Ad-hoc-Pruefungen zu schreiben.
|
||||||
|
*/
|
||||||
|
import type { Role } from '@prisma/client';
|
||||||
|
|
||||||
|
/** Minimal notwendige Informationen ueber den angemeldeten Benutzer. */
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
/** Frei waehlbarer Anzeigename; null, wenn nicht gesetzt. */
|
||||||
|
username: string | null;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal notwendige Informationen ueber einen Kalender. */
|
||||||
|
export interface CalendarRef {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal notwendige Informationen ueber eine Reservierung. */
|
||||||
|
export interface ReservationRef {
|
||||||
|
id: string;
|
||||||
|
calendarId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal notwendige Informationen ueber eine Mitgliedschaft. */
|
||||||
|
export interface MembershipRef {
|
||||||
|
calendarId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darf der User den Kalender sehen?
|
||||||
|
* Admin: alle Kalender (Verwaltungsrolle). User: nur Kalender, in
|
||||||
|
* denen er Mitglied ist. Deaktivierte Kalender bleiben fuer Member
|
||||||
|
* sichtbar (mit Hinweis), damit sie ihre Reservierungen noch einsehen
|
||||||
|
* koennen.
|
||||||
|
*/
|
||||||
|
export function canViewCalendar(
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
calendar: CalendarRef,
|
||||||
|
isMember: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (user.role === 'ADMIN') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darf der User den Kalender verwalten (bearbeiten, Invites erstellen,
|
||||||
|
* loeschen)? Admins duerfen alle Kalender verwalten (auch die anderer
|
||||||
|
* Benutzer), normale User keinen.
|
||||||
|
*/
|
||||||
|
export function canManageCalendar(
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
calendar: CalendarRef,
|
||||||
|
): boolean {
|
||||||
|
return user.role === 'ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darf der User eine Reservierung in diesem Kalender erstellen?
|
||||||
|
* Admins in allen aktiven Kalendern, User nur in ihrem einen Kalender
|
||||||
|
* (Mitgliedschaft). Deaktivierte Kalender erlauben keine neuen
|
||||||
|
* Reservierungen.
|
||||||
|
*/
|
||||||
|
export function canCreateReservation(
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
calendar: CalendarRef,
|
||||||
|
isMember: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (!calendar.isActive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (user.role === 'ADMIN') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darf der User eine Reservierung LOESCHEN?
|
||||||
|
* Ausschliesslich Admins (in allen Kalendern). Normale User koennen
|
||||||
|
* Reservierungen nur anlegen und sehen - nicht loeschen.
|
||||||
|
*/
|
||||||
|
export function canDeleteReservation(
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
_reservation: ReservationRef,
|
||||||
|
calendar: CalendarRef,
|
||||||
|
): boolean {
|
||||||
|
return user.role === 'ADMIN' && canViewCalendar(user, calendar, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darf der User die Reservierung sehen?
|
||||||
|
* Admin: in allen Kalendern alle. User: nur eigene Reservierungen
|
||||||
|
* im eigenen Kalender.
|
||||||
|
*/
|
||||||
|
export function canViewReservation(
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
reservation: ReservationRef,
|
||||||
|
calendar: CalendarRef,
|
||||||
|
isMember: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (!canViewCalendar(user, calendar, isMember)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (user.role === 'ADMIN') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return reservation.userId === user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darf der User die Reservierung aendern oder loeschen?
|
||||||
|
* Admin: alle Reservierungen in allen Kalendern.
|
||||||
|
* User: ausschliesslich eigene Reservierungen.
|
||||||
|
*/
|
||||||
|
export function canModifyReservation(
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
reservation: ReservationRef,
|
||||||
|
calendar: CalendarRef,
|
||||||
|
isMember: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (!canViewCalendar(user, calendar, isMember)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (user.role === 'ADMIN') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return reservation.userId === user.id;
|
||||||
|
}
|
||||||
40
lib/privacy/display.ts
Normal file
40
lib/privacy/display.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Datenschutz-Helfer fuer die Anzeige von Benutzerbezügen.
|
||||||
|
*
|
||||||
|
* E-Mail-Adressen sind personenbezogene Daten. In Kalender- und
|
||||||
|
* Reservierungslisten sehen normale Mitglieder nur einen maskierten
|
||||||
|
* Kuerzel statt der vollstaendigen Adresse; wer einen Username gesetzt
|
||||||
|
* hat, erscheint unter diesem. Vollstaendige E-Mails sind der
|
||||||
|
* Admin-Verwaltung vorbehalten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maskiert eine E-Mail: "max.mustermann@example.com" -> "m***@e***.com".
|
||||||
|
* Fuer ungueltige/leere Eingaben wird ein neutraler Platzhalter
|
||||||
|
* zurueckgegeben.
|
||||||
|
*/
|
||||||
|
export function maskEmail(email: string | null | undefined): string {
|
||||||
|
if (!email) {
|
||||||
|
return 'Unbekannt';
|
||||||
|
}
|
||||||
|
const at = email.indexOf('@');
|
||||||
|
if (at <= 0 || at === email.length - 1) {
|
||||||
|
return 'Unbekannt';
|
||||||
|
}
|
||||||
|
const local = email.slice(0, 1);
|
||||||
|
const domain = email.slice(at + 1);
|
||||||
|
const lastDot = domain.lastIndexOf('.');
|
||||||
|
const domainMasked =
|
||||||
|
lastDot > 0
|
||||||
|
? `${domain.slice(0, 1)}***${domain.slice(lastDot)}`
|
||||||
|
: `${domain.slice(0, 1)}***`;
|
||||||
|
return `${local}***@${domainMasked}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anzeigename: Username bevorzugt, sonst maskierte E-Mail. */
|
||||||
|
export function displayName(
|
||||||
|
username: string | null | undefined,
|
||||||
|
email: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
return username ?? maskEmail(email);
|
||||||
|
}
|
||||||
81
lib/reservations/conflicts.ts
Normal file
81
lib/reservations/conflicts.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Konfliktpruefung fuer Reservierungen (plan.md Abschnitt 6).
|
||||||
|
*
|
||||||
|
* Zwei Zeitraume kollidieren, wenn sie sich ueberlappen:
|
||||||
|
* existing.start < new.end UND new.start < existing.end
|
||||||
|
*
|
||||||
|
* Die Pruefung MUSS serverseitig innerhalb einer Transaktion mit
|
||||||
|
* konsistentem Isolation-Level erfolgen, damit gleichzeitige Buchungen
|
||||||
|
* zweier Benutzer nicht zu einer Doppelbuchung fuehren koennen.
|
||||||
|
*/
|
||||||
|
import type { Reservation } from '@prisma/client';
|
||||||
|
|
||||||
|
/** Fehler, der bei einer Zeitkollision geworfen wird. */
|
||||||
|
export class ReservationConflictError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Der gewuenschte Zeitraum ist bereits belegt.');
|
||||||
|
this.name = 'ReservationConflictError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fehler, wenn der Zeitraum ungueltig ist (Vergangenheit, Start>=Ende). */
|
||||||
|
export class ReservationValidationError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ReservationValidationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prueft rein funktional, ob sich zwei Zeitraume ueberlappen. */
|
||||||
|
export function intervalsOverlap(
|
||||||
|
aStart: Date,
|
||||||
|
aEnd: Date,
|
||||||
|
bStart: Date,
|
||||||
|
bEnd: Date,
|
||||||
|
): boolean {
|
||||||
|
return aStart < bEnd && bStart < aEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Findet kollidierende Reservierungen eines Kalenders im Zeitraum.
|
||||||
|
* Rein funktional und damit gut testbar; die Datenbankabfrage wird
|
||||||
|
* als Parameter injiziert.
|
||||||
|
*/
|
||||||
|
export function findConflicts(
|
||||||
|
existing: Pick<Reservation, 'id' | 'startAt' | 'endAt'>[],
|
||||||
|
newStart: Date,
|
||||||
|
newEnd: Date,
|
||||||
|
ignoreReservationId?: string,
|
||||||
|
): Pick<Reservation, 'id' | 'startAt' | 'endAt'>[] {
|
||||||
|
return existing.filter(
|
||||||
|
(reservation) =>
|
||||||
|
reservation.id !== ignoreReservationId &&
|
||||||
|
intervalsOverlap(reservation.startAt, reservation.endAt, newStart, newEnd),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert, dass Start vor Ende liegt und der Zeitraum nicht in der
|
||||||
|
* Vergangenheit beginnt. Wirft sonst eine aussagekraeftige Fehlermeldung.
|
||||||
|
*/
|
||||||
|
export function validateReservationPeriod(
|
||||||
|
startAt: Date,
|
||||||
|
endAt: Date,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): void {
|
||||||
|
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
|
||||||
|
throw new ReservationValidationError(
|
||||||
|
'Start- und Endzeit muessen gueltige Zeitpunkte sein.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (startAt >= endAt) {
|
||||||
|
throw new ReservationValidationError(
|
||||||
|
'Die Startzeit muss vor der Endzeit liegen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (startAt < now) {
|
||||||
|
throw new ReservationValidationError(
|
||||||
|
'Der Zeitraum liegt in der Vergangenheit.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
238
lib/reservations/service.ts
Normal file
238
lib/reservations/service.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Service-Schicht fuer Reservierungen (plan.md Abschnitt 5, 6 und 15).
|
||||||
|
*
|
||||||
|
* Die Doppelbuchungspruefung laeuft ATOMAR innerhalb einer serialisierbaren
|
||||||
|
* Transaktion: Der Server validiert Berechtigung, Zeitraum, maximale
|
||||||
|
* Dauer und Kollision in einem einzigen kritischen Abschnitt. Zwei
|
||||||
|
* gleichzeitige Buchungen koennen so niemals denselben Slot belegen.
|
||||||
|
*
|
||||||
|
* Dauer: Reservierungen geben Start- und Endzeit an; die maximale
|
||||||
|
* Dauer wird gegen die Kalender-Einstellung (max_reservation_minutes)
|
||||||
|
* geprueft. 0 bedeutet unbegrenzt.
|
||||||
|
*/
|
||||||
|
import { prisma } from '@/lib/db/client';
|
||||||
|
import { ReservationConflictError, validateReservationPeriod } from '@/lib/reservations/conflicts';
|
||||||
|
import type { Reservation } from '@prisma/client';
|
||||||
|
|
||||||
|
/** Zeitfenster-Puffer rund um den gebuchten Zeitraum (24 Stunden). */
|
||||||
|
const WINDOW_BUFFER_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute Obergrenze fuer eine einzelne Reservierung (24 Stunden).
|
||||||
|
* Gilt AUCH wenn der Kalender "unbegrenzt" (0) eingestellt ist, damit
|
||||||
|
* kein Benutzer den Kalender mit jahrelangen Blockaden lahmlegen kann.
|
||||||
|
*/
|
||||||
|
export const ABSOLUTE_MAX_DURATION_MINUTES = 24 * 60;
|
||||||
|
|
||||||
|
/** Fehler, wenn die maximale Reservierungsdauer ueberschritten wird. */
|
||||||
|
export class MaxDurationExceededError extends Error {
|
||||||
|
readonly maxMinutes: number;
|
||||||
|
|
||||||
|
constructor(maxMinutes: number) {
|
||||||
|
super(
|
||||||
|
maxMinutes === 0
|
||||||
|
? 'Die Reservierungsdauer ist ungueltig.'
|
||||||
|
: `Maximale Reservierungsdauer ist ${formatDuration(maxMinutes)}.`,
|
||||||
|
);
|
||||||
|
this.name = 'MaxDurationExceededError';
|
||||||
|
this.maxMinutes = maxMinutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fehler, wenn der Kalender nicht existiert oder deaktiviert ist. */
|
||||||
|
export class CalendarInactiveError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Kalender nicht gefunden oder deaktiviert.');
|
||||||
|
this.name = 'CalendarInactiveError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fehler, wenn die zu aendernde Reservierung nicht existiert. */
|
||||||
|
export class ReservationNotFoundError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Reservierung nicht gefunden.');
|
||||||
|
this.name = 'ReservationNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formatiert Minuten als lesbare Angabe (z. B. "4 Stunden" / "90 Minuten"). */
|
||||||
|
export function formatDuration(minutes: number): string {
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes} Minuten`;
|
||||||
|
}
|
||||||
|
const hours = minutes / 60;
|
||||||
|
return Number.isInteger(hours)
|
||||||
|
? `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
|
||||||
|
: `${hours.toFixed(1).replace('.', ',')} Stunden`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prueft, ob der Zeitraum die maximale Dauer des Kalenders einhaelt. */
|
||||||
|
function assertMaxDuration(
|
||||||
|
startAt: Date,
|
||||||
|
endAt: Date,
|
||||||
|
maxReservationMinutes: number,
|
||||||
|
): void {
|
||||||
|
const durationMinutes = (endAt.getTime() - startAt.getTime()) / 60_000;
|
||||||
|
// Absolute Obergrenze gilt immer (DoS-Schutz gegen Blockaden).
|
||||||
|
if (durationMinutes > ABSOLUTE_MAX_DURATION_MINUTES) {
|
||||||
|
throw new MaxDurationExceededError(ABSOLUTE_MAX_DURATION_MINUTES);
|
||||||
|
}
|
||||||
|
if (maxReservationMinutes === 0) {
|
||||||
|
return; // 0 = unbegrenzt (innerhalb der absoluten Obergrenze).
|
||||||
|
}
|
||||||
|
if (durationMinutes > maxReservationMinutes) {
|
||||||
|
throw new MaxDurationExceededError(maxReservationMinutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateReservationInput {
|
||||||
|
calendarId: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
startAt: Date;
|
||||||
|
endAt: Date;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateReservationInput {
|
||||||
|
startAt?: Date;
|
||||||
|
endAt?: Date;
|
||||||
|
title?: string;
|
||||||
|
notes?: string | null;
|
||||||
|
status?: 'CONFIRMED' | 'CANCELLED';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt eine Reservierung atomar.
|
||||||
|
*
|
||||||
|
* Ablauf innerhalb der Transaktion:
|
||||||
|
* 1. Kalender laden und pruefen (aktiv? maximale Dauer?).
|
||||||
|
* 2. Bestehende Reservierungen im Zeitfenster sperren/lesen.
|
||||||
|
* 3. Kollision pruefen -> bei Konflikt ReservationConflictError.
|
||||||
|
* 4. Reservierung anlegen.
|
||||||
|
*
|
||||||
|
* Das Isolation-Level SERIALIZABLE verhindert, dass zwei parallele
|
||||||
|
* Transaktionen dieselbe Luecke gleichzeitig als frei einstufen.
|
||||||
|
*/
|
||||||
|
export async function createReservationAtomically(
|
||||||
|
input: CreateReservationInput,
|
||||||
|
): Promise<Reservation> {
|
||||||
|
validateReservationPeriod(input.startAt, input.endAt);
|
||||||
|
|
||||||
|
return prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const calendar = await tx.calendar.findUnique({
|
||||||
|
where: { id: input.calendarId },
|
||||||
|
select: { id: true, isActive: true, maxReservationMinutes: true },
|
||||||
|
});
|
||||||
|
if (!calendar || !calendar.isActive) {
|
||||||
|
throw new CalendarInactiveError();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertMaxDuration(input.startAt, input.endAt, calendar.maxReservationMinutes);
|
||||||
|
|
||||||
|
// Zeitfenster mit Puffer lesen: alle Reservierungen, die den Tag
|
||||||
|
// des neuen Zeitraums ueberdecken, genau genug fuer den Overlap-Check.
|
||||||
|
const windowStart = new Date(input.startAt.getTime() - WINDOW_BUFFER_MS);
|
||||||
|
const windowEnd = new Date(input.endAt.getTime() + WINDOW_BUFFER_MS);
|
||||||
|
|
||||||
|
const overlapping = await tx.reservation.findMany({
|
||||||
|
where: {
|
||||||
|
calendarId: input.calendarId,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
startAt: { lt: windowEnd },
|
||||||
|
endAt: { gt: windowStart },
|
||||||
|
},
|
||||||
|
select: { id: true, startAt: true, endAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const conflict = overlapping.some(
|
||||||
|
(existing) => existing.startAt < input.endAt && input.startAt < existing.endAt,
|
||||||
|
);
|
||||||
|
if (conflict) {
|
||||||
|
throw new ReservationConflictError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.reservation.create({
|
||||||
|
data: {
|
||||||
|
calendarId: input.calendarId,
|
||||||
|
userId: input.userId,
|
||||||
|
title: input.title,
|
||||||
|
startAt: input.startAt,
|
||||||
|
endAt: input.endAt,
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ isolationLevel: 'Serializable' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert eine Reservierung atomar (gleiche Konflikt- und
|
||||||
|
* Dauerlogik; die eigene Reservierung wird aus der Kollisionspruefung
|
||||||
|
* ausgenommen).
|
||||||
|
*/
|
||||||
|
export async function updateReservationAtomically(
|
||||||
|
reservationId: string,
|
||||||
|
input: UpdateReservationInput,
|
||||||
|
): Promise<Reservation> {
|
||||||
|
return prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const existing = await tx.reservation.findUnique({
|
||||||
|
where: { id: reservationId },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new ReservationNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAt = input.startAt ?? existing.startAt;
|
||||||
|
const endAt = input.endAt ?? existing.endAt;
|
||||||
|
validateReservationPeriod(startAt, endAt);
|
||||||
|
|
||||||
|
const calendar = await tx.calendar.findUnique({
|
||||||
|
where: { id: existing.calendarId },
|
||||||
|
select: { isActive: true, maxReservationMinutes: true },
|
||||||
|
});
|
||||||
|
// Konsistenz zum Anlegen: In deaktivierten Kalendern sind auch
|
||||||
|
// Aenderungen bestehender Reservierungen nicht mehr moeglich.
|
||||||
|
if (!calendar || !calendar.isActive) {
|
||||||
|
throw new CalendarInactiveError();
|
||||||
|
}
|
||||||
|
assertMaxDuration(startAt, endAt, calendar.maxReservationMinutes);
|
||||||
|
|
||||||
|
const windowStart = new Date(startAt.getTime() - WINDOW_BUFFER_MS);
|
||||||
|
const windowEnd = new Date(endAt.getTime() + WINDOW_BUFFER_MS);
|
||||||
|
|
||||||
|
const overlapping = await tx.reservation.findMany({
|
||||||
|
where: {
|
||||||
|
calendarId: existing.calendarId,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
id: { not: reservationId },
|
||||||
|
startAt: { lt: windowEnd },
|
||||||
|
endAt: { gt: windowStart },
|
||||||
|
},
|
||||||
|
select: { id: true, startAt: true, endAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const conflict = overlapping.some(
|
||||||
|
(other) => other.startAt < endAt && startAt < other.endAt,
|
||||||
|
);
|
||||||
|
if (conflict) {
|
||||||
|
throw new ReservationConflictError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.reservation.update({
|
||||||
|
where: { id: reservationId },
|
||||||
|
data: {
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
title: input.title ?? existing.title,
|
||||||
|
notes: input.notes === undefined ? existing.notes : input.notes,
|
||||||
|
status: input.status ?? existing.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ isolationLevel: 'Serializable' },
|
||||||
|
);
|
||||||
|
}
|
||||||
109
lib/security/rate-limit.ts
Normal file
109
lib/security/rate-limit.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* In-Memory Rate-Limiting (plan.md Abschnitt 11).
|
||||||
|
*
|
||||||
|
* Fester Zeitfenster-Algorithmus pro Schluessel (z. B. "login:email:x@y").
|
||||||
|
* Bewusst In-Memory: Fuer den MVP ausreichend; bei mehreren App-Instanzen
|
||||||
|
* muesste ein gemeinsamer Store (z. B. Redis) verwendet werden.
|
||||||
|
*
|
||||||
|
* Die Anzahl getrackter Schluessel ist begrenzt (Schutz vor Memory-DoS
|
||||||
|
* durch Angreifer mit vielen erfundenen E-Mails/IPs). Beim Erreichen
|
||||||
|
* des Limits werden zuerst abgelaufene, danach die aeltesten Eintraege
|
||||||
|
* entfernt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Maximale Anzahl gleichzeitig getrackter Schluessel. */
|
||||||
|
const MAX_TRACKED_KEYS = 10_000;
|
||||||
|
|
||||||
|
interface Bucket {
|
||||||
|
count: number;
|
||||||
|
firstAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
export interface RateLimitResult {
|
||||||
|
allowed: boolean;
|
||||||
|
/** Sekunden bis zum naechsten erlaubten Versuch (falls blockiert). */
|
||||||
|
retryAfterSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raeumt abgelaufene bzw. ueberschuessige Eintraege auf. */
|
||||||
|
function makeRoom(now: number, windowMs: number): void {
|
||||||
|
if (buckets.size < MAX_TRACKED_KEYS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 1) Abgelaufene Eintraege entfernen (Map-Iteration erlaubt Loeschen).
|
||||||
|
for (const [key, bucket] of buckets) {
|
||||||
|
if (now - bucket.firstAt > windowMs) {
|
||||||
|
buckets.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) Immer noch voll: aelteste Eintraege verwerfen (Map behaelt die
|
||||||
|
// Einfuege-Reihenfolge bei).
|
||||||
|
while (buckets.size >= MAX_TRACKED_KEYS) {
|
||||||
|
const oldest = buckets.keys().next();
|
||||||
|
if (oldest.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buckets.delete(oldest.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prueft, ob der Schluessel aktuell gesperrt ist (ohne zu zaehlen). */
|
||||||
|
export function checkRateLimit(
|
||||||
|
key: string,
|
||||||
|
max: number,
|
||||||
|
windowMs: number,
|
||||||
|
): RateLimitResult {
|
||||||
|
const now = Date.now();
|
||||||
|
const bucket = buckets.get(key);
|
||||||
|
if (!bucket || now - bucket.firstAt > windowMs) {
|
||||||
|
return { allowed: true, retryAfterSeconds: 0 };
|
||||||
|
}
|
||||||
|
if (bucket.count >= max) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
retryAfterSeconds: Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil((bucket.firstAt + windowMs - now) / 1000),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { allowed: true, retryAfterSeconds: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zaehlt einen Versuch innerhalb des Zeitfensters. */
|
||||||
|
export function recordRateLimitHit(key: string, windowMs: number): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const bucket = buckets.get(key);
|
||||||
|
if (!bucket || now - bucket.firstAt > windowMs) {
|
||||||
|
makeRoom(now, windowMs);
|
||||||
|
buckets.set(key, { count: 1, firstAt: now });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bucket.count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt den Schluessel (z. B. nach erfolgreichem Login). */
|
||||||
|
export function resetRateLimit(key: string): void {
|
||||||
|
buckets.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-IP aus den Standard-Proxy-Headern.
|
||||||
|
*
|
||||||
|
* Hinweis: Ohne vertrauenswuerdigen Proxy ist x-forwarded-for
|
||||||
|
* clientseitig spoofbar. Limits auf Basis echter Identitaeten
|
||||||
|
* (z. B. E-Mail beim Login) sind davon nicht betroffen; IP-Limits
|
||||||
|
* sind eine zusaetzliche, nicht die einzige Schranke.
|
||||||
|
*/
|
||||||
|
export function getClientIp(request: {
|
||||||
|
headers: { get(name: string): string | null };
|
||||||
|
}): string {
|
||||||
|
const forwarded = request.headers.get('x-forwarded-for');
|
||||||
|
const first = forwarded?.split(',')[0]?.trim();
|
||||||
|
if (first) {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
return request.headers.get('x-real-ip')?.trim() || 'unbekannt';
|
||||||
|
}
|
||||||
159
lib/validation.ts
Normal file
159
lib/validation.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Eingabevalidierung mit Zod (plan.md: "Jede Eingabe gilt als unsicher").
|
||||||
|
* Alle API-Routen validieren Request-Bodies ausschliesslich ueber diese Schemas.
|
||||||
|
*/
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/** Sichere E-Mail (Zod prueft Format, wir begrenzen die Laenge). */
|
||||||
|
export const emailSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.min(3, 'E-Mail ist erforderlich.')
|
||||||
|
.max(254, 'E-Mail ist zu lang.')
|
||||||
|
.email('Ungueltige E-Mail-Adresse.');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passwortregeln: mindestens 10 Zeichen, Gross-/Kleinbuchstabe und Ziffer.
|
||||||
|
* Maximale Laenge begrenzen (DoS-Schutz bei bcrypt).
|
||||||
|
*/
|
||||||
|
export const passwordSchema = z
|
||||||
|
.string()
|
||||||
|
.min(10, 'Das Passwort muss mindestens 10 Zeichen lang sein.')
|
||||||
|
.max(72, 'Das Passwort darf hoechstens 72 Zeichen lang sein.')
|
||||||
|
.regex(/[a-z]/, 'Das Passwort muss einen Kleinbuchstaben enthalten.')
|
||||||
|
.regex(/[A-Z]/, 'Das Passwort muss einen Grossbuchstaben enthalten.')
|
||||||
|
.regex(/[0-9]/, 'Das Passwort muss eine Ziffer enthalten.');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Username: 2-32 Zeichen, Buchstaben, Ziffern, Unterstrich, Bindestrich,
|
||||||
|
* Punkt. Keine Leerzeichen oder Sonderzeichen (verhindert Spoofing und
|
||||||
|
* Injection in der Anzeige). Case-insensitive Eindeutigkeit wird in der
|
||||||
|
* DB sichergestellt; hier wird nur das Format geprueft.
|
||||||
|
*/
|
||||||
|
export const usernameSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(2, 'Der Benutzername muss mindestens 2 Zeichen lang sein.')
|
||||||
|
.max(32, 'Der Benutzername darf hoechstens 32 Zeichen lang sein.')
|
||||||
|
.regex(
|
||||||
|
/^[a-zA-Z0-9_.-]+$/,
|
||||||
|
'Der Benutzername darf nur Buchstaben, Ziffern, Unterstrich, Bindestrich und Punkt enthalten.',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const registerSchema = z
|
||||||
|
.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
username: usernameSchema.optional(),
|
||||||
|
inviteToken: z.string().trim().min(10).max(200).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const loginSchema = z
|
||||||
|
.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: z.string().min(1, 'Passwort ist erforderlich.').max(72),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const createCalendarSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().trim().min(1, 'Titel ist erforderlich.').max(120),
|
||||||
|
description: z.string().trim().max(2000).optional().nullable(),
|
||||||
|
timezone: z.string().trim().min(1).max(64),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const updateCalendarSchema = createCalendarSchema
|
||||||
|
.partial()
|
||||||
|
.extend({
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
/** Maximale Reservierungsdauer in Minuten; 0 = unbegrenzt. */
|
||||||
|
maxReservationMinutes: z
|
||||||
|
.number()
|
||||||
|
.int('Die maximale Dauer muss ein Vielfaches von 15 Minuten sein.')
|
||||||
|
.min(0, 'Die maximale Dauer kann nicht negativ sein.')
|
||||||
|
.max(72 * 60, 'Die maximale Dauer betraegt hoechstens 72 Stunden.')
|
||||||
|
.multipleOf(15, 'Die maximale Dauer muss ein Vielfaches von 15 Minuten sein.')
|
||||||
|
.optional(),
|
||||||
|
/** Duerven Nicht-Admins die Titel fremder Reservierungen sehen? */
|
||||||
|
showTitleToMembers: z.boolean().optional(),
|
||||||
|
/** Duerven Nicht-Admins die Notizen fremder Reservierungen sehen? */
|
||||||
|
showNotesToMembers: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
/** Dauer in Minuten: 15 Minuten bis 24 Stunden, in 15-Minuten-Schritten. */
|
||||||
|
export const durationSchema = z
|
||||||
|
.number()
|
||||||
|
.int('Die Dauer muss ein Vielfaches von 15 Minuten sein.')
|
||||||
|
.min(15, 'Die Mindestdauer betraegt 15 Minuten.')
|
||||||
|
.max(24 * 60, 'Die Maximaldauer betraegt 24 Stunden.')
|
||||||
|
.multipleOf(15, 'Die Dauer muss ein Vielfaches von 15 Minuten sein.');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reservierung anlegen: Start- UND Endzeit werden angegeben. Die
|
||||||
|
* Dauer ergibt sich aus der Differenz; die maximale Dauer wird
|
||||||
|
* serverseitig gegen die Kalender-Einstellung geprueft.
|
||||||
|
*/
|
||||||
|
export const createReservationSchema = z
|
||||||
|
.object({
|
||||||
|
calendarId: z.string().trim().min(1),
|
||||||
|
startAt: z.coerce.date(),
|
||||||
|
endAt: z.coerce.date(),
|
||||||
|
title: z.string().trim().min(1, 'Titel ist erforderlich.').max(120),
|
||||||
|
notes: z.string().trim().max(2000).optional().nullable(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const updateReservationSchema = z
|
||||||
|
.object({
|
||||||
|
startAt: z.coerce.date().optional(),
|
||||||
|
endAt: z.coerce.date().optional(),
|
||||||
|
title: z.string().trim().min(1).max(120).optional(),
|
||||||
|
notes: z.string().trim().max(2000).optional().nullable(),
|
||||||
|
status: z.enum(['CONFIRMED', 'CANCELLED']).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const createInviteSchema = z
|
||||||
|
.object({
|
||||||
|
calendarId: z.string().trim().min(1),
|
||||||
|
expiresInDays: z.number().int().min(1).max(90).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mitglied per E-Mail zu einem Kalender hinzufuegen (DB-basiertes
|
||||||
|
* Einladungssystem, ersetzt den Token-Link-Flow in der Verwaltung).
|
||||||
|
*/
|
||||||
|
export const addMemberSchema = z
|
||||||
|
.object({
|
||||||
|
email: emailSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
/** Rollenaenderung (Userverwaltung): nur die beiden bekannten Rollen. */
|
||||||
|
export const updateRoleSchema = z
|
||||||
|
.object({
|
||||||
|
role: z.enum(['ADMIN', 'USER'], {
|
||||||
|
errorMap: () => ({ message: 'Rolle muss ADMIN oder USER sein.' }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
/** Berechnet die Endzeit aus Startzeit und Dauer in Minuten. */
|
||||||
|
export function computeEndAt(startAt: Date, durationMinutes: number): Date {
|
||||||
|
return new Date(startAt.getTime() + durationMinutes * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Username im Profil aendern. Der Wert ist optional (null = loeschen),
|
||||||
|
* aber wenn er gesetzt ist, muss er dem Format entsprechen.
|
||||||
|
*/
|
||||||
|
export const updateUsernameSchema = z
|
||||||
|
.object({
|
||||||
|
username: usernameSchema.nullable(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
77
middleware.ts
Normal file
77
middleware.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Middleware: schuetzt alle privaten Bereiche (plan.md Abschnitt 13).
|
||||||
|
*
|
||||||
|
* Nicht angemeldete Benutzer werden zur Anmeldung geleitet, bevor
|
||||||
|
* ueberhaupt serverseitige Daten geladen werden. Die eigentliche
|
||||||
|
* Berechtigungspruefung bleibt zusaetzlich in den Routen/Seiten
|
||||||
|
* (Defense in Depth) - die Middleware ist nur die erste Schranke.
|
||||||
|
*/
|
||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME = 'calendar_session';
|
||||||
|
|
||||||
|
/** Oeffentliche Pfade, die ohne Session erreichbar sind. */
|
||||||
|
const PUBLIC_PATHS = new Set([
|
||||||
|
'/login',
|
||||||
|
'/register',
|
||||||
|
'/forgot-password',
|
||||||
|
// SSO-Einstieg der MultiToolApp-Plattform: Authentifizierung erfolgt
|
||||||
|
// ueber das Hub-Token im Query-Parameter, nicht ueber die Session.
|
||||||
|
'/auth/hub',
|
||||||
|
// Modul-Contract: Manifest muss ohne Session abrufbar sein.
|
||||||
|
'/api/manifest',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isPublicPath(pathname: string): boolean {
|
||||||
|
if (PUBLIC_PATHS.has(pathname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Invite-Annahme ist oeffentlich (plan.md Abschnitt 3).
|
||||||
|
if (pathname.startsWith('/invite/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Statische Assets und API-Auth-Routen.
|
||||||
|
if (pathname.startsWith('/_next') || pathname.startsWith('/api/auth')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Modul-Contract: .well-known-Pfad (wird per Rewrite auf /api/manifest gemappt).
|
||||||
|
if (pathname.startsWith('/.well-known/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (isPublicPath(pathname)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||||
|
if (!token) {
|
||||||
|
const loginUrl = new URL('/login', request.url);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secret = new TextEncoder().encode(process.env.SESSION_SECRET ?? '');
|
||||||
|
if (secret.length === 0) {
|
||||||
|
throw new Error('SESSION_SECRET fehlt.');
|
||||||
|
}
|
||||||
|
await jwtVerify(token, secret);
|
||||||
|
return NextResponse.next();
|
||||||
|
} catch {
|
||||||
|
// Ungueltige/abgelaufene Session -> Cookie loeschen und zur Anmeldung.
|
||||||
|
const loginUrl = new URL('/login', request.url);
|
||||||
|
const response = NextResponse.redirect(loginUrl);
|
||||||
|
response.cookies.delete(SESSION_COOKIE_NAME);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Alles ausser statischen Dateien.
|
||||||
|
matcher: ['/((?!favicon.ico|.*\\.(?:png|jpg|jpeg|svg|ico|webp|txt)).*)'],
|
||||||
|
};
|
||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
73
next.config.mjs
Normal file
73
next.config.mjs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
|
// MultiToolApp-Plattform (module-plan.md):
|
||||||
|
// HUB_URL – Basis-URL des Hubs (JWKS-Abruf für Token-Validierung)
|
||||||
|
// HUB_BASE_PATH – Pfad, unter dem der Hub diese App routet (z. B. /apps/kalender).
|
||||||
|
// Setzt Next.js basePath → alle Routen hängen darunter.
|
||||||
|
// Ohne HUB_BASE_PATH läuft die App standalone (basePath: '').
|
||||||
|
const hubUrl = (process.env.HUB_URL ?? '').replace(/\/$/, '');
|
||||||
|
const hubBasePath = (process.env.HUB_BASE_PATH ?? '').replace(/\/$/, '');
|
||||||
|
|
||||||
|
const nextConfig = {
|
||||||
|
// Standalone-Ausgabe für einen schlanken Docker-Container
|
||||||
|
output: 'standalone',
|
||||||
|
reactStrictMode: true,
|
||||||
|
// Proxy-Modus: App läuft unter /apps/<slug> innerhalb der Hub-Domain.
|
||||||
|
// basePath '' (falsy) = Standalone-Betrieb ohne Pfad-Präfix.
|
||||||
|
...(hubBasePath ? { basePath: hubBasePath } : {}),
|
||||||
|
// Manifest-Route: /.well-known/app-manifest → /api/manifest
|
||||||
|
// (Next.js App Router routed keine Dot-Ordner; mit beforeFiles-Phase
|
||||||
|
// fangen wir den Pfad ab, bevor basePath zuschlägt.)
|
||||||
|
async rewrites() {
|
||||||
|
return {
|
||||||
|
beforeFiles: [
|
||||||
|
{
|
||||||
|
source: '/.well-known/app-manifest',
|
||||||
|
destination: '/api/manifest',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// Security-Header (Next.js setzt keine davon by default):
|
||||||
|
// - X-Content-Type-Options: verhindert MIME-Sniffing
|
||||||
|
// - Referrer-Policy: keine URLs/Token an Dritte leaken
|
||||||
|
// - Permissions-Policy: Browser-Features, die die App nicht nutzt
|
||||||
|
// - CSP: Skripte nur 'self' + Inline (Next.js Hydration-Snippet);
|
||||||
|
// style-src 'unsafe-inline' ist fuer Tailwind noetig.
|
||||||
|
// - frame-ancestors: nur der Hub darf einbetten (statt X-Frame-Options,
|
||||||
|
// das Subdomain-Embedding blockieren wuerde – platform-plan.md §4).
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/:path*',
|
||||||
|
headers: [
|
||||||
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||||
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
|
{
|
||||||
|
key: 'Permissions-Policy',
|
||||||
|
value: 'camera=(), microphone=(), geolocation=()',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Content-Security-Policy',
|
||||||
|
value: [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline'",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"font-src 'self'",
|
||||||
|
// JWKS-Abruf vom Hub (Token-Validierung in /auth/hub)
|
||||||
|
hubUrl ? `connect-src 'self' ${hubUrl}` : "connect-src 'self'",
|
||||||
|
// Proxy-Modus: kein Framing mehr nötig – aber der Hub darf
|
||||||
|
// weiterhin einbetten (Rückwärtskompatibilität).
|
||||||
|
hubUrl ? `frame-ancestors ${hubUrl}` : "frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
].join('; '),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
3140
package-lock.json
generated
Normal file
3140
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "calendar-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Einfaches Kalender- und Reservierungstool mit Invite-System",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "prisma generate && next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"seed:admin": "node prisma/seed-admin.cjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.20.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"jose": "^5.9.3",
|
||||||
|
"next": "^14.2.13",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^20.16.10",
|
||||||
|
"@types/react": "^18.3.10",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"prisma": "^5.20.0",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vitest": "^2.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
860
plan.md
Normal file
860
plan.md
Normal file
@@ -0,0 +1,860 @@
|
|||||||
|
## 1. Produktidee
|
||||||
|
|
||||||
|
Ein **einfaches Kalender- und Reservierungstool**, bei dem ein Admin Kalender anlegt und Personen gezielt Zugang zu einem bestimmten Kalender gibt.
|
||||||
|
|
||||||
|
Der wichtigste Grundsatz sollte sein:
|
||||||
|
|
||||||
|
> **Ein User gehört genau einem Kalender an und sieht ausschließlich diesen Kalender.**
|
||||||
|
|
||||||
|
Ein Admin kann dagegen mehrere Kalender besitzen und verwalten.
|
||||||
|
|
||||||
|
### Beispiel
|
||||||
|
|
||||||
|
Ein Admin betreibt drei Kalender:
|
||||||
|
|
||||||
|
- „Fußballplatz“
|
||||||
|
- „Besprechungsraum“
|
||||||
|
- „Tennisplatz“
|
||||||
|
|
||||||
|
Er erstellt für „Fußballplatz“ einen Invite-Link und verschickt ihn an die entsprechenden Personen.
|
||||||
|
|
||||||
|
Ein eingeladener User sieht nach der Anmeldung ausschließlich:
|
||||||
|
|
||||||
|
**Fußballplatz**
|
||||||
|
|
||||||
|
und kann dort freie Zeiten ansehen und Reservierungen erstellen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Rollen und Rechte
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
|
||||||
|
Der Admin kann:
|
||||||
|
|
||||||
|
- sich registrieren/anmelden
|
||||||
|
- Kalender erstellen
|
||||||
|
- Kalender bearbeiten
|
||||||
|
- Kalender löschen/deaktivieren
|
||||||
|
- Kalender-Titel festlegen
|
||||||
|
- Beschreibung festlegen
|
||||||
|
- Zeitzone festlegen
|
||||||
|
- Invite-Links erzeugen
|
||||||
|
- Benutzer einem Kalender zuordnen
|
||||||
|
- alle Reservierungen seines Kalenders sehen
|
||||||
|
- Reservierungen verwalten/löschen
|
||||||
|
- optional Reservierungen für andere Benutzer erstellen
|
||||||
|
|
||||||
|
Ein Admin sollte mehrere Kalender besitzen können.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User
|
||||||
|
|
||||||
|
Ein User kann:
|
||||||
|
|
||||||
|
- sich per E-Mail und Passwort anmelden
|
||||||
|
- seinen zugewiesenen Kalender öffnen
|
||||||
|
- freie Zeiten sehen
|
||||||
|
- bestehende Reservierungen sehen
|
||||||
|
- eigene Reservierungen erstellen
|
||||||
|
- Dauer einer Reservierung festlegen
|
||||||
|
- eigene Reservierungen ändern
|
||||||
|
- eigene Reservierungen stornieren
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
**Ein User darf niemals Daten eines anderen Kalenders über die URL oder API abrufen können.**
|
||||||
|
|
||||||
|
Also nicht nur im Frontend verstecken, sondern auch serverseitig erzwingen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Invite-System
|
||||||
|
|
||||||
|
Das würde ich etwas stärker ausbauen als in deinem ursprünglichen Konzept.
|
||||||
|
|
||||||
|
Ein Admin klickt auf:
|
||||||
|
|
||||||
|
**„Benutzer einladen“**
|
||||||
|
|
||||||
|
und bekommt einen Link wie:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://deine-app.de/invite/a8f73kd92...
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Link enthält einen zufälligen, nicht erratbaren Token.
|
||||||
|
|
||||||
|
Der Empfänger öffnet den Link und sieht:
|
||||||
|
|
||||||
|
> Du wurdest zum Kalender „Besprechungsraum“ eingeladen.
|
||||||
|
|
||||||
|
Danach:
|
||||||
|
|
||||||
|
**E-Mail + Passwort erstellen**
|
||||||
|
|
||||||
|
und der Benutzer wird automatisch diesem Kalender zugeordnet.
|
||||||
|
|
||||||
|
### Wichtig
|
||||||
|
|
||||||
|
Ein Invite sollte:
|
||||||
|
|
||||||
|
- einen zufälligen Token besitzen
|
||||||
|
- optional ein Ablaufdatum haben
|
||||||
|
- optional nur einmal verwendbar sein
|
||||||
|
- deaktivierbar sein
|
||||||
|
|
||||||
|
Noch besser:
|
||||||
|
|
||||||
|
Der Admin kann später sehen:
|
||||||
|
|
||||||
|
```
|
||||||
|
Einladungen
|
||||||
|
────────────────────────────
|
||||||
|
Aktiv 3
|
||||||
|
Abgelaufen 1
|
||||||
|
Verwendet 5
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Kalender
|
||||||
|
|
||||||
|
Jeder Kalender sollte mehr Informationen als nur einen Titel besitzen.
|
||||||
|
|
||||||
|
Zum Beispiel:
|
||||||
|
|
||||||
|
```
|
||||||
|
Calendar
|
||||||
|
-------------------------
|
||||||
|
Titel
|
||||||
|
Beschreibung
|
||||||
|
Zeitzone
|
||||||
|
Erstellt am
|
||||||
|
Besitzer
|
||||||
|
Status
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```
|
||||||
|
Besprechungsraum 1
|
||||||
|
Raum für interne Meetings
|
||||||
|
Europe/Berlin
|
||||||
|
Aktiv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sinnvolle spätere Einstellungen
|
||||||
|
|
||||||
|
- Öffnungszeiten
|
||||||
|
- erlaubte Reservierungsdauer
|
||||||
|
- maximale Reservierungsdauer
|
||||||
|
- minimale Vorlaufzeit
|
||||||
|
- maximale Vorausbuchung
|
||||||
|
- Puffer zwischen Reservierungen
|
||||||
|
- Wochenenden erlauben/deaktivieren
|
||||||
|
|
||||||
|
Das würde ich aber teilweise erst nach dem MVP bauen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Reservierungen
|
||||||
|
|
||||||
|
Eine Reservierung sollte mindestens folgende Daten haben:
|
||||||
|
|
||||||
|
```
|
||||||
|
Reservierung
|
||||||
|
-------------------------
|
||||||
|
Kalender
|
||||||
|
Benutzer
|
||||||
|
Startzeit
|
||||||
|
Endzeit
|
||||||
|
Dauer
|
||||||
|
Titel / Betreff
|
||||||
|
Notiz
|
||||||
|
Status
|
||||||
|
Erstellt am
|
||||||
|
Geändert am
|
||||||
|
```
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```
|
||||||
|
Besprechungsraum
|
||||||
|
|
||||||
|
10:00 ─────────────────
|
||||||
|
Max Mustermann
|
||||||
|
60 Minuten
|
||||||
|
Projektmeeting
|
||||||
|
11:00 ─────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dauer
|
||||||
|
|
||||||
|
Der Benutzer soll nicht nur eine Startzeit wählen, sondern auch eine Dauer:
|
||||||
|
|
||||||
|
```
|
||||||
|
Start: 14:00
|
||||||
|
Dauer: 90 Minuten
|
||||||
|
```
|
||||||
|
|
||||||
|
Das System berechnet:
|
||||||
|
|
||||||
|
```
|
||||||
|
Ende: 15:30
|
||||||
|
```
|
||||||
|
|
||||||
|
Intern würde ich **Startzeit + Endzeit** speichern und die Dauer daraus berechnen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Ganz wichtig: Doppelbuchungen verhindern
|
||||||
|
|
||||||
|
Das ist eine der wichtigsten Funktionen der gesamten Anwendung.
|
||||||
|
|
||||||
|
Wenn bereits existiert:
|
||||||
|
|
||||||
|
```
|
||||||
|
14:00 - 15:00
|
||||||
|
```
|
||||||
|
|
||||||
|
darf niemand buchen:
|
||||||
|
|
||||||
|
```
|
||||||
|
14:30 - 15:30
|
||||||
|
```
|
||||||
|
|
||||||
|
Ebenso nicht:
|
||||||
|
|
||||||
|
```
|
||||||
|
14:00 - 14:30
|
||||||
|
```
|
||||||
|
|
||||||
|
oder:
|
||||||
|
|
||||||
|
```
|
||||||
|
13:00 - 14:01
|
||||||
|
```
|
||||||
|
|
||||||
|
Das muss **serverseitig und in der Datenbanklogik** geprüft werden.
|
||||||
|
|
||||||
|
Nicht nur:
|
||||||
|
|
||||||
|
> „Button deaktivieren, wenn es im Frontend belegt aussieht.“
|
||||||
|
|
||||||
|
Denn zwei Benutzer könnten gleichzeitig buchen.
|
||||||
|
|
||||||
|
Der Server muss die Reservierung atomar validieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Kalenderansicht
|
||||||
|
|
||||||
|
Für das MVP würde ich drei Ansichten anbieten:
|
||||||
|
|
||||||
|
### Monat
|
||||||
|
|
||||||
|
Gut für den Überblick.
|
||||||
|
|
||||||
|
### Woche
|
||||||
|
|
||||||
|
Wahrscheinlich die wichtigste Ansicht für Reservierungen.
|
||||||
|
|
||||||
|
### Tag
|
||||||
|
|
||||||
|
Sehr praktisch, wenn viele Buchungen vorhanden sind.
|
||||||
|
|
||||||
|
Optisch beispielsweise:
|
||||||
|
|
||||||
|
```
|
||||||
|
Montag 18. September
|
||||||
|
|
||||||
|
08:00
|
||||||
|
09:00
|
||||||
|
10:00 █████████████
|
||||||
|
11:00 █████████████
|
||||||
|
12:00
|
||||||
|
13:00 ███████
|
||||||
|
14:00 ███████
|
||||||
|
15:00
|
||||||
|
```
|
||||||
|
|
||||||
|
Reservierungen sollten anklickbar sein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Sichtbarkeit
|
||||||
|
|
||||||
|
Hier würde ich dein ursprüngliches Konzept etwas anders definieren.
|
||||||
|
|
||||||
|
Ich würde **nicht** sagen:
|
||||||
|
|
||||||
|
> „Öffentlicher Kalender = jeder im Internet kann ihn sehen.“
|
||||||
|
|
||||||
|
Das kann später Datenschutzprobleme verursachen.
|
||||||
|
|
||||||
|
Besser:
|
||||||
|
|
||||||
|
### Standard: Invite-only
|
||||||
|
|
||||||
|
Ein Kalender ist nur für Benutzer sichtbar, die ihm zugeordnet sind.
|
||||||
|
|
||||||
|
Der Invite-Link dient als Zugang.
|
||||||
|
|
||||||
|
Später kannst du einen zweiten Modus einbauen:
|
||||||
|
|
||||||
|
### Öffentlich
|
||||||
|
|
||||||
|
Der Kalender kann ohne Anmeldung angesehen werden.
|
||||||
|
|
||||||
|
Zum Beispiel:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://deine-app.de/c/public/abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
Aber:
|
||||||
|
|
||||||
|
**Reservierungen erfordern weiterhin Anmeldung.**
|
||||||
|
|
||||||
|
Damit hast du beide Möglichkeiten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. Datenbank
|
||||||
|
|
||||||
|
Für das Projekt würde ich PostgreSQL verwenden.
|
||||||
|
|
||||||
|
Eine mögliche Struktur:
|
||||||
|
|
||||||
|
```
|
||||||
|
users
|
||||||
|
----------------
|
||||||
|
id
|
||||||
|
email
|
||||||
|
password_hash
|
||||||
|
role
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
calendars
|
||||||
|
----------------
|
||||||
|
id
|
||||||
|
owner_id
|
||||||
|
title
|
||||||
|
description
|
||||||
|
timezone
|
||||||
|
is_active
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
calendar_members
|
||||||
|
----------------
|
||||||
|
id
|
||||||
|
calendar_id
|
||||||
|
user_id
|
||||||
|
created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit kannst du sauber abbilden:
|
||||||
|
|
||||||
|
```
|
||||||
|
Admin
|
||||||
|
│
|
||||||
|
├── Kalender A
|
||||||
|
│ ├── User 1
|
||||||
|
│ ├── User 2
|
||||||
|
│ └── User 3
|
||||||
|
│
|
||||||
|
└── Kalender B
|
||||||
|
├── User 4
|
||||||
|
└── User 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Reservierungen:
|
||||||
|
|
||||||
|
```
|
||||||
|
reservations
|
||||||
|
----------------
|
||||||
|
id
|
||||||
|
calendar_id
|
||||||
|
user_id
|
||||||
|
title
|
||||||
|
start_at
|
||||||
|
end_at
|
||||||
|
status
|
||||||
|
notes
|
||||||
|
created_at
|
||||||
|
updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
Invites:
|
||||||
|
|
||||||
|
```
|
||||||
|
invites
|
||||||
|
----------------
|
||||||
|
id
|
||||||
|
calendar_id
|
||||||
|
token_hash
|
||||||
|
expires_at
|
||||||
|
used_at
|
||||||
|
created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
Ich würde den Invite-Token **nicht im Klartext** speichern, sondern nur seinen Hash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Rollen würde ich technisch einfach halten
|
||||||
|
|
||||||
|
Zum Start:
|
||||||
|
|
||||||
|
```
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
```
|
||||||
|
|
||||||
|
Nicht sofort ein kompliziertes Berechtigungssystem bauen.
|
||||||
|
|
||||||
|
Später könntest du erweitern:
|
||||||
|
|
||||||
|
```
|
||||||
|
SUPER_ADMIN
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
VIEWER
|
||||||
|
```
|
||||||
|
|
||||||
|
Aber für Version 1 reichen zwei Rollen völlig.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Authentifizierung
|
||||||
|
|
||||||
|
Login:
|
||||||
|
|
||||||
|
```
|
||||||
|
E-Mail
|
||||||
|
Passwort
|
||||||
|
[Anmelden]
|
||||||
|
```
|
||||||
|
|
||||||
|
Registrierung:
|
||||||
|
|
||||||
|
```
|
||||||
|
E-Mail
|
||||||
|
Passwort
|
||||||
|
Passwort wiederholen
|
||||||
|
```
|
||||||
|
|
||||||
|
Zusätzlich:
|
||||||
|
|
||||||
|
- Passwort-Hashing
|
||||||
|
- Passwort vergessen
|
||||||
|
- Session-Management
|
||||||
|
- E-Mail-Verifizierung
|
||||||
|
- Rate-Limiting beim Login
|
||||||
|
- sichere Cookies
|
||||||
|
- CSRF-Schutz, falls dein Auth-Setup das erfordert
|
||||||
|
|
||||||
|
Passwörter niemals selbst verschlüsseln oder im Klartext speichern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. Mein technischer Stack
|
||||||
|
|
||||||
|
Für dein Projekt würde ich etwas Modernes und relativ simples nehmen:
|
||||||
|
|
||||||
|
### Frontend + Backend
|
||||||
|
|
||||||
|
**Next.js + TypeScript**
|
||||||
|
**Docker Cotainer**
|
||||||
|
|
||||||
|
Damit kannst du Frontend und Backend in einem Projekt haben.
|
||||||
|
|
||||||
|
### Datenbank
|
||||||
|
|
||||||
|
**PostgreSQL**
|
||||||
|
|
||||||
|
### ORM
|
||||||
|
|
||||||
|
**Prisma**
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
**Tailwind CSS**
|
||||||
|
**ShadCN*
|
||||||
|
|
||||||
|
plus eine Kalender-Komponente wie z. B. FullCalendar.
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
Eine etablierte Auth-Lösung statt eigener Session-Implementierung.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
Zum Beispiel:
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend/API → Next.js Hosting
|
||||||
|
Datenbank → PostgreSQL
|
||||||
|
Domain → deine-domain.de
|
||||||
|
Dockercontainer -> als stack
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit bleibt die Architektur relativ klein:
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Next.js
|
||||||
|
│
|
||||||
|
├── Auth
|
||||||
|
├── API
|
||||||
|
├── UI
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Seiten der Anwendung
|
||||||
|
|
||||||
|
Ich würde ungefähr diese Struktur bauen.
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── login
|
||||||
|
├── register
|
||||||
|
├── forgot-password
|
||||||
|
│
|
||||||
|
├── dashboard
|
||||||
|
│
|
||||||
|
├── calendar/[id]
|
||||||
|
│
|
||||||
|
├── reservation/new
|
||||||
|
├── reservation/[id]
|
||||||
|
│
|
||||||
|
├── invite/[token]
|
||||||
|
│
|
||||||
|
└── admin
|
||||||
|
├── calendars
|
||||||
|
├── calendars/new
|
||||||
|
├── calendars/[id]
|
||||||
|
├── users
|
||||||
|
└── invites
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 14. Dashboard
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
Der User bekommt praktisch nur:
|
||||||
|
|
||||||
|
```
|
||||||
|
Mein Kalender
|
||||||
|
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Besprechungsraum │
|
||||||
|
│ │
|
||||||
|
│ [ Kalender öffnen ] │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Da ein User nur einen Kalender haben darf, brauchst du keine komplizierte Kalenderauswahl.
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
Beim Admin:
|
||||||
|
|
||||||
|
```
|
||||||
|
Dashboard
|
||||||
|
|
||||||
|
Meine Kalender
|
||||||
|
|
||||||
|
[ Besprechungsraum ]
|
||||||
|
[ Fußballplatz ]
|
||||||
|
[ Tennisplatz ]
|
||||||
|
|
||||||
|
+ Kalender erstellen
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. Reservierung erstellen
|
||||||
|
|
||||||
|
Ich würde den Ablauf sehr einfach halten:
|
||||||
|
|
||||||
|
```
|
||||||
|
Neue Reservierung
|
||||||
|
|
||||||
|
Datum
|
||||||
|
[ 18.09.2026 ]
|
||||||
|
|
||||||
|
Startzeit
|
||||||
|
[ 14:00 ]
|
||||||
|
|
||||||
|
Dauer
|
||||||
|
[ 60 Minuten ]
|
||||||
|
|
||||||
|
Titel
|
||||||
|
[ Besprechung ]
|
||||||
|
|
||||||
|
Notiz
|
||||||
|
[ ............. ]
|
||||||
|
|
||||||
|
[ Reservieren ]
|
||||||
|
```
|
||||||
|
|
||||||
|
Nach dem Abschicken prüft der Server:
|
||||||
|
|
||||||
|
```
|
||||||
|
Ist der Kalender gültig?
|
||||||
|
Ist der Benutzer berechtigt?
|
||||||
|
Ist die Zeit verfügbar?
|
||||||
|
Liegt die Zeit innerhalb der erlaubten Buchungszeiten?
|
||||||
|
Ist die Dauer erlaubt?
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann wird gebucht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 16. Zusätzliche Funktion, die ich unbedingt ergänzen würde
|
||||||
|
|
||||||
|
## Verfügbarkeitsregeln
|
||||||
|
|
||||||
|
Sonst kann jemand beispielsweise um 03:00 Uhr nachts einen Raum reservieren.
|
||||||
|
|
||||||
|
Der Admin sollte deshalb irgendwann einstellen können:
|
||||||
|
|
||||||
|
```
|
||||||
|
Buchungszeiten
|
||||||
|
|
||||||
|
Montag 08:00 - 18:00
|
||||||
|
Dienstag 08:00 - 18:00
|
||||||
|
Mittwoch 08:00 - 18:00
|
||||||
|
Donnerstag 08:00 - 18:00
|
||||||
|
Freitag 08:00 - 16:00
|
||||||
|
Samstag geschlossen
|
||||||
|
Sonntag geschlossen
|
||||||
|
```
|
||||||
|
|
||||||
|
Und:
|
||||||
|
|
||||||
|
```
|
||||||
|
Mindestdauer: 30 Minuten
|
||||||
|
Maximaldauer: 4 Stunden
|
||||||
|
Buchbar bis: 30 Tage im Voraus
|
||||||
|
Vorlaufzeit: 1 Stunde
|
||||||
|
```
|
||||||
|
|
||||||
|
Das macht aus einem simplen Kalender tatsächlich ein brauchbares Reservierungssystem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 17. Weitere sinnvolle Funktionen
|
||||||
|
|
||||||
|
Für **Version 1.0** würde ich nur das Nötigste bauen.
|
||||||
|
|
||||||
|
Danach könnten kommen:
|
||||||
|
|
||||||
|
**E-Mail-Benachrichtigungen**
|
||||||
|
|
||||||
|
Bei:
|
||||||
|
|
||||||
|
- neuer Reservierung
|
||||||
|
- Änderung
|
||||||
|
- Stornierung
|
||||||
|
- Einladung
|
||||||
|
|
||||||
|
**Wiederkehrende Reservierungen**
|
||||||
|
|
||||||
|
Zum Beispiel:
|
||||||
|
|
||||||
|
```
|
||||||
|
Jeden Montag
|
||||||
|
18:00 - 19:00
|
||||||
|
```
|
||||||
|
|
||||||
|
**ICS / Kalender-Export**
|
||||||
|
|
||||||
|
Damit Benutzer Reservierungen in Outlook, Apple Calendar oder Google Calendar übernehmen können.
|
||||||
|
|
||||||
|
**Admin-Statistiken**
|
||||||
|
|
||||||
|
Zum Beispiel:
|
||||||
|
|
||||||
|
```
|
||||||
|
Reservierungen diesen Monat: 143
|
||||||
|
Auslastung: 67 %
|
||||||
|
```
|
||||||
|
|
||||||
|
**Audit-Log**
|
||||||
|
|
||||||
|
Damit der Admin nachvollziehen kann:
|
||||||
|
|
||||||
|
```
|
||||||
|
10:31 Max erstellt Reservierung
|
||||||
|
10:42 Max geändert Reservierung
|
||||||
|
11:03 Admin gelöscht Reservierung
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 18. Was ich ausdrücklich nicht in Version 1 bauen würde
|
||||||
|
|
||||||
|
Sonst wird das Projekt unnötig groß.
|
||||||
|
|
||||||
|
Zunächst würde ich weglassen:
|
||||||
|
|
||||||
|
- Chat
|
||||||
|
- Push-Benachrichtigungen
|
||||||
|
- Mobile Apps
|
||||||
|
- komplizierte Rollen
|
||||||
|
- mehrere Kalender pro User
|
||||||
|
- Zahlungen
|
||||||
|
- Abonnements
|
||||||
|
- externe Kalender-Synchronisierung
|
||||||
|
- komplexe Ressourcenverwaltung
|
||||||
|
|
||||||
|
Erst einmal:
|
||||||
|
|
||||||
|
**Login → Kalender → Einladung → Reservierung → Konfliktprüfung**
|
||||||
|
|
||||||
|
Das ist dein Kernprodukt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 19. MVP-Definition
|
||||||
|
|
||||||
|
Ich würde dein erstes Release auf diese Funktionen begrenzen:
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
- Registrierung
|
||||||
|
- Login
|
||||||
|
- Logout
|
||||||
|
- Passwort vergessen
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
- Kalender erstellen
|
||||||
|
- Kalender bearbeiten
|
||||||
|
- Kalender deaktivieren
|
||||||
|
- Invite-Link erstellen
|
||||||
|
- Benutzer sehen
|
||||||
|
- Reservierungen sehen/löschen
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
- genau einem Kalender zugeordnet
|
||||||
|
- Kalender ansehen
|
||||||
|
- Reservierung erstellen
|
||||||
|
- Startzeit wählen
|
||||||
|
- Dauer wählen
|
||||||
|
- eigene Reservierung bearbeiten
|
||||||
|
- eigene Reservierung löschen
|
||||||
|
|
||||||
|
### System
|
||||||
|
|
||||||
|
- Berechtigungsprüfung
|
||||||
|
- Doppelbuchungen verhindern
|
||||||
|
- Zeitzonen sauber behandeln
|
||||||
|
- responsive Oberfläche
|
||||||
|
- sichere Sessions
|
||||||
|
- saubere Fehlerbehandlung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 20. Projektstruktur
|
||||||
|
|
||||||
|
Ich würde ungefähr so starten:
|
||||||
|
|
||||||
|
```
|
||||||
|
calendar-app/
|
||||||
|
│
|
||||||
|
├── app/
|
||||||
|
│ ├── login/
|
||||||
|
│ ├── register/
|
||||||
|
│ ├── dashboard/
|
||||||
|
│ ├── calendar/
|
||||||
|
│ ├── invite/
|
||||||
|
│ └── admin/
|
||||||
|
│
|
||||||
|
├── components/
|
||||||
|
│ ├── calendar/
|
||||||
|
│ ├── reservation/
|
||||||
|
│ ├── forms/
|
||||||
|
│ └── ui/
|
||||||
|
│
|
||||||
|
├── lib/
|
||||||
|
│ ├── auth/
|
||||||
|
│ ├── db/
|
||||||
|
│ ├── permissions/
|
||||||
|
│ ├── reservations/
|
||||||
|
│ └── invites/
|
||||||
|
│
|
||||||
|
├── prisma/
|
||||||
|
│ └── schema.prisma
|
||||||
|
│
|
||||||
|
├── api/
|
||||||
|
│
|
||||||
|
└── tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Besonders wichtig ist eine zentrale Berechtigungsschicht.
|
||||||
|
|
||||||
|
Zum Beispiel konzeptionell:
|
||||||
|
|
||||||
|
```
|
||||||
|
canViewCalendar(user, calendar)
|
||||||
|
canCreateReservation(user, calendar)
|
||||||
|
canDeleteReservation(user, reservation)
|
||||||
|
canManageCalendar(user, calendar)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann verteilst du Berechtigungslogik nicht überall im Code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 21. Eine wichtige Entscheidung für dein Konzept
|
||||||
|
|
||||||
|
Ich würde den Satz
|
||||||
|
|
||||||
|
> „Jeder kann nur seinen einen Kalender sehen“
|
||||||
|
|
||||||
|
technisch als feste Regel definieren:
|
||||||
|
|
||||||
|
```
|
||||||
|
USER → genau 1 Kalender
|
||||||
|
|
||||||
|
ADMIN → 0..n Kalender
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit ist das Datenmodell eindeutig.
|
||||||
|
|
||||||
|
Und bei einem Invite gilt:
|
||||||
|
|
||||||
|
```
|
||||||
|
Invite
|
||||||
|
↓
|
||||||
|
User registriert sich
|
||||||
|
↓
|
||||||
|
User wird Kalender zugeordnet
|
||||||
|
↓
|
||||||
|
User sieht ausschließlich diesen Kalender
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein User kann anschließend **nicht selbstständig einen zweiten Kalender hinzufügen**.
|
||||||
|
|
||||||
|
Das verhindert sehr viel Komplexität.
|
||||||
9
postcss.config.mjs
Normal file
9
postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
100
prisma/migrations/20260918000000_init/migration.sql
Normal file
100
prisma/migrations/20260918000000_init/migration.sql
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
-- Initiale Migration (manuell gepflegt, damit `prisma migrate deploy`
|
||||||
|
-- im Docker-Container ohne Datenbankzugriff zur Build-Zeit funktioniert).
|
||||||
|
-- Erzeugt aus prisma/schema.prisma mit: npx prisma migrate dev --name init
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER');
|
||||||
|
|
||||||
|
CREATE TYPE "ReservationStatus" AS ENUM ('CONFIRMED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password_hash" TEXT NOT NULL,
|
||||||
|
"role" "Role" NOT NULL DEFAULT 'USER',
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "calendars" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"owner_id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"timezone" TEXT NOT NULL DEFAULT 'Europe/Berlin',
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "calendars_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "calendar_members" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"calendar_id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "calendar_members_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "reservations" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"calendar_id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"start_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"end_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"status" "ReservationStatus" NOT NULL DEFAULT 'CONFIRMED',
|
||||||
|
"notes" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "reservations_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "invites" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"calendar_id" TEXT NOT NULL,
|
||||||
|
"token_hash" TEXT NOT NULL,
|
||||||
|
"expires_at" TIMESTAMP(3),
|
||||||
|
"used_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "invites_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "calendar_members_user_id_key" ON "calendar_members"("user_id");
|
||||||
|
|
||||||
|
CREATE INDEX "calendars_owner_id_idx" ON "calendars"("owner_id");
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "calendar_members_calendar_id_user_id_key" ON "calendar_members"("calendar_id", "user_id");
|
||||||
|
|
||||||
|
CREATE INDEX "calendar_members_calendar_id_idx" ON "calendar_members"("calendar_id");
|
||||||
|
|
||||||
|
CREATE INDEX "reservations_calendar_id_start_at_idx" ON "reservations"("calendar_id", "start_at");
|
||||||
|
|
||||||
|
CREATE INDEX "reservations_user_id_idx" ON "reservations"("user_id");
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "invites_token_hash_key" ON "invites"("token_hash");
|
||||||
|
|
||||||
|
CREATE INDEX "invites_calendar_id_idx" ON "invites"("calendar_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "calendars" ADD CONSTRAINT "calendars_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "calendar_members" ADD CONSTRAINT "calendar_members_calendar_id_fkey" FOREIGN KEY ("calendar_id") REFERENCES "calendars"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "calendar_members" ADD CONSTRAINT "calendar_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "reservations" ADD CONSTRAINT "reservations_calendar_id_fkey" FOREIGN KEY ("calendar_id") REFERENCES "calendars"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "reservations" ADD CONSTRAINT "reservations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "invites" ADD CONSTRAINT "invites_calendar_id_fkey" FOREIGN KEY ("calendar_id") REFERENCES "calendars"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Migration: Kalender-Einstellungen fuer Reservierungsregeln.
|
||||||
|
-- max_reservation_minutes: maximale Reservierungsdauer in Minuten
|
||||||
|
-- (15..1440); 0 bedeutet unbegrenzt. Default 240 (4 Stunden).
|
||||||
|
-- show_title_to_members: duerfen Nicht-Admins die Titel fremder
|
||||||
|
-- Reservierungen sehen? Default true (bisheriges Verhalten).
|
||||||
|
-- show_notes_to_members: duerfen Nicht-Admins die Notizen fremder
|
||||||
|
-- Reservierungen sehen? Default false (bisheriges Verhalten).
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "calendars" ADD COLUMN "max_reservation_minutes" INTEGER NOT NULL DEFAULT 240;
|
||||||
|
|
||||||
|
ALTER TABLE "calendars" ADD COLUMN "show_title_to_members" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
ALTER TABLE "calendars" ADD COLUMN "show_notes_to_members" BOOLEAN NOT NULL DEFAULT false;
|
||||||
10
prisma/migrations/20260921000001_user_username/migration.sql
Normal file
10
prisma/migrations/20260921000001_user_username/migration.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Migration: Username-Spalte fuer Benutzer.
|
||||||
|
-- username: frei waehlbarer Anzeigename (optional, eindeutig).
|
||||||
|
-- Im Profil-Badge wird username bevorzugt, sonst email angezeigt.
|
||||||
|
-- Bestehende Benutzer erhalten NULL; sie koennen den Namen im Profil setzen.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "username" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Migration: Case-insensitive Eindeutigkeit fuer Benutzernamen.
|
||||||
|
-- Bisher war nur "Anna" != "anna" geschuetzt (case-sensitiver Unique-
|
||||||
|
-- Index). Beide Varianten konnten gleichzeitig existieren und im
|
||||||
|
-- Profil-Badge/Reservierungslisten als Identitaet verwechselt werden.
|
||||||
|
-- Der neue funktionale Unique-Index auf lower(username) ist strenger
|
||||||
|
-- und deckt den alten Fall mit ab.
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "users_username_key";
|
||||||
|
|
||||||
|
-- CreateIndex (funktional; von Prisma-Introspektion nicht abbildbar)
|
||||||
|
CREATE UNIQUE INDEX "users_username_lower_key" ON "users"(lower("username"));
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Fügt die hub_id-Spalte hinzu: stabile User-ID der MultiToolApp-Plattform.
|
||||||
|
-- null = rein lokaler Account; unique verhindert doppelte Kopplung.
|
||||||
|
|
||||||
|
ALTER TABLE "users" ADD COLUMN "hub_id" TEXT;
|
||||||
|
CREATE UNIQUE INDEX "users_hub_id_key" ON "users"("hub_id");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
132
prisma/schema.prisma
Normal file
132
prisma/schema.prisma
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// Prisma-Schema fuer das Kalender- und Reservierungstool.
|
||||||
|
// Kernregeln aus plan.md:
|
||||||
|
// USER -> genau 1 Kalender (ueber calendar_members, unique auf user_id)
|
||||||
|
// ADMIN -> 0..n Kalender (owner_id)
|
||||||
|
// Invite-Token wird nur gehasht gespeichert.
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
// linux-musl-openssl-3.0.x: Alpine-Container (node:20-alpine) nutzen
|
||||||
|
// OpenSSL 3; ohne dieses Target waehlt Prisma die 1.1-Engine, die
|
||||||
|
// dort zur Laufzeit nicht ladbar ist.
|
||||||
|
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rollen nach plan.md Abschnitt 10: nur ADMIN und USER zum Start.
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status einer Reservierung.
|
||||||
|
enum ReservationStatus {
|
||||||
|
CONFIRMED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
/// Frei waehlbarer Anzeigename; null, wenn noch nicht gesetzt.
|
||||||
|
/// Im Profil-Badge wird username bevorzugt, sonst email angezeigt.
|
||||||
|
username String? @unique
|
||||||
|
passwordHash String @map("password_hash")
|
||||||
|
role Role @default(USER)
|
||||||
|
/// Stabile User-ID der MultiToolApp-Plattform (SSO via Hub-Token).
|
||||||
|
/// null = rein lokaler Account (klassisches Login). Beim ersten
|
||||||
|
/// Hub-Login wird der Account an die Hub-ID gekoppelt (JIT-Provisioning).
|
||||||
|
hubId String? @unique @map("hub_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
/// Kalender, deren Mitglied dieser User ist.
|
||||||
|
/// Durch @unique ist sichergestellt: Ein User gehoert GENAU EINEM Kalender an.
|
||||||
|
memberships CalendarMember[]
|
||||||
|
|
||||||
|
/// Kalender, die dieser User besitzt (nur fuer ADMIN relevant).
|
||||||
|
ownedCalendars Calendar[] @relation("CalendarOwner")
|
||||||
|
|
||||||
|
reservations Reservation[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Calendar {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ownerId String @map("owner_id")
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
timezone String @default("Europe/Berlin")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
/// Maximale Reservierungsdauer in Minuten (15..1440). 0 = unbegrenzt.
|
||||||
|
maxReservationMinutes Int @default(240) @map("max_reservation_minutes")
|
||||||
|
/// Duerven Nicht-Admins die Titel fremder Reservierungen sehen?
|
||||||
|
showTitleToMembers Boolean @default(true) @map("show_title_to_members")
|
||||||
|
/// Duerven Nicht-Admins die Notizen fremder Reservierungen sehen?
|
||||||
|
showNotesToMembers Boolean @default(false) @map("show_notes_to_members")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
owner User @relation("CalendarOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
|
members CalendarMember[]
|
||||||
|
invites Invite[]
|
||||||
|
reservations Reservation[]
|
||||||
|
|
||||||
|
@@index([ownerId])
|
||||||
|
@@map("calendars")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zuordnung User <-> Kalender.
|
||||||
|
/// Das unique auf user_id erzwingt die Kernregel: ein User, genau ein Kalender.
|
||||||
|
model CalendarMember {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
calendarId String @map("calendar_id")
|
||||||
|
userId String @unique @map("user_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
calendar Calendar @relation(fields: [calendarId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([calendarId, userId])
|
||||||
|
@@index([calendarId])
|
||||||
|
@@map("calendar_members")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Reservation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
calendarId String @map("calendar_id")
|
||||||
|
userId String @map("user_id")
|
||||||
|
title String
|
||||||
|
startAt DateTime @map("start_at")
|
||||||
|
endAt DateTime @map("end_at")
|
||||||
|
status ReservationStatus @default(CONFIRMED)
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
calendar Calendar @relation(fields: [calendarId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([calendarId, startAt])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("reservations")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Invite {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
calendarId String @map("calendar_id")
|
||||||
|
tokenHash String @unique @map("token_hash")
|
||||||
|
expiresAt DateTime? @map("expires_at")
|
||||||
|
usedAt DateTime? @map("used_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
calendar Calendar @relation(fields: [calendarId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([calendarId])
|
||||||
|
@@map("invites")
|
||||||
|
}
|
||||||
78
prisma/seed-admin.cjs
Normal file
78
prisma/seed-admin.cjs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Seed-Script: Legt einen Admin-Account an (First-Run-Setup).
|
||||||
|
*
|
||||||
|
* Wird beim Container-Start ausgefuehrt (nach migrate deploy, vor server.js)
|
||||||
|
* und kann auch manuell aufgerufen werden:
|
||||||
|
*
|
||||||
|
* docker compose exec app node prisma/seed-admin.cjs admin@example.com "Passwort10"
|
||||||
|
*
|
||||||
|
* Verhalten (nicht-destruktiv, wie bei Gitea/Vaultwarden):
|
||||||
|
* - Wenn bereits ein Admin existiert: Abbruch mit Hinweis (nichts geloescht).
|
||||||
|
* - Wenn noch kein Admin existiert: Legt genau einen an.
|
||||||
|
* - Bestehende User werden NIEMALS geloescht.
|
||||||
|
* - Wenn SEED_ADMIN_* nicht gesetzt sind: laeuft ohne Nebeneffekte durch.
|
||||||
|
*
|
||||||
|
* Credentials werden aus Env-Variablen oder CLI-Argumenten gelesen
|
||||||
|
* (CLI-Argumente haben Vorrang).
|
||||||
|
*/
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function seedAdmin() {
|
||||||
|
// CLI-Argumente haben Vorrang vor Env-Variablen.
|
||||||
|
const email = (process.argv[2] || process.env.SEED_ADMIN_EMAIL || '').trim();
|
||||||
|
const password = (process.argv[3] || process.env.SEED_ADMIN_PASSWORD || '').trim();
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
console.error(
|
||||||
|
'[seed] Verwendung: node prisma/seed-admin.cjs <email> <passwort>\n' +
|
||||||
|
' oder: SEED_ADMIN_EMAIL=… SEED_ADMIN_PASSWORD=… node prisma/seed-admin.cjs',
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 10) {
|
||||||
|
console.error('[seed] Passwort muss mindestens 10 Zeichen lang sein.');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-Run-Detection: Wenn schon ein Admin existiert, nicht anlegen.
|
||||||
|
const adminCount = await prisma.user.count({ where: { role: 'ADMIN' } });
|
||||||
|
if (adminCount > 0) {
|
||||||
|
console.log('[seed] Es existiert bereits ein Admin-Account. Abbruch (nicht-destruktiv).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefen, ob die E-Mail bereits von einem normalen User belegt ist.
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email: email } });
|
||||||
|
if (existing) {
|
||||||
|
console.error('[seed] Ein User mit dieser E-Mail existiert bereits (als USER).');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: email,
|
||||||
|
passwordHash: passwordHash,
|
||||||
|
role: 'ADMIN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[seed] Admin-Account "' + email + '" erfolgreich angelegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
seedAdmin()
|
||||||
|
.catch(function (error) {
|
||||||
|
console.error('[seed] Fehler:', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async function () {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
5
public/robots.txt
Normal file
5
public/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /dashboard
|
||||||
|
Disallow: /calendar
|
||||||
|
Disallow: /admin
|
||||||
|
Disallow: /api
|
||||||
12
tailwind.config.ts
Normal file
12
tailwind.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
265
tests/core.test.ts
Normal file
265
tests/core.test.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* Unit-Tests fuer die Kernlogik (plan.md Abschnitt 6 und 19):
|
||||||
|
* Kollisionspruefung, Invite-Tokens, Validierung, Berechtigungen.
|
||||||
|
*
|
||||||
|
* Bewusst ohne Datenbank: Die Regeln sind reine Funktionen und
|
||||||
|
* damit ohne PostgreSQL testbar (schnelle Unit-Tests).
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { intervalsOverlap, findConflicts, validateReservationPeriod } from '@/lib/reservations/conflicts';
|
||||||
|
import { generateInviteToken, hashInviteToken, isInviteUsable, defaultInviteExpiry } from '@/lib/invites/tokens';
|
||||||
|
import { createReservationSchema } from '@/lib/validation';
|
||||||
|
import { formatDuration } from '@/lib/reservations/service';
|
||||||
|
import {
|
||||||
|
canViewCalendar,
|
||||||
|
canManageCalendar,
|
||||||
|
canCreateReservation,
|
||||||
|
canModifyReservation,
|
||||||
|
canDeleteReservation,
|
||||||
|
} from '@/lib/permissions/permissions';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Konflikte
|
||||||
|
|
||||||
|
describe('intervalsOverlap', () => {
|
||||||
|
const base = new Date('2026-09-18T12:00:00Z');
|
||||||
|
|
||||||
|
it('erkennnt eine Ueberlappung in der Mitte', () => {
|
||||||
|
expect(
|
||||||
|
intervalsOverlap(
|
||||||
|
new Date('2026-09-18T14:00:00Z'),
|
||||||
|
new Date('2026-09-18T15:00:00Z'),
|
||||||
|
new Date('2026-09-18T14:30:00Z'),
|
||||||
|
new Date('2026-09-18T15:30:00Z'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennnt vollstaendiges Enthaltensein', () => {
|
||||||
|
expect(
|
||||||
|
intervalsOverlap(
|
||||||
|
new Date('2026-09-18T14:00:00Z'),
|
||||||
|
new Date('2026-09-18T15:00:00Z'),
|
||||||
|
new Date('2026-09-18T14:00:00Z'),
|
||||||
|
new Date('2026-09-18T14:30:00Z'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennnt 1-Minuten-Ueberschneidung (13:00-14:01 vs 14:00-15:00)', () => {
|
||||||
|
expect(
|
||||||
|
intervalsOverlap(
|
||||||
|
new Date('2026-09-18T14:00:00Z'),
|
||||||
|
new Date('2026-09-18T15:00:00Z'),
|
||||||
|
new Date('2026-09-18T13:00:00Z'),
|
||||||
|
new Date('2026-09-18T14:01:00Z'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erlaubt direktes Aneinandergrenzen (14:00-15:00 und 15:00-16:00)', () => {
|
||||||
|
expect(
|
||||||
|
intervalsOverlap(
|
||||||
|
new Date('2026-09-18T14:00:00Z'),
|
||||||
|
new Date('2026-09-18T15:00:00Z'),
|
||||||
|
new Date('2026-09-18T15:00:00Z'),
|
||||||
|
new Date('2026-09-18T16:00:00Z'),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findConflicts', () => {
|
||||||
|
const existing = [
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
startAt: new Date('2026-09-18T14:00:00Z'),
|
||||||
|
endAt: new Date('2026-09-18T15:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r2',
|
||||||
|
startAt: new Date('2026-09-18T16:00:00Z'),
|
||||||
|
endAt: new Date('2026-09-18T17:00:00Z'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('findet kollidierende Reservierungen', () => {
|
||||||
|
const conflicts = findConflicts(
|
||||||
|
existing,
|
||||||
|
new Date('2026-09-18T14:30:00Z'),
|
||||||
|
new Date('2026-09-18T15:30:00Z'),
|
||||||
|
);
|
||||||
|
expect(conflicts).toHaveLength(1);
|
||||||
|
expect(conflicts[0]?.id).toBe('r1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert die eigene Reservierung beim Bearbeiten', () => {
|
||||||
|
const conflicts = findConflicts(
|
||||||
|
existing,
|
||||||
|
new Date('2026-09-18T14:30:00Z'),
|
||||||
|
new Date('2026-09-18T15:30:00Z'),
|
||||||
|
'r1',
|
||||||
|
);
|
||||||
|
expect(conflicts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateReservationPeriod', () => {
|
||||||
|
it('akzeptiert einen gueltigen zukuenftigen Zeitraum', () => {
|
||||||
|
const now = new Date('2026-09-18T12:00:00Z');
|
||||||
|
expect(() =>
|
||||||
|
validateReservationPeriod(
|
||||||
|
new Date('2026-09-18T14:00:00Z'),
|
||||||
|
new Date('2026-09-18T15:00:00Z'),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt Start >= Ende ab', () => {
|
||||||
|
expect(() =>
|
||||||
|
validateReservationPeriod(
|
||||||
|
new Date('2026-09-18T15:00:00Z'),
|
||||||
|
new Date('2026-09-18T15:00:00Z'),
|
||||||
|
),
|
||||||
|
).toThrow(/Startzeit muss vor der Endzeit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt Vergangenheit ab', () => {
|
||||||
|
const now = new Date('2026-09-18T12:00:00Z');
|
||||||
|
expect(() =>
|
||||||
|
validateReservationPeriod(
|
||||||
|
new Date('2026-09-18T10:00:00Z'),
|
||||||
|
new Date('2026-09-18T11:00:00Z'),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).toThrow(/Vergangenheit/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- Invites
|
||||||
|
|
||||||
|
describe('Invite-Tokens', () => {
|
||||||
|
it('erzeugt nicht erratbare Tokens', () => {
|
||||||
|
const token = generateInviteToken();
|
||||||
|
expect(token).toHaveLength(43); // 32 Bytes base64url
|
||||||
|
expect(token).not.toContain('=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasht deterministisch und speichert nie den Klartext', () => {
|
||||||
|
const token = generateInviteToken();
|
||||||
|
const hash = hashInviteToken(token);
|
||||||
|
expect(hash).toHaveLength(64);
|
||||||
|
expect(hash).not.toBe(token);
|
||||||
|
expect(hashInviteToken(token)).toBe(hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennt abgelaufene und verwendete Invites', () => {
|
||||||
|
const now = new Date('2026-09-18T12:00:00Z');
|
||||||
|
expect(
|
||||||
|
isInviteUsable({ expiresAt: new Date('2026-09-01T00:00:00Z'), usedAt: null }, now),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isInviteUsable({ expiresAt: null, usedAt: new Date('2026-09-01T00:00:00Z') }, now),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isInviteUsable({ expiresAt: new Date('2026-09-30T00:00:00Z'), usedAt: null }, now),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt die Standard-Ablaufzeit auf 7 Tage', () => {
|
||||||
|
const now = new Date('2026-09-18T12:00:00Z');
|
||||||
|
const expiry = defaultInviteExpiry(now);
|
||||||
|
expect(expiry.getTime() - now.getTime()).toBe(7 * 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Validierung
|
||||||
|
|
||||||
|
describe('createReservationSchema', () => {
|
||||||
|
it('akzeptiert gueltige Eingaben (Start- und Endzeit)', () => {
|
||||||
|
const parsed = createReservationSchema.parse({
|
||||||
|
calendarId: 'c1',
|
||||||
|
startAt: '2026-09-18T14:00:00Z',
|
||||||
|
endAt: '2026-09-18T15:00:00Z',
|
||||||
|
title: 'Projektmeeting',
|
||||||
|
});
|
||||||
|
expect(parsed.endAt).toEqual(new Date('2026-09-18T15:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt fehlende Endzeit ab', () => {
|
||||||
|
expect(() =>
|
||||||
|
createReservationSchema.parse({
|
||||||
|
calendarId: 'c1',
|
||||||
|
startAt: '2026-09-18T14:00:00Z',
|
||||||
|
title: 'X',
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDuration', () => {
|
||||||
|
it('formatiert Minuten unter 60 als Minuten', () => {
|
||||||
|
expect(formatDuration(45)).toBe('45 Minuten');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formatiert ganze Stunden', () => {
|
||||||
|
expect(formatDuration(240)).toBe('4 Stunden');
|
||||||
|
expect(formatDuration(60)).toBe('1 Stunde');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formatiert halbe Stunden mit Komma', () => {
|
||||||
|
expect(formatDuration(90)).toBe('1,5 Stunden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Berechtigungen
|
||||||
|
|
||||||
|
describe('Berechtigungsschicht (plan.md Abschnitt 2 und 20)', () => {
|
||||||
|
const admin = { id: 'admin-1', email: 'admin@test.local', username: null, role: 'ADMIN' as const };
|
||||||
|
const user = { id: 'user-1', email: 'user@test.local', username: null, role: 'USER' as const };
|
||||||
|
const ownCalendar = { id: 'cal-1', ownerId: 'admin-1', isActive: true };
|
||||||
|
const foreignCalendar = { id: 'cal-2', ownerId: 'admin-2', isActive: true };
|
||||||
|
const reservation = { id: 'res-1', calendarId: 'cal-1', userId: 'user-1' };
|
||||||
|
|
||||||
|
it('Admin sieht alle Kalender, User nur seinen einen', () => {
|
||||||
|
expect(canViewCalendar(admin, ownCalendar, false)).toBe(true);
|
||||||
|
expect(canViewCalendar(admin, foreignCalendar, false)).toBe(true);
|
||||||
|
expect(canViewCalendar(user, ownCalendar, true)).toBe(true);
|
||||||
|
expect(canViewCalendar(user, ownCalendar, false)).toBe(false);
|
||||||
|
expect(canViewCalendar(user, foreignCalendar, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Admins verwalten alle Kalender, User keinen', () => {
|
||||||
|
expect(canManageCalendar(admin, ownCalendar)).toBe(true);
|
||||||
|
expect(canManageCalendar(admin, foreignCalendar)).toBe(true);
|
||||||
|
expect(canManageCalendar(user, ownCalendar)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('User kann in seinem Kalender reservieren, Admin in allen aktiven', () => {
|
||||||
|
expect(canCreateReservation(user, ownCalendar, true)).toBe(true);
|
||||||
|
expect(canCreateReservation(user, foreignCalendar, false)).toBe(false);
|
||||||
|
expect(canCreateReservation(admin, ownCalendar, false)).toBe(true);
|
||||||
|
expect(canCreateReservation(admin, foreignCalendar, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deaktivierte Kalender erlauben keine neuen Reservierungen', () => {
|
||||||
|
const inactive = { ...ownCalendar, isActive: false };
|
||||||
|
expect(canCreateReservation(user, inactive, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('User aendert nur eigene Reservierungen', () => {
|
||||||
|
const foreignReservation = { ...reservation, userId: 'user-2' };
|
||||||
|
expect(canModifyReservation(user, reservation, ownCalendar, true)).toBe(true);
|
||||||
|
expect(canModifyReservation(user, foreignReservation, ownCalendar, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Admin verwaltet alle Reservierungen in allen Kalendern', () => {
|
||||||
|
expect(canModifyReservation(admin, reservation, ownCalendar, false)).toBe(true);
|
||||||
|
expect(canModifyReservation(admin, reservation, foreignCalendar, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NUR Admins duerfen Reservierungen loeschen', () => {
|
||||||
|
expect(canDeleteReservation(admin, reservation, ownCalendar)).toBe(true);
|
||||||
|
expect(canDeleteReservation(user, reservation, ownCalendar)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
14
vitest.config.ts
Normal file
14
vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user