diff --git a/.dockerignore b/.dockerignore index 5086180..aa486b6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,5 @@ node_modules .env .env*.local *.md -tests \ No newline at end of file +tests +.env* diff --git a/.env.example b/.env.example index fcff9f9..ac24a14 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,19 @@ -# PostgreSQL-Verbindung (in docker-compose.yml automatisch korrekt gesetzt) -DATABASE_URL="postgresql://calendar_app:calendar_app@localhost:5432/calendar?schema=public" +# Nur für den eigenständigen Betrieb außerhalb von MPM. +# In MPM werden dieselben Werte sicher über den Konfigurationsdialog gesetzt. -# 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). +# Zufällige alphanumerische Passwörter verwenden (mindestens 32 Zeichen). +CALENDAR_ADMIN_PASSWORD="change-this-database-admin-password" +CALENDAR_DB_PASSWORD="change-this-calendar-database-password" + +# Mindestens 32 Zeichen, z. B. mit `openssl rand -base64 48` erzeugen. SESSION_SECRET="change-me-change-me-change-me-change-me" -# Basis-URL der Anwendung (fuer Invite-Links) -APP_URL="http://localhost:3000" +# Nur für lokale Entwicklung ohne Compose; Passwort URL-kodieren. +# DATABASE_URL="postgresql://calendar_app:PASSWORT@127.0.0.1:5432/calendar?schema=public" -# "false" = Registrierung nur ueber Invite-Links moeglich +# Standalone-Optionen +APP_PORT="3000" 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. +# HUB_BASE_PATH="/kalendartool" # SEED_ADMIN_EMAIL="admin@example.com" -# SEED_ADMIN_PASSWORD="SicheresPasswort10" \ No newline at end of file +# SEED_ADMIN_PASSWORD="change-this-admin-password" diff --git a/Dockerfile b/Dockerfile index 9e47140..5b42f6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,11 @@ # zurueck und der Client kann nicht initialisiert werden. FROM node:20-bookworm-slim AS builder WORKDIR /app +ARG HUB_BASE_PATH="" +ARG MPM_AUTH_MODE="false" +ENV HUB_BASE_PATH=${HUB_BASE_PATH} +ENV MPM_AUTH_MODE=${MPM_AUTH_MODE} +ENV NEXT_PUBLIC_APP_BASE_PATH=${HUB_BASE_PATH} COPY package.json package-lock.json ./ # ci statt install: reproduzierbarer Build aus dem Lockfile @@ -50,16 +55,13 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modul 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 +COPY --from=builder --chown=nextjs:nodejs /app/container-entrypoint.cjs ./container-entrypoint.cjs +COPY --from=builder --chown=nextjs:nodejs /app/module-runtime.js ./module-runtime.js +COPY --from=builder --chown=nextjs:nodejs /app/platform-module-sdk.cjs ./platform-module-sdk.cjs USER nextjs -EXPOSE 3000 +EXPOSE 3000 41030 -# Migrationen beim Start anwenden, optional Admin seeden, dann Server starten. -# prisma migrate deploy ist idempotent und sicher fuer wiederholte Starts. -# Das Seed-Script ist NICHT-destruktiv: Es legt den Admin nur an, wenn -# noch keiner existiert (First-Run-Detection). Bestehende User werden -# niemals geloescht. Wenn SEED_ADMIN_* nicht gesetzt sind, laeuft es -# ohne Nebeneffekte durch. -# Direkter node-Aufruf statt npx: im Standalone-Output existiert -# node_modules/.bin nicht, daher wuerde "npx prisma" mit "not found" fehlschlagen. -CMD ["sh", "-c", "node node_modules/prisma/build/index.js migrate deploy && node prisma/seed-admin.cjs && node server.js"] \ No newline at end of file +# Der gemeinsame Entrypoint setzt die Datenbankverbindung, wendet +# Migrationen an und startet anschließend Standalone- oder MPM-Betrieb. +CMD ["node", "container-entrypoint.cjs"] diff --git a/Dockerfile.postgres b/Dockerfile.postgres new file mode 100644 index 0000000..4f865c8 --- /dev/null +++ b/Dockerfile.postgres @@ -0,0 +1,3 @@ +FROM postgres:16-alpine +COPY docker/postgres/init/01-app-user.sh /docker-entrypoint-initdb.d/01-app-user.sh +RUN chmod 0555 /docker-entrypoint-initdb.d/01-app-user.sh diff --git a/README.md b/README.md index ced997b..b0ad5d8 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # 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. +Einfaches Kalender- und Reservierungstool: Ein Admin legt Kalender an und ordnet vorhandene Benutzer direkt zu. 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 +- **Mitglieder**: Administratoren ordnen vorhandene Benutzer direkt einem Kalender zu - **Reservierungen**: Startzeit + Dauer (15-Minuten-Schritte), Titel, Notiz - **Doppelbuchungsschutz**: atomare Prüfung in serialisierbarer Transaktion (HTTP 409 bei Kollision) - **Ansichten**: Tag / Woche / Monat @@ -17,16 +17,40 @@ Einfaches Kalender- und Reservierungstool nach `plan.md`: Ein Admin legt Kalende Next.js 14 (App Router, TypeScript) · PostgreSQL · Prisma · Tailwind CSS · Docker · Vitest -## Schnellstart mit Docker (empfohlen) +## MPM Marketplace + +Das Repository ist als MPM-Modul vorbereitet. `module.json` beschreibt das +Modul und seine Konfigurationsfelder; `compose.yml` enthält App, PostgreSQL +und tägliche Backups. Die Modul-App veröffentlicht keinen Host-Port. MPM +erreicht sie intern über Port `41030` und die Health-Route `/health`. + +Die Anmeldung und Rollen kommen im MPM-Betrieb signiert vom MPM-Gateway. +Die lokale Cookie-Anmeldung bleibt für den eigenständigen Betrieb erhalten. +Kalender und Reservierungen liegen in PostgreSQL. Die benannten Volumes +`pgdata` und `pgbackups` bleiben beim Entfernen des Moduls erhalten. + +Bei der ersten MPM-Konfiguration werden zwei getrennte, zufällige Passwörter +für PostgreSQL-Administration und App-Zugriff benötigt. Die Anmeldung läuft +über MPM; lokale Konten und Einladungslinks sind im MPM-Betrieb deaktiviert. + +Benutzer und Modulzugriff werden in MPM verwaltet. Öffne das Modul zunächst +einmal mit dem neuen Benutzer, damit sein lokales Profil angelegt wird; ein +Kalenderadministrator kann ihn danach direkt einem Kalender zuordnen. Die +Datenbank-Initialisierung wird nur bei einem leeren Volume ausgeführt. Ein +späteres Ändern der Datenbankpasswörter im MPM-Konfigurationsdialog ändert die +Zugangsdaten eines bestehenden PostgreSQL-Volumes nicht automatisch. + +## Schnellstart mit Docker (eigenständiger Betrieb) ```bash # 1. Session-Secret erzeugen und .env anlegen cp .env.example .env -# SESSION_SECRET in .env setzen (mindestens 32 Zeichen): +# CALENDAR_ADMIN_PASSWORD, CALENDAR_DB_PASSWORD und SESSION_SECRET in .env +# durch zufällige Werte ersetzen. SESSION_SECRET braucht mindestens 32 Zeichen: node -e "console.log('SESSION_SECRET=' + require('crypto').randomBytes(48).toString('base64url'))" # 2. Stack starten (PostgreSQL + App) -docker compose up --build +docker compose -f docker-compose.yml up --build ``` Die App läuft dann auf http://localhost:3000. Migrationen werden beim Containerstart automatisch angewendet (`prisma migrate deploy`). @@ -35,7 +59,7 @@ Die App läuft dann auf http://localhost:3000. Migrationen werden beim Container ```bash npm install -cp .env.example .env # DATABASE_URL und SESSION_SECRET anpassen +cp .env.example .env # Passwörter und SESSION_SECRET anpassen npx prisma db push # Schema in die Datenbank schreiben npm run dev ``` @@ -46,30 +70,27 @@ Voraussetzung: laufendes PostgreSQL (z. B. nur der `db`-Service aus `docker-comp 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 +3. Im Kalender unter „Mitglieder" vorhandene Benutzer zuordnen ## Projektstruktur ``` app/ Next.js App Router (Seiten + API-Routen) api/auth/ register, login, logout, forgot-password - api/calendars/ CRUD + Reservierungen + Invites + api/calendars/ CRUD + Reservierungen + Mitglieder 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/ Unit-Tests (Kollisionen, Validierung, Rechte) ``` ## Tests @@ -78,7 +99,7 @@ tests/ Unit-Tests (Kollisionen, Invites, Rechte) npm test ``` -Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus plan.md Abschnitt 6), Invite-Token-Hashing/-Ablauf, Eingabevalidierung und die komplette Berechtigungsmatrix. +Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus plan.md Abschnitt 6), Eingabevalidierung und die komplette Berechtigungsmatrix. ## Sicherheitskonzept (Kurzfassung) @@ -86,7 +107,6 @@ Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus | --- | --- | | 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) | @@ -95,4 +115,4 @@ Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus ## Bewusste MVP-Grenzen (plan.md Abschnitt 18) -Buchungszeitfenster-Regeln, E-Mail-Versand, ICS-Export, wiederkehrende Reservierungen und Statistiken sind bewusst **nicht** Teil von Version 1 – die Struktur (Service-Schicht, Regeln in `lib/`) ist darauf vorbereitet. \ No newline at end of file +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. diff --git a/app/admin/calendars/[id]/page.tsx b/app/admin/calendars/[id]/page.tsx index 74b1309..fb05d45 100644 --- a/app/admin/calendars/[id]/page.tsx +++ b/app/admin/calendars/[id]/page.tsx @@ -1,6 +1,6 @@ /** * Admin: Kalenderverwaltung (plan.md Abschnitt 13: /admin/calendars/[id]). - * Bearbeiten, deaktivieren, Invite-Links erstellen, Benutzer einsehen. + * Bearbeiten, deaktivieren und Mitglieder verwalten. */ import { redirect } from 'next/navigation'; import { prisma } from '@/lib/db/client'; @@ -9,7 +9,7 @@ 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 { MemberSection } from '@/components/admin/MemberSection'; import { AppShell } from '@/components/layout/AppShell'; interface PageProps { @@ -26,29 +26,18 @@ export default async function AdminCalendarPage({ params }: PageProps) { 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 } }, - }, - }), - ]); + const reservations = await 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 ( @@ -62,28 +51,8 @@ export default async function AdminCalendarPage({ params }: PageProps) { initialIsActive={context.calendar.isActive} /> - - -

Mitglieder ({members.length})

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

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

- ) : ( -
    - {members.map((member) => ( -
  • - {displayName(member.user.username, member.user.email)} - {member.user.username && ( - - {member.user.email} - - )} -
  • - ))} -
- )} +
@@ -112,4 +81,4 @@ export default async function AdminCalendarPage({ params }: PageProps) {
); -} \ No newline at end of file +} diff --git a/app/admin/calendars/page.tsx b/app/admin/calendars/page.tsx index 31919c3..a87e262 100644 --- a/app/admin/calendars/page.tsx +++ b/app/admin/calendars/page.tsx @@ -2,7 +2,7 @@ * 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). + * /admin/calendars/[id] (Mitglieder, Reservierungen). */ import { redirect } from 'next/navigation'; import { prisma } from '@/lib/db/client'; @@ -55,4 +55,4 @@ export default async function ManageCalendarsPage() { /> ); -} \ No newline at end of file +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 90dd17a..03c2e6b 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -1,17 +1,15 @@ /** * 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 + * Lokale Registrierung für den eigenständigen Betrieb. Sie ist nur + * verfügbar, 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 + * - SERIALIZABLE-Transaktion: 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 @@ -22,7 +20,6 @@ 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 { @@ -65,39 +62,9 @@ export async function POST(request: NextRequest): Promise { 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) { + if (!config.allowOpenRegistration) { throw new RegistrationError( - 'Registrierung ist nur ueber Einladungslinks moeglich.', + 'Registrierung ist derzeit deaktiviert.', 403, ); } @@ -114,9 +81,6 @@ export async function POST(request: NextRequest): Promise { passwordHash, role, ...(body.username ? { username: body.username } : {}), - ...(calendarId - ? { memberships: { create: { calendarId } } } - : {}), }, select: { id: true, email: true, role: true, username: true }, }); @@ -156,4 +120,4 @@ export async function POST(request: NextRequest): Promise { } return errorResponse(error); } -} \ No newline at end of file +} diff --git a/app/api/calendars/[id]/invites/route.ts b/app/api/calendars/[id]/invites/route.ts deleted file mode 100644 index c9581a9..0000000 --- a/app/api/calendars/[id]/invites/route.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * POST /api/calendars/[id]/invites - Invite-Link erstellen (nur Owner-Admin). - * GET /api/calendars/[id]/invites - Invite-Uebersicht (Aktiv/Abgelaufen/Verwendet). - */ -import { NextRequest, NextResponse } from 'next/server'; -import { prisma } from '@/lib/db/client'; -import { requireCalendarContext } from '@/lib/api/context'; -import { createInviteSchema } from '@/lib/validation'; -import { canManageCalendar } from '@/lib/permissions/permissions'; -import { - generateInviteToken, - hashInviteToken, - defaultInviteExpiry, -} from '@/lib/invites/tokens'; -import { getConfig } from '@/lib/config'; -import { errorResponse, forbiddenResponse } from '@/lib/api/responses'; - -interface RouteParams { - params: Promise<{ id: string }>; -} - -export async function POST( - request: NextRequest, - { params }: RouteParams, -): Promise { - try { - const { id } = await params; - const context = await requireCalendarContext(id); - - if (!canManageCalendar(context.user, context.calendar)) { - return forbiddenResponse(); - } - - const body = createInviteSchema.parse(await request.json()); - const expiry = body.expiresInDays - ? new Date(Date.now() + body.expiresInDays * 24 * 60 * 60 * 1000) - : defaultInviteExpiry(); - - // Klartext-Token wird genau einmal zurueckgegeben; in der DB - // liegt ausschliesslich der Hash (plan.md Abschnitt 9). - const token = generateInviteToken(); - const invite = await prisma.invite.create({ - data: { - calendarId: id, - tokenHash: hashInviteToken(token), - expiresAt: expiry, - }, - select: { id: true, expiresAt: true, createdAt: true }, - }); - - return NextResponse.json( - { - invite, - inviteUrl: `${getConfig().appUrl}/invite/${token}`, - }, - { status: 201 }, - ); - } catch (error) { - return errorResponse(error); - } -} - -export async function GET( - _request: NextRequest, - { params }: RouteParams, -): Promise { - try { - const { id } = await params; - const context = await requireCalendarContext(id); - - if (!canManageCalendar(context.user, context.calendar)) { - return forbiddenResponse(); - } - - const invites = await prisma.invite.findMany({ - where: { calendarId: id }, - orderBy: { createdAt: 'desc' }, - select: { id: true, expiresAt: true, usedAt: true, createdAt: true }, - }); - - const now = new Date(); - const summary = { - active: invites.filter( - (invite) => - invite.usedAt === null && - (invite.expiresAt === null || invite.expiresAt > now), - ).length, - expired: invites.filter( - (invite) => invite.usedAt === null && invite.expiresAt !== null && invite.expiresAt <= now, - ).length, - used: invites.filter((invite) => invite.usedAt !== null).length, - }; - - return NextResponse.json({ invites, summary }); - } catch (error) { - return errorResponse(error); - } -} \ No newline at end of file diff --git a/app/api/calendars/[id]/members/route.ts b/app/api/calendars/[id]/members/route.ts index 851592c..93cf190 100644 --- a/app/api/calendars/[id]/members/route.ts +++ b/app/api/calendars/[id]/members/route.ts @@ -3,8 +3,8 @@ * 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 + * Benutzer werden direkt per E-Mail-Suche ueber die Datenbank + * zugeordnet. Der Server * prueft die Verwaltungsberechtigung zentral (canManageCalendar). */ import { NextRequest, NextResponse } from 'next/server'; @@ -141,4 +141,4 @@ export async function DELETE( } catch (error) { return errorResponse(error); } -} \ No newline at end of file +} diff --git a/app/api/invites/[token]/route.ts b/app/api/invites/[token]/route.ts deleted file mode 100644 index 1b0da5f..0000000 --- a/app/api/invites/[token]/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * GET /api/invites/[token] - Invite-Informationen fuer die - * Annahme-Seite: "Du wurdest zum Kalender X eingeladen." - * - * Oeffentlich zugaenglich (vor der Registrierung), gibt aber nur - * den Kalendertitel preis - niemals Reservierungen oder Mitglieder. - */ -import { NextResponse } from 'next/server'; -import { prisma } from '@/lib/db/client'; -import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens'; -import { errorResponse, notFoundResponse } from '@/lib/api/responses'; - -interface RouteParams { - params: Promise<{ token: string }>; -} - -export async function GET( - _request: Request, - { params }: RouteParams, -): Promise { - try { - const { token } = await params; - const tokenHash = hashInviteToken(token); - - const invite = await prisma.invite.findUnique({ - where: { tokenHash }, - select: { - expiresAt: true, - usedAt: true, - calendar: { select: { title: true, description: true } }, - }, - }); - - if (!invite || !isInviteUsable(invite)) { - return notFoundResponse('Dieser Einladungslink ist ungueltig oder abgelaufen.'); - } - - return NextResponse.json({ - calendarTitle: invite.calendar.title, - calendarDescription: invite.calendar.description, - }); - } catch (error) { - return errorResponse(error); - } -} \ No newline at end of file diff --git a/app/api/manifest/route.ts b/app/api/manifest/route.ts index 29e9623..2caf414 100644 --- a/app/api/manifest/route.ts +++ b/app/api/manifest/route.ts @@ -10,16 +10,17 @@ import { NextResponse } from "next/server"; export async function GET() { const basePath = (process.env.HUB_BASE_PATH ?? "").replace(/\/$/, ""); + const mpmMode = process.env.MPM_AUTH_MODE === "true"; return NextResponse.json( { name: "Kalendertool", - slug: "kalender", - version: "1.0", + slug: "kalendartool", + version: "1.1.0", icon: "/icon.png", - auth: { mode: "hub-token", path: "/auth/hub" }, - basePath: basePath || "/apps/kalender", + auth: mpmMode ? { mode: "mpm-gateway" } : { mode: "hub-token", path: "/auth/hub" }, + basePath: basePath || "/", }, { headers: { "Cache-Control": "public, max-age=60" } } ); -} \ No newline at end of file +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 68fa3e7..7403b5d 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -108,7 +108,7 @@ export default async function DashboardPage() {

Du bist noch keinem Kalender zugeordnet. Bitte wende dich an den - Administrator fuer einen Einladungslink. + Administrator, damit er dich einem Kalender zuordnet.

) : ( @@ -143,4 +143,4 @@ export default async function DashboardPage() { )} ); -} \ No newline at end of file +} diff --git a/app/forgot-password/page.tsx b/app/forgot-password/page.tsx index 22e19af..27554b4 100644 --- a/app/forgot-password/page.tsx +++ b/app/forgot-password/page.tsx @@ -4,6 +4,7 @@ * "Passwort vergessen"-Seite (plan.md Abschnitt 11). * Antwortet immer mit Erfolgsmeldung (kein Account-Enumeration). */ +import { appFetch } from '@/lib/client-fetch'; import { useState } from 'react'; import Link from 'next/link'; import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui'; @@ -20,7 +21,7 @@ export default function ForgotPasswordPage() { const formData = new FormData(event.currentTarget); try { - const response = await fetch('/api/auth/forgot-password', { + const response = await appFetch('/api/auth/forgot-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: formData.get('email') }), diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx deleted file mode 100644 index 0065268..0000000 --- a/app/invite/[token]/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Invite-Annahmeseite (plan.md Abschnitt 3). - * - * Serverkomponente: prueft den Token serverseitig und zeigt den - * Kalendertitel. Danach Registrierung mit dem Token in der URL. - */ -import Link from 'next/link'; -import { prisma } from '@/lib/db/client'; -import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens'; -import { Card } from '@/components/ui/ui'; - -interface PageProps { - params: Promise<{ token: string }>; -} - -export default async function InvitePage({ params }: PageProps) { - const { token } = await params; - const tokenHash = hashInviteToken(token); - - const invite = await prisma.invite.findUnique({ - where: { tokenHash }, - select: { - expiresAt: true, - usedAt: true, - calendar: { select: { title: true, description: true } }, - }, - }); - - if (!invite || !isInviteUsable(invite)) { - return ( -
- -

Einladung ungueltig

-

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

-
-
- ); - } - - return ( -
- -

Einladung

-

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

- - Jetzt registrieren - -
-
- ); -} \ No newline at end of file diff --git a/app/login/page.tsx b/app/login/page.tsx index 75f4c5f..73f7644 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -3,6 +3,7 @@ /** * Anmeldeseite (plan.md Abschnitt 11). */ +import { appFetch } from '@/lib/client-fetch'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; @@ -20,7 +21,7 @@ export default function LoginPage() { const formData = new FormData(event.currentTarget); try { - const response = await fetch('/api/auth/login', { + const response = await appFetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/app/register/page.tsx b/app/register/page.tsx index 33354a2..bff4c1a 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -1,18 +1,16 @@ 'use client'; /** - * Registrierungsseite (plan.md Abschnitt 3 und 11). - * Mit Invite-Token (aus URL) wird dem Kalender automatisch zugeordnet. + * Lokale Registrierungsseite für den eigenständigen Betrieb. */ -import { Suspense, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { appFetch } from '@/lib/client-fetch'; +import { useState } from 'react'; +import { useRouter } 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(null); const [busy, setBusy] = useState(false); @@ -35,14 +33,13 @@ function RegisterForm() { try { const username = String(formData.get('username') ?? '').trim(); - const response = await fetch('/api/auth/register', { + const response = await appFetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: formData.get('email'), password, ...(username ? { username } : {}), - ...(inviteToken ? { inviteToken } : {}), }), }); @@ -62,12 +59,6 @@ function RegisterForm() { return ( - {inviteToken && ( -

- Du wurdest zu einem Kalender eingeladen. Nach der Registrierung siehst du - ausschliesslich diesen Kalender. -

- )}
@@ -128,9 +119,7 @@ export default function RegisterPage() { return (

Konto erstellen

- Wird geladen …}> - - +
); -} \ No newline at end of file +} diff --git a/components/admin/CalendarManager.tsx b/components/admin/CalendarManager.tsx index 1a87d04..d6009c0 100644 --- a/components/admin/CalendarManager.tsx +++ b/components/admin/CalendarManager.tsx @@ -9,6 +9,7 @@ * - "Kalender erstellen" oeffnet ein Erstellen-Modal (POST /api/calendars). * - Alle Modals sind per X-Icon, ESC und Hintergrund-Klick schliessbar. */ +import { appFetch } from '@/lib/client-fetch'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui'; @@ -66,7 +67,7 @@ export function CalendarManager({ setIsPending(true); setError(null); try { - const response = await fetch('/api/calendars', { + const response = await appFetch('/api/calendars', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values), diff --git a/components/admin/CreateCalendarForm.tsx b/components/admin/CreateCalendarForm.tsx index c576f38..1867933 100644 --- a/components/admin/CreateCalendarForm.tsx +++ b/components/admin/CreateCalendarForm.tsx @@ -1,6 +1,7 @@ 'use client'; /** Formular zum Erstellen eines Kalenders (nur Admin). */ +import { appFetch } from '@/lib/client-fetch'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui'; @@ -17,7 +18,7 @@ export function CreateCalendarForm() { const formData = new FormData(event.currentTarget); try { - const response = await fetch('/api/calendars', { + const response = await appFetch('/api/calendars', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/components/admin/EditCalendarForm.tsx b/components/admin/EditCalendarForm.tsx index 3a7e51e..e995bc3 100644 --- a/components/admin/EditCalendarForm.tsx +++ b/components/admin/EditCalendarForm.tsx @@ -1,6 +1,7 @@ 'use client'; /** Formular zum Bearbeiten/Deaktivieren eines Kalenders (Admin). */ +import { appFetch } from '@/lib/client-fetch'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui'; @@ -29,7 +30,7 @@ export function EditCalendarForm({ const formData = new FormData(event.currentTarget); try { - const response = await fetch(`/api/calendars/${calendarId}`, { + const response = await appFetch(`/api/calendars/${calendarId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/components/admin/EditCalendarModal.tsx b/components/admin/EditCalendarModal.tsx index 6ed5d28..fd5a494 100644 --- a/components/admin/EditCalendarModal.tsx +++ b/components/admin/EditCalendarModal.tsx @@ -15,6 +15,7 @@ * Dashboard verwendet (DRY). Schliessen via X-Icon, ESC oder * Hintergrund-Klick; waehrend des Speicherns ist Schliessen blockiert. */ +import { appFetch } from '@/lib/client-fetch'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui'; @@ -61,7 +62,7 @@ export function EditCalendarModal({ const formData = new FormData(event.currentTarget); try { - const response = await fetch(`/api/calendars/${calendar.id}`, { + const response = await appFetch(`/api/calendars/${calendar.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/components/admin/InviteManager.tsx b/components/admin/InviteManager.tsx deleted file mode 100644 index 85a9649..0000000 --- a/components/admin/InviteManager.tsx +++ /dev/null @@ -1,113 +0,0 @@ -'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(null); - const [busy, setBusy] = useState(false); - const [lastInviteUrl, setLastInviteUrl] = useState(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 ( - -
-

Einladungen

- -
- - - - {lastInviteUrl && ( -
-

Neuer Invite-Link (einmalig anzeigen):

- {lastInviteUrl} -
- )} - -
- Aktiv: {active} - Abgelaufen: {expired} - Verwendet: {used} -
- - {invites.length === 0 ? ( -

Noch keine Einladungen erstellt.

- ) : ( -
    - {invites.slice(0, 10).map((invite) => ( -
  • - Erstellt {invite.createdAt.toLocaleDateString('de-DE')} - - {invite.usedAt - ? 'Verwendet' - : invite.expiresAt && invite.expiresAt <= now - ? 'Abgelaufen' - : 'Aktiv'} - -
  • - ))} -
- )} -
- ); -} \ No newline at end of file diff --git a/components/admin/InviteSection.tsx b/components/admin/InviteSection.tsx deleted file mode 100644 index f91c369..0000000 --- a/components/admin/InviteSection.tsx +++ /dev/null @@ -1,159 +0,0 @@ -'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([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [isCreating, setIsCreating] = useState(false); - const [lastInviteUrl, setLastInviteUrl] = useState(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 ( -
-
-

Einladungen

- -
- - - - {lastInviteUrl && ( -
-

Neuer Invite-Link (einmalig anzeigen):

- {lastInviteUrl} -
- )} - - {!isLoading && ( -
- Aktiv: {active} - Abgelaufen: {expired} - Verwendet: {used} -
- )} - - {isLoading ? ( -

Einladungen werden geladen …

- ) : invites.length === 0 ? ( -

- Noch keine Einladungen erstellt. -

- ) : ( -
    - {invites.slice(0, 10).map((invite) => { - const isUsed = invite.usedAt !== null; - const isExpired = - !isUsed && invite.expiresAt !== null && new Date(invite.expiresAt) <= now; - return ( -
  • - - Erstellt {new Date(invite.createdAt).toLocaleDateString('de-DE')} - - - {isUsed - ? 'Verwendet' - : isExpired - ? 'Abgelaufen' - : 'Aktiv'} - -
  • - ); - })} -
- )} -
- ); -} \ No newline at end of file diff --git a/components/admin/MemberSection.tsx b/components/admin/MemberSection.tsx index e4370ca..91a67d5 100644 --- a/components/admin/MemberSection.tsx +++ b/components/admin/MemberSection.tsx @@ -1,16 +1,17 @@ 'use client'; /** - * Mitgliederverwaltung fuer Modals (DB-basiertes Einladungssystem). + * Mitgliederverwaltung fuer Kalender. * - * Statt Invite-Links werden bestehende Benutzer direkt per E-Mail - * ueber die Datenbank gesucht und dem Kalender zugeordnet: + * Vorhandene Benutzer werden 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 { appFetch } from '@/lib/client-fetch'; import { useCallback, useEffect, useRef, useState } from 'react'; import { ErrorMessage } from '@/components/ui/ui'; @@ -44,7 +45,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) { setIsLoading(true); setError(null); try { - const response = await fetch(`/api/calendars/${calendarId}/members`); + const response = await appFetch(`/api/calendars/${calendarId}/members`); if (!response.ok) { const data = (await response.json().catch(() => ({}))) as { error?: string }; setError(data.error ?? 'Mitglieder konnten nicht geladen werden.'); @@ -75,7 +76,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) { setAddingEmail(email); setError(null); try { - const response = await fetch(`/api/calendars/${calendarId}/members`, { + const response = await appFetch(`/api/calendars/${calendarId}/members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), @@ -100,7 +101,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) { setRemovingId(memberId); setError(null); try { - const response = await fetch( + const response = await appFetch( `/api/calendars/${calendarId}/members?memberId=${encodeURIComponent(memberId)}`, { method: 'DELETE' }, ); @@ -130,7 +131,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) { searchTimer.current = setTimeout(async () => { setIsSearching(true); try { - const response = await fetch( + const response = await appFetch( `/api/users/search?q=${encodeURIComponent(nextQuery.trim())}`, ); if (response.ok) { @@ -237,4 +238,4 @@ export function MemberSection({ calendarId }: { calendarId: string }) { )} ); -} \ No newline at end of file +} diff --git a/components/admin/UserManagement.tsx b/components/admin/UserManagement.tsx index 1eff9ef..ff35677 100644 --- a/components/admin/UserManagement.tsx +++ b/components/admin/UserManagement.tsx @@ -6,6 +6,7 @@ * Fehler werden inline angezeigt. Der eigene Account ist geschuetzt * (Server lehnt ab). */ +import { appFetch } from '@/lib/client-fetch'; import { useCallback, useEffect, useState } from 'react'; import { Button, Card, ErrorMessage, Badge } from '@/components/ui/ui'; import { ModalShell } from '@/components/ui/ModalShell'; @@ -32,7 +33,7 @@ export function UserManagement({ currentUserId }: { currentUserId: string }) { setIsLoading(true); setError(null); try { - const response = await fetch('/api/users', { cache: 'no-store' }); + const response = await appFetch('/api/users', { cache: 'no-store' }); if (!response.ok) { const data = (await response.json().catch(() => null)) as | { error?: string } @@ -65,7 +66,7 @@ export function UserManagement({ currentUserId }: { currentUserId: string }) { setPendingId(userId); setError(null); try { - const response = await fetch(`/api/users/${userId}`, { + const response = await appFetch(`/api/users/${userId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: nextRole }), diff --git a/components/auth/LogoutButton.tsx b/components/auth/LogoutButton.tsx index a40b07c..23aeb88 100644 --- a/components/auth/LogoutButton.tsx +++ b/components/auth/LogoutButton.tsx @@ -1,6 +1,7 @@ 'use client'; /** Logout-Button: beendet die Session per API und laedt neu. */ +import { appFetch } from '@/lib/client-fetch'; import { useRouter } from 'next/navigation'; import { Button } from '@/components/ui/ui'; @@ -8,7 +9,7 @@ export function LogoutButton() { const router = useRouter(); async function logout() { - await fetch('/api/auth/logout', { method: 'POST' }); + await appFetch('/api/auth/logout', { method: 'POST' }); router.push('/login'); router.refresh(); } diff --git a/components/calendar/CalendarModal.tsx b/components/calendar/CalendarModal.tsx index fcf6ecb..d0d7a5f 100644 --- a/components/calendar/CalendarModal.tsx +++ b/components/calendar/CalendarModal.tsx @@ -12,6 +12,7 @@ * - Loeschen ausschliesslich durch Admins (Server prueft final) * - Legende: eigene vs. fremde Reservierungen */ +import { appFetch } from '@/lib/client-fetch'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { ModalShell } from '@/components/ui/ModalShell'; import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui'; @@ -189,7 +190,7 @@ export function CalendarModal({ setIsLoading(true); setError(null); try { - const response = await fetch( + const response = await appFetch( `/api/calendars/${calendarId}/reservations?from=${encodeURIComponent(window.from)}&to=${encodeURIComponent(window.to)}`, ); if (!response.ok) { @@ -231,7 +232,7 @@ export function CalendarModal({ } try { - const response = await fetch(`/api/calendars/${calendarId}/reservations`, { + const response = await appFetch(`/api/calendars/${calendarId}/reservations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -261,7 +262,7 @@ export function CalendarModal({ setDeletingId(reservationId); setError(null); try { - const response = await fetch(`/api/reservations/${reservationId}`, { + const response = await appFetch(`/api/reservations/${reservationId}`, { method: 'DELETE', }); if (!response.ok) { diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index ef9e695..f40952d 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -11,6 +11,7 @@ * Der Zustand wird in localStorage persistiert, damit die Wahl * ueber Seitenwechsel hinweg erhalten bleibt. */ +import { appFetch } from '@/lib/client-fetch'; import { useEffect, useState } from 'react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; @@ -115,7 +116,7 @@ export function Sidebar({ async function logout() { setIsLoggingOut(true); try { - await fetch('/api/auth/logout', { method: 'POST' }); + await appFetch('/api/auth/logout', { method: 'POST' }); router.push('/login'); router.refresh(); } finally { @@ -140,7 +141,7 @@ export function Sidebar({ Kalendartool )} - + }