Prepare Kalendartool for MPM and disable invites

This commit is contained in:
Kühn
2026-10-08 15:06:22 +02:00
parent bc49c3074e
commit 556202575e
50 changed files with 905 additions and 836 deletions

View File

@@ -5,3 +5,4 @@ node_modules
.env*.local .env*.local
*.md *.md
tests tests
.env*

View File

@@ -1,22 +1,19 @@
# PostgreSQL-Verbindung (in docker-compose.yml automatisch korrekt gesetzt) # Nur für den eigenständigen Betrieb außerhalb von MPM.
DATABASE_URL="postgresql://calendar_app:calendar_app@localhost:5432/calendar?schema=public" # In MPM werden dieselben Werte sicher über den Konfigurationsdialog gesetzt.
# Session-Signierung – mindestens 32 Zeichen. Erzeugen z. B. mit: # Zufällige alphanumerische Passwörter verwenden (mindestens 32 Zeichen).
# node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" CALENDAR_ADMIN_PASSWORD="change-this-database-admin-password"
# WICHTIG: Ohne gesetztes SESSION_SECRET startet die App NICHT mehr CALENDAR_DB_PASSWORD="change-this-calendar-database-password"
# (docker-compose.yml hat keinen unsicheren Fallback mehr).
# Mindestens 32 Zeichen, z. B. mit `openssl rand -base64 48` erzeugen.
SESSION_SECRET="change-me-change-me-change-me-change-me" SESSION_SECRET="change-me-change-me-change-me-change-me"
# Basis-URL der Anwendung (fuer Invite-Links) # Nur für lokale Entwicklung ohne Compose; Passwort URL-kodieren.
APP_URL="http://localhost:3000" # 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" ALLOW_OPEN_REGISTRATION="true"
# HUB_BASE_PATH="/kalendartool"
# 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_EMAIL="admin@example.com"
# SEED_ADMIN_PASSWORD="SicheresPasswort10" # SEED_ADMIN_PASSWORD="change-this-admin-password"

View File

@@ -5,6 +5,11 @@
# zurueck und der Client kann nicht initialisiert werden. # zurueck und der Client kann nicht initialisiert werden.
FROM node:20-bookworm-slim AS builder FROM node:20-bookworm-slim AS builder
WORKDIR /app 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 ./ COPY package.json package-lock.json ./
# ci statt install: reproduzierbarer Build aus dem Lockfile # 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 COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
# bcryptjs fuer das Admin-Seed-Script (manuell ausfuehrbar, nicht beim Start) # 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/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 USER nextjs
EXPOSE 3000 EXPOSE 3000 41030
# Migrationen beim Start anwenden, optional Admin seeden, dann Server starten. # Der gemeinsame Entrypoint setzt die Datenbankverbindung, wendet
# prisma migrate deploy ist idempotent und sicher fuer wiederholte Starts. # Migrationen an und startet anschließend Standalone- oder MPM-Betrieb.
# Das Seed-Script ist NICHT-destruktiv: Es legt den Admin nur an, wenn CMD ["node", "container-entrypoint.cjs"]
# 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"]

3
Dockerfile.postgres Normal file
View File

@@ -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

View File

@@ -1,13 +1,13 @@
# Kalendartool # 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) ## Kernfunktionen (MVP)
- **Auth**: Registrierung, Login, Logout, Passwort vergessen – bcrypt-Hashing, signierte HttpOnly-Session-Cookies, Rate-Limiting beim Login - **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 - **Rollen**: `ADMIN` (0..n Kalender) und `USER` (genau 1 Kalender) – serverseitig erzwungen
- **Kalender**: Titel, Beschreibung, Zeitzone, aktiv/inaktiv - **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 - **Reservierungen**: Startzeit + Dauer (15-Minuten-Schritte), Titel, Notiz
- **Doppelbuchungsschutz**: atomare Prüfung in serialisierbarer Transaktion (HTTP 409 bei Kollision) - **Doppelbuchungsschutz**: atomare Prüfung in serialisierbarer Transaktion (HTTP 409 bei Kollision)
- **Ansichten**: Tag / Woche / Monat - **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 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 ```bash
# 1. Session-Secret erzeugen und .env anlegen # 1. Session-Secret erzeugen und .env anlegen
cp .env.example .env 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'))" node -e "console.log('SESSION_SECRET=' + require('crypto').randomBytes(48).toString('base64url'))"
# 2. Stack starten (PostgreSQL + App) # 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`). 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 ```bash
npm install 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 npx prisma db push # Schema in die Datenbank schreiben
npm run dev 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** 1. Erste Registrierung unter `/register` → der **erste Benutzer wird automatisch ADMIN**
2. Im Dashboard einen Kalender anlegen 2. Im Dashboard einen Kalender anlegen
3. Unter „Benutzer einladen" einen Invite-Link erstellen und verschicken 3. Im Kalender unter „Mitglieder" vorhandene Benutzer zuordnen
4. Eingeladene Personen registrieren sich über den Link und sehen sofort nur diesen einen Kalender
## Projektstruktur ## Projektstruktur
``` ```
app/ Next.js App Router (Seiten + API-Routen) app/ Next.js App Router (Seiten + API-Routen)
api/auth/ register, login, logout, forgot-password api/auth/ register, login, logout, forgot-password
api/calendars/ CRUD + Reservierungen + Invites api/calendars/ CRUD + Reservierungen + Mitglieder
api/reservations/ Bearbeiten/Löschen (atomar) api/reservations/ Bearbeiten/Löschen (atomar)
dashboard/ User: 1 Kalender · Admin: alle Kalender dashboard/ User: 1 Kalender · Admin: alle Kalender
calendar/[id]/ Ansicht Tag/Woche/Monat calendar/[id]/ Ansicht Tag/Woche/Monat
invite/[token]/ Invite-Annahme
admin/ Kalenderverwaltung admin/ Kalenderverwaltung
components/ UI-, Kalender-, Reservierungs-, Admin-Komponenten components/ UI-, Kalender-, Reservierungs-, Admin-Komponenten
lib/ lib/
auth/ Session (JWT-Cookies), Passwort-Hashing auth/ Session (JWT-Cookies), Passwort-Hashing
permissions/ zentrale Berechtigungsschicht permissions/ zentrale Berechtigungsschicht
reservations/ Konfliktlogik + atomarer Service reservations/ Konfliktlogik + atomarer Service
invites/ Token-Erzeugung/Hashing
api/ Response-Helfer, Kontext-Lader api/ Response-Helfer, Kontext-Lader
validation.ts Zod-Schemas validation.ts Zod-Schemas
prisma/schema.prisma Datenmodell prisma/schema.prisma Datenmodell
tests/ Unit-Tests (Kollisionen, Invites, Rechte) tests/ Unit-Tests (Kollisionen, Validierung, Rechte)
``` ```
## Tests ## Tests
@@ -78,7 +99,7 @@ tests/ Unit-Tests (Kollisionen, Invites, Rechte)
npm test 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) ## Sicherheitskonzept (Kurzfassung)
@@ -86,7 +107,6 @@ Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus
| --- | --- | | --- | --- |
| Doppelbuchungen | `SERIALIZABLE`-Transaktion in `lib/reservations/service.ts` | | Doppelbuchungen | `SERIALIZABLE`-Transaktion in `lib/reservations/service.ts` |
| Berechtigungen | zentrale Funktionen in `lib/permissions/permissions.ts`, geprüft in **jeder** API-Route | | 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 | | Passwörter | bcrypt (12 Runden), nie im Klartext |
| Sessions | HS256-signierte JWTs in HttpOnly/SameSite-Cookies | | Sessions | HS256-signierte JWTs in HttpOnly/SameSite-Cookies |
| Login-Missbrauch | Rate-Limiting (5 Versuche / 15 min) | | Login-Missbrauch | Rate-Limiting (5 Versuche / 15 min) |

View File

@@ -1,6 +1,6 @@
/** /**
* Admin: Kalenderverwaltung (plan.md Abschnitt 13: /admin/calendars/[id]). * 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 { redirect } from 'next/navigation';
import { prisma } from '@/lib/db/client'; import { prisma } from '@/lib/db/client';
@@ -9,7 +9,7 @@ import { canManageCalendar } from '@/lib/permissions/permissions';
import { displayName } from '@/lib/privacy/display'; import { displayName } from '@/lib/privacy/display';
import { Card } from '@/components/ui/ui'; import { Card } from '@/components/ui/ui';
import { EditCalendarForm } from '@/components/admin/EditCalendarForm'; import { EditCalendarForm } from '@/components/admin/EditCalendarForm';
import { InviteManager } from '@/components/admin/InviteManager'; import { MemberSection } from '@/components/admin/MemberSection';
import { AppShell } from '@/components/layout/AppShell'; import { AppShell } from '@/components/layout/AppShell';
interface PageProps { interface PageProps {
@@ -26,29 +26,18 @@ export default async function AdminCalendarPage({ params }: PageProps) {
redirect('/dashboard'); redirect('/dashboard');
} }
const [members, invites, reservations] = await Promise.all([ const reservations = await prisma.reservation.findMany({
prisma.calendarMember.findMany({ where: { calendarId: id, status: 'CONFIRMED' },
where: { calendarId: id }, orderBy: { startAt: 'desc' },
select: { user: { select: { email: true, username: true, createdAt: true } } }, take: 50,
}), select: {
prisma.invite.findMany({ id: true,
where: { calendarId: id }, title: true,
orderBy: { createdAt: 'desc' }, startAt: true,
select: { id: true, expiresAt: true, usedAt: true, createdAt: true }, endAt: true,
}), user: { select: { email: true, username: 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 ( return (
<AppShell role={context.user.role} userEmail={context.user.email} username={context.user.username}> <AppShell role={context.user.role} userEmail={context.user.email} username={context.user.username}>
@@ -62,28 +51,8 @@ export default async function AdminCalendarPage({ params }: PageProps) {
initialIsActive={context.calendar.isActive} initialIsActive={context.calendar.isActive}
/> />
<InviteManager calendarId={id} invites={invites} />
<Card> <Card>
<h2 className="mb-3 text-lg font-semibold">Mitglieder ({members.length})</h2> <MemberSection calendarId={id} />
{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>
<Card> <Card>

View File

@@ -2,7 +2,7 @@
* Admin: Kalenderverwaltung (Cards-Ansicht). * Admin: Kalenderverwaltung (Cards-Ansicht).
* Zeigt alle Kalender als Karten - eigene und die anderer Benutzer. * Zeigt alle Kalender als Karten - eigene und die anderer Benutzer.
* Verwaltung direkt im Bearbeiten-Modal oder auf der Detailseite * 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 { redirect } from 'next/navigation';
import { prisma } from '@/lib/db/client'; import { prisma } from '@/lib/db/client';

View File

@@ -1,17 +1,15 @@
/** /**
* POST /api/auth/register * POST /api/auth/register
* *
* Registrierung (plan.md Abschnitt 11). Zwei Wege: * Lokale Registrierung für den eigenständigen Betrieb. Sie ist nur
* 1. Mit Invite-Token -> User wird dem Kalender des Invites zugeordnet. * verfügbar, wenn ALLOW_OPEN_REGISTRATION=true; der erste
* 2. Ohne Token -> nur wenn ALLOW_OPEN_REGISTRATION=true; der erste
* Benutzer wird ADMIN, weitere offene Registrierungen werden * Benutzer wird ADMIN, weitere offene Registrierungen werden
* dann automatisch zu USER ohne Kalender (oder abgelehnt, je * dann automatisch zu USER ohne Kalender (oder abgelehnt, je
* nach Konfiguration). * nach Konfiguration).
* *
* Sicherheit: * Sicherheit:
* - Rate-Limit pro IP (Brute-Force-/Enumeration-Schutz). * - Rate-Limit pro IP (Brute-Force-/Enumeration-Schutz).
* - SERIALIZABLE-Transaktion: Ein Invite kann trotz paralleler * - SERIALIZABLE-Transaktion: Der "erste Benutzer
* Registrierungen nur EINMAL verwendet werden; der "erste Benutzer
* wird ADMIN"-Wettlauf kann nicht mehrere Admins erzeugen. * wird ADMIN"-Wettlauf kann nicht mehrere Admins erzeugen.
* - Bei bereits registrierter E-Mail wird bewusst mit 400 und einer * - Bei bereits registrierter E-Mail wird bewusst mit 400 und einer
* generischen Meldung geantwortet (kein 409), damit die Antwort * 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 { hashPassword } from '@/lib/auth/password';
import { createSessionToken, setSessionCookie } from '@/lib/auth/session'; import { createSessionToken, setSessionCookie } from '@/lib/auth/session';
import { registerSchema } from '@/lib/validation'; import { registerSchema } from '@/lib/validation';
import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { errorResponse } from '@/lib/api/responses'; import { errorResponse } from '@/lib/api/responses';
import { import {
@@ -65,39 +62,9 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
const passwordHash = await hashPassword(body.password); const passwordHash = await hashPassword(body.password);
const createdUser = await withSerializableTx(async (tx) => { const createdUser = await withSerializableTx(async (tx) => {
let calendarId: string | null = null; if (!config.allowOpenRegistration) {
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( throw new RegistrationError(
'Registrierung ist nur ueber Einladungslinks moeglich.', 'Registrierung ist derzeit deaktiviert.',
403, 403,
); );
} }
@@ -114,9 +81,6 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
passwordHash, passwordHash,
role, role,
...(body.username ? { username: body.username } : {}), ...(body.username ? { username: body.username } : {}),
...(calendarId
? { memberships: { create: { calendarId } } }
: {}),
}, },
select: { id: true, email: true, role: true, username: true }, select: { id: true, email: true, role: true, username: true },
}); });

View File

@@ -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<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);
}
}

View File

@@ -3,8 +3,8 @@
* POST /api/calendars/[id]/members - User per E-Mail hinzufuegen (nur Admin). * POST /api/calendars/[id]/members - User per E-Mail hinzufuegen (nur Admin).
* DELETE /api/calendars/[id]/members - Mitglied entfernen (nur Admin). * DELETE /api/calendars/[id]/members - Mitglied entfernen (nur Admin).
* *
* DB-basiertes Einladungssystem: Statt Invite-Links werden Benutzer * Benutzer werden direkt per E-Mail-Suche ueber die Datenbank
* direkt per E-Mail-Suche ueber die Datenbank zugeordnet. Der Server * zugeordnet. Der Server
* prueft die Verwaltungsberechtigung zentral (canManageCalendar). * prueft die Verwaltungsberechtigung zentral (canManageCalendar).
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';

View File

@@ -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<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);
}
}

View File

@@ -10,15 +10,16 @@ import { NextResponse } from "next/server";
export async function GET() { export async function GET() {
const basePath = (process.env.HUB_BASE_PATH ?? "").replace(/\/$/, ""); const basePath = (process.env.HUB_BASE_PATH ?? "").replace(/\/$/, "");
const mpmMode = process.env.MPM_AUTH_MODE === "true";
return NextResponse.json( return NextResponse.json(
{ {
name: "Kalendertool", name: "Kalendertool",
slug: "kalender", slug: "kalendartool",
version: "1.0", version: "1.1.0",
icon: "/icon.png", icon: "/icon.png",
auth: { mode: "hub-token", path: "/auth/hub" }, auth: mpmMode ? { mode: "mpm-gateway" } : { mode: "hub-token", path: "/auth/hub" },
basePath: basePath || "/apps/kalender", basePath: basePath || "/",
}, },
{ headers: { "Cache-Control": "public, max-age=60" } } { headers: { "Cache-Control": "public, max-age=60" } }
); );

View File

@@ -108,7 +108,7 @@ export default async function DashboardPage() {
<Card> <Card>
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
Du bist noch keinem Kalender zugeordnet. Bitte wende dich an den Du bist noch keinem Kalender zugeordnet. Bitte wende dich an den
Administrator fuer einen Einladungslink. Administrator, damit er dich einem Kalender zuordnet.
</p> </p>
</Card> </Card>
) : ( ) : (

View File

@@ -4,6 +4,7 @@
* "Passwort vergessen"-Seite (plan.md Abschnitt 11). * "Passwort vergessen"-Seite (plan.md Abschnitt 11).
* Antwortet immer mit Erfolgsmeldung (kein Account-Enumeration). * Antwortet immer mit Erfolgsmeldung (kein Account-Enumeration).
*/ */
import { appFetch } from '@/lib/client-fetch';
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui'; import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
@@ -20,7 +21,7 @@ export default function ForgotPasswordPage() {
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
try { try {
const response = await fetch('/api/auth/forgot-password', { const response = await appFetch('/api/auth/forgot-password', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: formData.get('email') }), body: JSON.stringify({ email: formData.get('email') }),

View File

@@ -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 (
<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>
);
}

View File

@@ -3,6 +3,7 @@
/** /**
* Anmeldeseite (plan.md Abschnitt 11). * Anmeldeseite (plan.md Abschnitt 11).
*/ */
import { appFetch } from '@/lib/client-fetch';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
@@ -20,7 +21,7 @@ export default function LoginPage() {
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
try { try {
const response = await fetch('/api/auth/login', { const response = await appFetch('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -1,18 +1,16 @@
'use client'; 'use client';
/** /**
* Registrierungsseite (plan.md Abschnitt 3 und 11). * Lokale Registrierungsseite für den eigenständigen Betrieb.
* Mit Invite-Token (aus URL) wird dem Kalender automatisch zugeordnet.
*/ */
import { Suspense, useState } from 'react'; import { appFetch } from '@/lib/client-fetch';
import { useRouter, useSearchParams } from 'next/navigation'; import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui'; import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
function RegisterForm() { function RegisterForm() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const inviteToken = searchParams.get('token') ?? '';
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -35,14 +33,13 @@ function RegisterForm() {
try { try {
const username = String(formData.get('username') ?? '').trim(); const username = String(formData.get('username') ?? '').trim();
const response = await fetch('/api/auth/register', { const response = await appFetch('/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
email: formData.get('email'), email: formData.get('email'),
password, password,
...(username ? { username } : {}), ...(username ? { username } : {}),
...(inviteToken ? { inviteToken } : {}),
}), }),
}); });
@@ -62,12 +59,6 @@ function RegisterForm() {
return ( return (
<Card> <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"> <form onSubmit={handleSubmit} className="space-y-4">
<ErrorMessage message={error} /> <ErrorMessage message={error} />
<div> <div>
@@ -128,9 +119,7 @@ export default function RegisterPage() {
return ( return (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4"> <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> <h1 className="mb-6 text-center text-2xl font-bold">Konto erstellen</h1>
<Suspense fallback={<Card>Wird geladen …</Card>}> <RegisterForm />
<RegisterForm />
</Suspense>
</main> </main>
); );
} }

View File

@@ -9,6 +9,7 @@
* - "Kalender erstellen" oeffnet ein Erstellen-Modal (POST /api/calendars). * - "Kalender erstellen" oeffnet ein Erstellen-Modal (POST /api/calendars).
* - Alle Modals sind per X-Icon, ESC und Hintergrund-Klick schliessbar. * - Alle Modals sind per X-Icon, ESC und Hintergrund-Klick schliessbar.
*/ */
import { appFetch } from '@/lib/client-fetch';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui'; import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
@@ -66,7 +67,7 @@ export function CalendarManager({
setIsPending(true); setIsPending(true);
setError(null); setError(null);
try { try {
const response = await fetch('/api/calendars', { const response = await appFetch('/api/calendars', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values), body: JSON.stringify(values),

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
/** Formular zum Erstellen eines Kalenders (nur Admin). */ /** Formular zum Erstellen eines Kalenders (nur Admin). */
import { appFetch } from '@/lib/client-fetch';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui'; import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
@@ -17,7 +18,7 @@ export function CreateCalendarForm() {
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
try { try {
const response = await fetch('/api/calendars', { const response = await appFetch('/api/calendars', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
/** Formular zum Bearbeiten/Deaktivieren eines Kalenders (Admin). */ /** Formular zum Bearbeiten/Deaktivieren eines Kalenders (Admin). */
import { appFetch } from '@/lib/client-fetch';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui'; import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
@@ -29,7 +30,7 @@ export function EditCalendarForm({
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
try { try {
const response = await fetch(`/api/calendars/${calendarId}`, { const response = await appFetch(`/api/calendars/${calendarId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -15,6 +15,7 @@
* Dashboard verwendet (DRY). Schliessen via X-Icon, ESC oder * Dashboard verwendet (DRY). Schliessen via X-Icon, ESC oder
* Hintergrund-Klick; waehrend des Speicherns ist Schliessen blockiert. * Hintergrund-Klick; waehrend des Speicherns ist Schliessen blockiert.
*/ */
import { appFetch } from '@/lib/client-fetch';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui'; import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui';
@@ -61,7 +62,7 @@ export function EditCalendarModal({
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
try { try {
const response = await fetch(`/api/calendars/${calendar.id}`, { const response = await appFetch(`/api/calendars/${calendar.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -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<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>
);
}

View File

@@ -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<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>
);
}

View File

@@ -1,16 +1,17 @@
'use client'; 'use client';
/** /**
* Mitgliederverwaltung fuer Modals (DB-basiertes Einladungssystem). * Mitgliederverwaltung fuer Kalender.
* *
* Statt Invite-Links werden bestehende Benutzer direkt per E-Mail * Vorhandene Benutzer werden direkt per E-Mail ueber die Datenbank
* ueber die Datenbank gesucht und dem Kalender zugeordnet: * gesucht und dem Kalender zugeordnet:
* - Suchfeld mit Live-Vorschlaegen (GET /api/users/search?q=...) * - Suchfeld mit Live-Vorschlaegen (GET /api/users/search?q=...)
* - Hinzufuegen per Klick (POST /api/calendars/[id]/members) * - Hinzufuegen per Klick (POST /api/calendars/[id]/members)
* - Mitgliederliste mit Entfernen-Button (DELETE ...?memberId=...) * - Mitgliederliste mit Entfernen-Button (DELETE ...?memberId=...)
* *
* Nur fuer Admins gerendert; der Server prueft die Berechtigung final. * Nur fuer Admins gerendert; der Server prueft die Berechtigung final.
*/ */
import { appFetch } from '@/lib/client-fetch';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { ErrorMessage } from '@/components/ui/ui'; import { ErrorMessage } from '@/components/ui/ui';
@@ -44,7 +45,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch(`/api/calendars/${calendarId}/members`); const response = await appFetch(`/api/calendars/${calendarId}/members`);
if (!response.ok) { if (!response.ok) {
const data = (await response.json().catch(() => ({}))) as { error?: string }; const data = (await response.json().catch(() => ({}))) as { error?: string };
setError(data.error ?? 'Mitglieder konnten nicht geladen werden.'); setError(data.error ?? 'Mitglieder konnten nicht geladen werden.');
@@ -75,7 +76,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
setAddingEmail(email); setAddingEmail(email);
setError(null); setError(null);
try { try {
const response = await fetch(`/api/calendars/${calendarId}/members`, { const response = await appFetch(`/api/calendars/${calendarId}/members`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }), body: JSON.stringify({ email }),
@@ -100,7 +101,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
setRemovingId(memberId); setRemovingId(memberId);
setError(null); setError(null);
try { try {
const response = await fetch( const response = await appFetch(
`/api/calendars/${calendarId}/members?memberId=${encodeURIComponent(memberId)}`, `/api/calendars/${calendarId}/members?memberId=${encodeURIComponent(memberId)}`,
{ method: 'DELETE' }, { method: 'DELETE' },
); );
@@ -130,7 +131,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
searchTimer.current = setTimeout(async () => { searchTimer.current = setTimeout(async () => {
setIsSearching(true); setIsSearching(true);
try { try {
const response = await fetch( const response = await appFetch(
`/api/users/search?q=${encodeURIComponent(nextQuery.trim())}`, `/api/users/search?q=${encodeURIComponent(nextQuery.trim())}`,
); );
if (response.ok) { if (response.ok) {

View File

@@ -6,6 +6,7 @@
* Fehler werden inline angezeigt. Der eigene Account ist geschuetzt * Fehler werden inline angezeigt. Der eigene Account ist geschuetzt
* (Server lehnt ab). * (Server lehnt ab).
*/ */
import { appFetch } from '@/lib/client-fetch';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Button, Card, ErrorMessage, Badge } from '@/components/ui/ui'; import { Button, Card, ErrorMessage, Badge } from '@/components/ui/ui';
import { ModalShell } from '@/components/ui/ModalShell'; import { ModalShell } from '@/components/ui/ModalShell';
@@ -32,7 +33,7 @@ export function UserManagement({ currentUserId }: { currentUserId: string }) {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch('/api/users', { cache: 'no-store' }); const response = await appFetch('/api/users', { cache: 'no-store' });
if (!response.ok) { if (!response.ok) {
const data = (await response.json().catch(() => null)) as const data = (await response.json().catch(() => null)) as
| { error?: string } | { error?: string }
@@ -65,7 +66,7 @@ export function UserManagement({ currentUserId }: { currentUserId: string }) {
setPendingId(userId); setPendingId(userId);
setError(null); setError(null);
try { try {
const response = await fetch(`/api/users/${userId}`, { const response = await appFetch(`/api/users/${userId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: nextRole }), body: JSON.stringify({ role: nextRole }),

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
/** Logout-Button: beendet die Session per API und laedt neu. */ /** Logout-Button: beendet die Session per API und laedt neu. */
import { appFetch } from '@/lib/client-fetch';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/ui'; import { Button } from '@/components/ui/ui';
@@ -8,7 +9,7 @@ export function LogoutButton() {
const router = useRouter(); const router = useRouter();
async function logout() { async function logout() {
await fetch('/api/auth/logout', { method: 'POST' }); await appFetch('/api/auth/logout', { method: 'POST' });
router.push('/login'); router.push('/login');
router.refresh(); router.refresh();
} }

View File

@@ -12,6 +12,7 @@
* - Loeschen ausschliesslich durch Admins (Server prueft final) * - Loeschen ausschliesslich durch Admins (Server prueft final)
* - Legende: eigene vs. fremde Reservierungen * - Legende: eigene vs. fremde Reservierungen
*/ */
import { appFetch } from '@/lib/client-fetch';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { ModalShell } from '@/components/ui/ModalShell'; import { ModalShell } from '@/components/ui/ModalShell';
import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui'; import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui';
@@ -189,7 +190,7 @@ export function CalendarModal({
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch( const response = await appFetch(
`/api/calendars/${calendarId}/reservations?from=${encodeURIComponent(window.from)}&to=${encodeURIComponent(window.to)}`, `/api/calendars/${calendarId}/reservations?from=${encodeURIComponent(window.from)}&to=${encodeURIComponent(window.to)}`,
); );
if (!response.ok) { if (!response.ok) {
@@ -231,7 +232,7 @@ export function CalendarModal({
} }
try { try {
const response = await fetch(`/api/calendars/${calendarId}/reservations`, { const response = await appFetch(`/api/calendars/${calendarId}/reservations`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -261,7 +262,7 @@ export function CalendarModal({
setDeletingId(reservationId); setDeletingId(reservationId);
setError(null); setError(null);
try { try {
const response = await fetch(`/api/reservations/${reservationId}`, { const response = await appFetch(`/api/reservations/${reservationId}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -11,6 +11,7 @@
* Der Zustand wird in localStorage persistiert, damit die Wahl * Der Zustand wird in localStorage persistiert, damit die Wahl
* ueber Seitenwechsel hinweg erhalten bleibt. * ueber Seitenwechsel hinweg erhalten bleibt.
*/ */
import { appFetch } from '@/lib/client-fetch';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
@@ -115,7 +116,7 @@ export function Sidebar({
async function logout() { async function logout() {
setIsLoggingOut(true); setIsLoggingOut(true);
try { try {
await fetch('/api/auth/logout', { method: 'POST' }); await appFetch('/api/auth/logout', { method: 'POST' });
router.push('/login'); router.push('/login');
router.refresh(); router.refresh();
} finally { } finally {
@@ -140,7 +141,7 @@ export function Sidebar({
Kalendartool Kalendartool
</span> </span>
)} )}
<button {process.env.NEXT_PUBLIC_MPM_AUTH_MODE !== 'true' && <button
type="button" type="button"
onClick={toggleCollapsed} onClick={toggleCollapsed}
aria-expanded={!isCollapsed} aria-expanded={!isCollapsed}
@@ -148,7 +149,7 @@ export function Sidebar({
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" 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 ? '»' : '«'} {isCollapsed ? '»' : '«'}
</button> </button>}
</div> </div>
<nav className="flex-1 space-y-1 overflow-y-auto p-2"> <nav className="flex-1 space-y-1 overflow-y-auto p-2">

View File

@@ -11,6 +11,7 @@
* Wird aus der Sidebar ueber das Profil-Badge geoeffnet; nach dem * Wird aus der Sidebar ueber das Profil-Badge geoeffnet; nach dem
* Speichern aktualisiert router.refresh() die Sidebar sofort. * Speichern aktualisiert router.refresh() die Sidebar sofort.
*/ */
import { appFetch } from '@/lib/client-fetch';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Button, ErrorMessage, Input, Label, Badge } from '@/components/ui/ui'; import { Button, ErrorMessage, Input, Label, Badge } from '@/components/ui/ui';
@@ -35,7 +36,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch('/api/me/profile', { cache: 'no-store' }); const response = await appFetch('/api/me/profile', { cache: 'no-store' });
if (!response.ok) { if (!response.ok) {
throw new Error('Profil konnte nicht geladen werden.'); throw new Error('Profil konnte nicht geladen werden.');
} }
@@ -66,7 +67,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
const username = String(formData.get('username') ?? '').trim(); const username = String(formData.get('username') ?? '').trim();
try { try {
const response = await fetch('/api/me/profile', { const response = await appFetch('/api/me/profile', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username || null }), body: JSON.stringify({ username: username || null }),

View File

@@ -10,6 +10,7 @@
* geprueft und der Benutzer bei Ueberschreitung informiert. * geprueft und der Benutzer bei Ueberschreitung informiert.
* Der Server prueft Kollisionen; 409 wird verstaendlich angezeigt. * Der Server prueft Kollisionen; 409 wird verstaendlich angezeigt.
*/ */
import { appFetch } from '@/lib/client-fetch';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui'; import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
@@ -133,7 +134,7 @@ export function NewReservationButton({
} }
try { try {
const response = await fetch(`/api/calendars/${calendarId}/reservations`, { const response = await appFetch(`/api/calendars/${calendarId}/reservations`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({

67
compose.yml Normal file
View File

@@ -0,0 +1,67 @@
services:
db:
build:
context: .
dockerfile: Dockerfile.postgres
restart: unless-stopped
environment:
POSTGRES_USER: calendar_admin
POSTGRES_PASSWORD: ${CALENDAR_ADMIN_PASSWORD:?Set CALENDAR_ADMIN_PASSWORD in module configuration}
POSTGRES_DB: calendar
CALENDAR_DB_PASSWORD: ${CALENDAR_DB_PASSWORD:?Set CALENDAR_DB_PASSWORD in module configuration}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U calendar_admin -d calendar"]
interval: 5s
timeout: 5s
retries: 10
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_admin
POSTGRES_PASSWORD: ${CALENDAR_ADMIN_PASSWORD:?Set CALENDAR_ADMIN_PASSWORD in module configuration}
SCHEDULE: "@daily"
BACKUP_KEEP_DAYS: 7
BACKUP_KEEP_WEEKS: 4
BACKUP_KEEP_MONTHS: 3
POSTGRES_EXTRA_OPTS: "-Z6"
TZ: Europe/Berlin
volumes:
- pgbackups:/backups
app:
build:
context: .
args:
HUB_BASE_PATH: /kalendartool
MPM_AUTH_MODE: "true"
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
MPM_AUTH_MODE: "true"
HUB_BASE_PATH: /kalendartool
CALENDAR_DB_PASSWORD: ${CALENDAR_DB_PASSWORD:?Set CALENDAR_DB_PASSWORD in module configuration}
ALLOW_OPEN_REGISTRATION: "false"
NODE_ENV: production
expose:
- "41030"
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:41030/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
interval: 10s
timeout: 3s
retries: 10
start_period: 45s
volumes:
pgdata: {}
pgbackups: {}

24
container-entrypoint.cjs Normal file
View File

@@ -0,0 +1,24 @@
'use strict';
const { spawnSync } = require('node:child_process');
const databaseUrl = new URL('postgresql://db:5432/calendar');
databaseUrl.username = 'calendar_app';
databaseUrl.password = process.env.CALENDAR_DB_PASSWORD || '';
databaseUrl.searchParams.set('schema', 'public');
process.env.DATABASE_URL = databaseUrl.toString();
function runNode(args) {
const result = spawnSync(process.execPath, args, { stdio: 'inherit', env: process.env });
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
runNode(['node_modules/prisma/build/index.js', 'migrate', 'deploy']);
runNode(['prisma/seed-admin.cjs']);
if (process.env.MPM_AUTH_MODE === 'true') {
require('./module-runtime.js');
} else {
runNode(['server.js']);
}

View File

@@ -1,29 +1,24 @@
# Eigenständiger Betrieb außerhalb von MPM:
# docker compose -f docker-compose.yml up --build
services: services:
db: db:
image: postgres:16-alpine build:
context: .
dockerfile: Dockerfile.postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: calendar POSTGRES_USER: calendar_admin
POSTGRES_PASSWORD: calendar POSTGRES_PASSWORD: ${CALENDAR_ADMIN_PASSWORD:?CALENDAR_ADMIN_PASSWORD fehlt}
POSTGRES_DB: calendar POSTGRES_DB: calendar
# KEIN Port-Mapping: Die DB ist nur im internen Compose-Netz CALENDAR_DB_PASSWORD: ${CALENDAR_DB_PASSWORD:?CALENDAR_DB_PASSWORD fehlt}
# erreichbar (App verbindet ueber den Hostnamen "db"). Fuer
# lokalen Zugriff bei Bedarf: docker compose exec db psql ...
volumes: volumes:
- pgdata:/var/lib/postgresql/data - 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: healthcheck:
test: ["CMD-SHELL", "pg_isready -U calendar -d calendar"] test: ["CMD-SHELL", "pg_isready -U calendar_admin -d calendar"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 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: db-backup:
image: prodrigestivill/postgres-backup-local:16-alpine image: prodrigestivill/postgres-backup-local:16-alpine
restart: unless-stopped restart: unless-stopped
@@ -33,45 +28,38 @@ services:
environment: environment:
POSTGRES_HOST: db POSTGRES_HOST: db
POSTGRES_DB: calendar POSTGRES_DB: calendar
POSTGRES_USER: calendar POSTGRES_USER: calendar_admin
POSTGRES_PASSWORD: calendar POSTGRES_PASSWORD: ${CALENDAR_ADMIN_PASSWORD:?CALENDAR_ADMIN_PASSWORD fehlt}
# Taeglich um 02:00 Uhr (Cron).
SCHEDULE: "@daily" SCHEDULE: "@daily"
# Backups nach 7 Tagen automatisch loeschen.
BACKUP_KEEP_DAYS: 7 BACKUP_KEEP_DAYS: 7
# Zusaetzlich die letzten 4 Wochensicherungen behalten.
BACKUP_KEEP_WEEKS: 4 BACKUP_KEEP_WEEKS: 4
# Monats-Sicherungen der letzten 3 Monate behalten.
BACKUP_KEEP_MONTHS: 3 BACKUP_KEEP_MONTHS: 3
# Kompression aktivieren (platzsparend).
POSTGRES_EXTRA_OPTS: "-Z6" POSTGRES_EXTRA_OPTS: "-Z6"
TZ: Europe/Berlin TZ: Europe/Berlin
volumes: volumes:
- pgbackups:/backups - pgbackups:/backups
app: app:
build: . build:
context: .
args:
HUB_BASE_PATH: ${HUB_BASE_PATH:-}
MPM_AUTH_MODE: "false"
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgresql://calendar_app:calendar_app@db:5432/calendar?schema=public MPM_AUTH_MODE: "false"
# KEIN Fallback-Secret: Ohne gesetztes SESSION_SECRET failt die HUB_BASE_PATH: ${HUB_BASE_PATH:-}
# App hart beim Start (lib/config.ts), statt JWTs mit einem SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET fehlt}
# oeffentlich bekannten Secret zu signieren. CALENDAR_DB_PASSWORD: ${CALENDAR_DB_PASSWORD:?CALENDAR_DB_PASSWORD fehlt}
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} 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_EMAIL: ${SEED_ADMIN_EMAIL:-}
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-} SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-}
NODE_ENV: production NODE_ENV: production
ports: ports:
- "3000:3000" - "${APP_PORT:-3000}:3000"
volumes: volumes:
pgdata: pgdata:

View File

@@ -0,0 +1,16 @@
#!/bin/sh
set -eu
: "${CALENDAR_DB_PASSWORD:?CALENDAR_DB_PASSWORD must be configured}"
psql -v ON_ERROR_STOP=1 \
--username "$POSTGRES_USER" \
--dbname "$POSTGRES_DB" \
-v calendar_db_password="$CALENDAR_DB_PASSWORD" <<'SQL'
SELECT format('CREATE ROLE calendar_app LOGIN PASSWORD %L', :'calendar_db_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'calendar_app')
\gexec
GRANT CONNECT ON DATABASE calendar TO calendar_app;
GRANT USAGE, CREATE ON SCHEMA public TO calendar_app;
SQL

View File

@@ -1,26 +0,0 @@
-- 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;

View File

@@ -0,0 +1,45 @@
import { randomBytes } from 'node:crypto';
import { prisma } from '@/lib/db/client';
import type { AuthenticatedUser } from '@/lib/permissions/permissions';
import type { Role } from '@prisma/client';
interface MpmIdentity {
userId: string;
username: string;
displayName: string | null;
role: 'ADMIN' | 'USER';
}
/**
* MPM is the identity and role authority in module mode. The module's local
* user row supplies stable foreign keys for calendars and reservations.
*/
export async function provisionMpmUser(identity: MpmIdentity): Promise<AuthenticatedUser> {
const hubId = `mpm:${identity.userId}`;
const email = `mpm-${Buffer.from(identity.userId).toString('base64url')}@users.invalid`;
const user = await prisma.user.upsert({
where: { hubId },
create: {
hubId,
email,
username: identity.username,
passwordHash: randomBytes(48).toString('hex'),
role: identity.role as Role,
},
update: {
email,
username: identity.username,
role: identity.role as Role,
},
select: { id: true, email: true, username: true, role: true },
});
// Keep the external display name available as the profile label when MPM
// has no separate username. Username is the stable, unique MPM account name.
return {
id: user.id,
email: user.email,
username: identity.displayName || user.username,
role: user.role,
};
}

View File

@@ -5,8 +5,9 @@
* Kryptografie, keine Klartext-Sessions in der Datenbank. * Kryptografie, keine Klartext-Sessions in der Datenbank.
*/ */
import { SignJWT, jwtVerify } from 'jose'; import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers'; import { cookies, headers } from 'next/headers';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { provisionMpmUser } from '@/lib/auth/mpm-provisioning';
import type { AuthenticatedUser } from '@/lib/permissions/permissions'; import type { AuthenticatedUser } from '@/lib/permissions/permissions';
const SESSION_COOKIE_NAME = 'calendar_session'; const SESSION_COOKIE_NAME = 'calendar_session';
@@ -62,6 +63,18 @@ export async function clearSessionCookie(): Promise<void> {
* Wirft UnauthorizedError, wenn keine gueltige Session existiert. * Wirft UnauthorizedError, wenn keine gueltige Session existiert.
*/ */
export async function getAuthenticatedUser(): Promise<AuthenticatedUser> { export async function getAuthenticatedUser(): Promise<AuthenticatedUser> {
if (process.env.MPM_AUTH_MODE === 'true') {
const requestHeaders = await headers();
const userId = requestHeaders.get('x-kalendartool-mpm-user-id');
const username = requestHeaders.get('x-kalendartool-mpm-username');
const displayName = requestHeaders.get('x-kalendartool-mpm-display-name');
const role = requestHeaders.get('x-kalendartool-mpm-role');
if (!userId || !username || !role || !['ADMIN', 'USER'].includes(role)) {
throw new UnauthorizedError();
}
return provisionMpmUser({ userId, username, displayName, role: role as 'ADMIN' | 'USER' });
}
const cookieStore = await cookies(); const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (!token) { if (!token) {

8
lib/client-fetch.ts Normal file
View File

@@ -0,0 +1,8 @@
/** Fetch wrapper for absolute app paths when Next.js basePath is enabled. */
export function appFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
if (typeof input !== 'string' || !input.startsWith('/') || input.startsWith('//')) {
return fetch(input, init);
}
const basePath = process.env.NEXT_PUBLIC_APP_BASE_PATH ?? '';
return fetch(`${basePath}${input}`, init);
}

View File

@@ -32,8 +32,7 @@ let cachedConfig: AppConfig | undefined;
export interface AppConfig { export interface AppConfig {
sessionSecret: string; sessionSecret: string;
appUrl: string; /** Wenn false, sind neue lokale Registrierungen deaktiviert. */
/** Wenn false, ist die Registrierung nur ueber Invite-Links moeglich. */
allowOpenRegistration: boolean; allowOpenRegistration: boolean;
/** MultiToolApp-Plattform: Basis-URL (JWKS-Abruf). null = SSO deaktiviert. */ /** MultiToolApp-Plattform: Basis-URL (JWKS-Abruf). null = SSO deaktiviert. */
hubUrl: string | null; hubUrl: string | null;
@@ -49,7 +48,6 @@ export function getConfig(): AppConfig {
} }
cachedConfig = { cachedConfig = {
sessionSecret: requireSessionSecret(), sessionSecret: requireSessionSecret(),
appUrl: process.env.APP_URL?.trim() || 'http://localhost:3000',
allowOpenRegistration: allowOpenRegistration:
(process.env.ALLOW_OPEN_REGISTRATION ?? 'true').toLowerCase() === 'true', (process.env.ALLOW_OPEN_REGISTRATION ?? 'true').toLowerCase() === 'true',
hubUrl: process.env.HUB_URL?.trim() || null, hubUrl: process.env.HUB_URL?.trim() || null,

View File

@@ -4,7 +4,7 @@
* *
* Postgres bricht bei Parallelitaets-Anomalien unter SERIALIZABLE eine * Postgres bricht bei Parallelitaets-Anomalien unter SERIALIZABLE eine
* der beteiligten Transaktionen ab. Wiederholbare Operationen (z. B. * der beteiligten Transaktionen ab. Wiederholbare Operationen (z. B.
* Registrierung mit Invite-Verbrauch, Rollen-Aenderung) werden dann * Registrierung und Rollen-Aenderung) werden dann
* einfach erneut ausgefuehrt - der Konflikt loest sich auf, weil die * einfach erneut ausgefuehrt - der Konflikt loest sich auf, weil die
* andere Transaktion inzwischen committet hat. * andere Transaktion inzwischen committet hat.
*/ */

View File

@@ -1,45 +0,0 @@
/**
* 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;
}

View File

@@ -56,7 +56,7 @@ export function canViewCalendar(
} }
/** /**
* Darf der User den Kalender verwalten (bearbeiten, Invites erstellen, * Darf der User den Kalender verwalten (bearbeiten,
* loeschen)? Admins duerfen alle Kalender verwalten (auch die anderer * loeschen)? Admins duerfen alle Kalender verwalten (auch die anderer
* Benutzer), normale User keinen. * Benutzer), normale User keinen.
*/ */

View File

@@ -46,7 +46,6 @@ export const registerSchema = z
email: emailSchema, email: emailSchema,
password: passwordSchema, password: passwordSchema,
username: usernameSchema.optional(), username: usernameSchema.optional(),
inviteToken: z.string().trim().min(10).max(200).optional(),
}) })
.strict(); .strict();
@@ -117,16 +116,8 @@ export const updateReservationSchema = z
}) })
.strict(); .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 * Vorhandenen Benutzer per E-Mail zu einem Kalender hinzufuegen.
* Einladungssystem, ersetzt den Token-Link-Flow in der Verwaltung).
*/ */
export const addMemberSchema = z export const addMemberSchema = z
.object({ .object({

View File

@@ -45,6 +45,27 @@ function isPublicPath(pathname: string): boolean {
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (process.env.MPM_AUTH_MODE === 'true') {
const userId = request.headers.get('x-kalendartool-mpm-user-id');
const role = request.headers.get('x-kalendartool-mpm-role');
if (userId && role && ['ADMIN', 'USER'].includes(role)) {
const basePath = (process.env.HUB_BASE_PATH ?? '').replace(/\/$/, '');
const localPath = basePath && pathname.startsWith(basePath)
? pathname.slice(basePath.length) || '/'
: pathname;
if (['/login', '/register', '/forgot-password', '/auth/hub'].includes(localPath)) {
const dashboardUrl = request.nextUrl.clone();
dashboardUrl.pathname = `${basePath}/dashboard`;
return NextResponse.redirect(dashboardUrl);
}
if (localPath.startsWith('/api/auth/')) {
return NextResponse.json({ statusCode: 404, message: 'Nicht gefunden' }, { status: 404 });
}
return NextResponse.next();
}
return NextResponse.json({ statusCode: 401, message: 'Nicht authentifiziert' }, { status: 401 });
}
if (isPublicPath(pathname)) { if (isPublicPath(pathname)) {
return NextResponse.next(); return NextResponse.next();
} }

102
module-runtime.js Normal file
View File

@@ -0,0 +1,102 @@
'use strict';
const http = require('node:http');
const { spawn } = require('node:child_process');
const path = require('node:path');
const { createLogger, createModuleServer, loadManifest } = require('./platform-module-sdk.cjs');
const manifest = loadManifest(path.join(__dirname, 'module.json'));
const logger = createLogger({ moduleId: manifest.id });
const basePath = process.env.HUB_BASE_PATH || `/${manifest.slug}`;
const nextPort = 3001;
let shuttingDown = false;
const nextServer = spawn(process.execPath, ['server.js'], {
cwd: __dirname,
env: { ...process.env, HOSTNAME: '127.0.0.1', PORT: String(nextPort) },
stdio: 'inherit',
});
nextServer.once('exit', (code) => {
if (shuttingDown) return;
logger.error('Next.js-Prozess wurde beendet', { code });
process.exit(code || 1);
});
const server = createModuleServer({
manifest,
logger,
identityRequired: true,
routes(request, response, context) {
const targetPath = `${basePath}${context.url.pathname}${context.url.search}`;
const headers = { ...request.headers, host: `127.0.0.1:${nextPort}` };
for (const name of Object.keys(headers)) {
if (name.startsWith('x-kalendartool-mpm-')) delete headers[name];
}
headers['x-kalendartool-mpm-user-id'] = context.identity.userId;
headers['x-kalendartool-mpm-username'] = context.identity.username;
headers['x-kalendartool-mpm-display-name'] = context.identity.displayName || context.identity.username;
headers['x-kalendartool-mpm-role'] = context.identity.role;
const upstream = http.request({
host: '127.0.0.1',
port: nextPort,
method: request.method,
path: targetPath,
headers,
}, (upstreamResponse) => {
response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
upstreamResponse.pipe(response);
});
upstream.on('error', () => {
if (!response.headersSent) {
response.writeHead(502, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ statusCode: 502, message: 'Kalendertool ist noch nicht bereit' }));
} else {
response.end();
}
});
request.pipe(upstream);
},
});
function waitForNext(attempt = 0) {
const probe = http.get({
host: '127.0.0.1',
port: nextPort,
path: `${basePath}/api/manifest`,
headers: {
'x-kalendartool-mpm-user-id': 'readiness-probe',
'x-kalendartool-mpm-username': 'readiness-probe',
'x-kalendartool-mpm-display-name': 'readiness-probe',
'x-kalendartool-mpm-role': 'USER',
},
}, (response) => {
response.resume();
if (response.statusCode === 200) {
const port = Number(process.env.PORT || manifest.port);
server.listen(port, '0.0.0.0', () => logger.info('MPM-Modulserver gestartet', { port }));
return;
}
retry(attempt);
});
probe.on('error', () => retry(attempt));
}
function retry(attempt) {
if (attempt >= 119) {
logger.error('Next.js wurde nicht rechtzeitig bereit');
process.exit(1);
}
setTimeout(() => waitForNext(attempt + 1), 250);
}
waitForNext();
function shutdown() {
shuttingDown = true;
if (server.listening) server.close();
nextServer.kill('SIGTERM');
}
process.once('SIGTERM', shutdown);
process.once('SIGINT', shutdown);

35
module.json Normal file
View File

@@ -0,0 +1,35 @@
{
"id": "kalendartool",
"name": "Kalendartool",
"version": "1.1.0",
"slug": "kalendartool",
"description": "Kalender- und Reservierungstool mit MPM-Anmeldung und PostgreSQL.",
"author": "lxe",
"runtime": "node",
"entrypoint": "module-runtime.js",
"port": 41030,
"healthcheck": "/health",
"apiVersion": "v1",
"composeFile": "compose.yml",
"appService": "app",
"configuration": [
{
"key": "CALENDAR_ADMIN_PASSWORD",
"label": "Datenbank-Administrationspasswort",
"description": "Zufälliges Passwort für den PostgreSQL-Administrationsbenutzer.",
"type": "text",
"secret": true,
"required": true,
"services": ["db", "db-backup"]
},
{
"key": "CALENDAR_DB_PASSWORD",
"label": "Datenbank-App-Passwort",
"description": "Zufälliges Passwort für den eingeschränkten Kalendertool-Datenbankbenutzer.",
"type": "text",
"secret": true,
"required": true,
"services": ["db", "app"]
}
]
}

View File

@@ -15,6 +15,12 @@ const nextConfig = {
// Proxy-Modus: App läuft unter /apps/<slug> innerhalb der Hub-Domain. // Proxy-Modus: App läuft unter /apps/<slug> innerhalb der Hub-Domain.
// basePath '' (falsy) = Standalone-Betrieb ohne Pfad-Präfix. // basePath '' (falsy) = Standalone-Betrieb ohne Pfad-Präfix.
...(hubBasePath ? { basePath: hubBasePath } : {}), ...(hubBasePath ? { basePath: hubBasePath } : {}),
// Client-side fetch() calls use absolute API paths and do not inherit
// Next.js' basePath automatically. Bake the prefix into browser bundles.
env: {
NEXT_PUBLIC_APP_BASE_PATH: hubBasePath,
NEXT_PUBLIC_MPM_AUTH_MODE: process.env.MPM_AUTH_MODE ?? 'false',
},
// Manifest-Route: /.well-known/app-manifest → /api/manifest // Manifest-Route: /.well-known/app-manifest → /api/manifest
// (Next.js App Router routed keine Dot-Ordner; mit beforeFiles-Phase // (Next.js App Router routed keine Dot-Ordner; mit beforeFiles-Phase
// fangen wir den Pfad ab, bevor basePath zuschlägt.) // fangen wir den Pfad ab, bevor basePath zuschlägt.)

381
platform-module-sdk.cjs Normal file
View File

@@ -0,0 +1,381 @@
'use strict';
/**
* MPM Modul-SDK (Phase 8)
* =======================
* Standardbibliothek für MPM-Module. Ein Modul ist ein eigenständiger
* Node-Prozess innerhalb des Management-Containers, der ausschließlich
* über den Modul-Gateway der Plattform erreichbar ist.
*
* Das SDK stellt bereit:
* - Authentication : Identität aus den sicheren Gateway-Headern
* - Current User : /api/me-Antwort nach Vertrag
* - Permissions : Modulrechte in der /api/me-Antwort
* - API Client : HTTP-Client für Plattform-Aufrufe (intern)
* - Module Config : Manifest-Laden und Laufzeit-Konfiguration
* - Logging : Strukturiertes Logging ohne Secrets
* - Healthcheck : /health- und /api/manifest-Endpunkte nach Vertrag
* - createModuleServer : HTTP-Server, der den gesamten Vertrag verdrahtet
*
* Sicherheitsregeln:
* - Module lauschen nur auf 127.0.0.1 (nie öffentlich erreichbar)
* - Der Gateway entfernt das Session-Cookie vor dem Proxy
* - Identitäts-Header werden ausschließlich vom Gateway gesetzt
* - Logs enthalten niemals Passwörter, Tokens oder personenbezogene Daten
*/
const http = require('node:http');
const fs = require('node:fs');
const { createHmac, timingSafeEqual } = require('node:crypto');
/** Identitäts-Header, die der Modul-Gateway setzt. */
const GATEWAY_HEADERS = Object.freeze({
userId: 'x-user-id',
username: 'x-user-username',
displayName: 'x-user-display-name',
role: 'x-user-role',
});
/** Erlaubte Plattform-Rollen. */
const PLATFORM_ROLES = Object.freeze(['ADMIN', 'USER']);
/** Schlüssel, die beim Logging geschwärzt werden. */
const SENSITIVE_KEYS = Object.freeze([
'password',
'passwort',
'token',
'secret',
'authorization',
'cookie',
'apikey',
'api_key',
]);
/** Liest einen Header case-insensitive (erster Wert). */
function readHeader(headers, name) {
const lowerName = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === lowerName) {
return Array.isArray(value) ? value[0] ?? null : value ?? null;
}
}
return null;
}
/**
* Extrahiert die Benutzer-Identität aus den Gateway-Headern.
* @returns {object|null} Identität oder null, wenn der Request nicht
* über den Gateway kam (z. B. direkter Aufruf ohne Plattform).
*/
function extractIdentity(headers) {
const userId = readHeader(headers, GATEWAY_HEADERS.userId);
const username = readHeader(headers, GATEWAY_HEADERS.username);
const displayName = readHeader(headers, GATEWAY_HEADERS.displayName);
const role = readHeader(headers, GATEWAY_HEADERS.role);
if (!userId || !username || !role) {
return null;
}
if (!PLATFORM_ROLES.includes(role)) {
return null;
}
return { userId, username, displayName: displayName ?? username, role };
}
/** Validate the gateway's signature, bound to this module and exact HTTP request. */
function extractVerifiedIdentity(headers, request, moduleId, identityKey) {
const identity = extractIdentity(headers);
const timestamp = readHeader(headers, 'x-mpm-identity-timestamp');
const supplied = readHeader(headers, 'x-mpm-identity-signature');
if (!identity || !timestamp || !supplied || !identityKey) return null;
const timestampNumber = Number(timestamp);
if (!Number.isSafeInteger(timestampNumber) || Math.abs(Date.now() - timestampNumber) > 30_000) {
return null;
}
const fields = [moduleId, request.method, request.url ?? '/', timestamp,
identity.userId, identity.username, identity.displayName, identity.role];
const expected = createHmac('sha256', identityKey).update(JSON.stringify(fields)).digest();
let actual;
try {
actual = Buffer.from(supplied, 'hex');
} catch {
return null;
}
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
return identity;
}
/** Erstellt die /health-Antwort nach Vertrag. */
function healthResponse(moduleId, version) {
return { moduleId, version, status: 'healthy' };
}
/** Erstellt die /api/manifest-Antwort nach Vertrag. */
function manifestResponse(manifest) {
return {
moduleId: manifest.id,
name: manifest.name,
version: manifest.version,
slug: manifest.slug,
apiVersion: manifest.apiVersion,
status: 'healthy',
};
}
/** Erstellt die /api/me-Antwort nach Vertrag. */
function meResponse(identity, moduleId, permissions = []) {
return {
user: {
id: identity.userId,
username: identity.username,
displayName: identity.displayName,
platformRole: identity.role,
},
module: { id: moduleId },
permissions,
};
}
/**
* Lädt und validiert ein Modul-Manifest (module.json).
* @param {string} manifestPath Absoluter Pfad zur module.json
* @returns {object} Validiertes, eingefrorenes Manifest
* @throws {Error} Bei fehlender/ungültiger Datei
*/
function loadManifest(manifestPath) {
let raw;
try {
raw = fs.readFileSync(manifestPath, 'utf8');
} catch (error) {
throw new Error(`Modul-Manifest nicht lesbar: ${manifestPath} (${error.message})`);
}
let manifest;
try {
manifest = JSON.parse(raw);
} catch (error) {
throw new Error(`Modul-Manifest ist kein gültiges JSON: ${error.message}`);
}
const requiredFields = ['id', 'name', 'version', 'slug', 'port', 'healthcheck'];
const missing = requiredFields.filter((field) => manifest[field] === undefined);
if (missing.length > 0) {
throw new Error(`Modul-Manifest unvollständig, fehlt: ${missing.join(', ')}`);
}
if (manifest.runtime !== 'node') {
throw new Error(`Nicht unterstützte Runtime: ${manifest.runtime} (erwartet: node)`);
}
if (manifest.apiVersion !== 'v1') {
throw new Error(`Nicht unterstützte apiVersion: ${manifest.apiVersion} (erwartet: v1)`);
}
return Object.freeze({ ...manifest });
}
/**
* Lädt die Laufzeit-Konfiguration eines Moduls.
* Der Prozess-Manager setzt PORT; PLATFORM_INTERNAL_URL ist optional.
*/
function loadModuleConfig(env = process.env) {
return Object.freeze({
port: Number(env.PORT ?? 0),
nodeEnv: env.NODE_ENV ?? 'production',
platformBaseUrl: env.PLATFORM_INTERNAL_URL ?? 'http://127.0.0.1:3000',
serviceToken: env.MODULE_SERVICE_TOKEN ?? null,
});
}
/** Schwärzt sensible Schlüssel in Log-Daten (rekursiv, flach begrenzt). */
function redactSensitive(data) {
if (data === null || typeof data !== 'object') {
return data;
}
const redacted = Array.isArray(data) ? [] : {};
for (const [key, value] of Object.entries(data)) {
if (SENSITIVE_KEYS.includes(key.toLowerCase())) {
redacted[key] = '[geschwärzt]';
} else if (value !== null && typeof value === 'object') {
redacted[key] = redactSensitive(value);
} else {
redacted[key] = value;
}
}
return redacted;
}
/**
* Erstellt einen strukturierten Logger (JSON-Zeilen auf stdout/stderr).
* Logs helfen beim Debuggen und enthalten niemals Secrets.
*/
function createLogger(options = {}) {
const moduleId = options.moduleId ?? 'unbekannt';
const minimumLevel = options.level ?? (options.debug ? 'debug' : 'info');
const levels = { debug: 10, info: 20, warn: 30, error: 40 };
function write(level, message, data) {
if (levels[level] < levels[minimumLevel]) {
return;
}
const entry = {
ts: new Date().toISOString(),
level,
moduleId,
message,
...(data !== undefined ? { data: redactSensitive(data) } : {}),
};
const line = JSON.stringify(entry);
if (level === 'error' || level === 'warn') {
console.error(line);
} else {
console.log(line);
}
}
return {
debug: (message, data) => write('debug', message, data),
info: (message, data) => write('info', message, data),
warn: (message, data) => write('warn', message, data),
error: (message, data) => write('error', message, data),
};
}
/**
* HTTP-Client für Aufrufe vom Modul zur Plattform (internes Netz).
* Der Plattform-Service-Token wird per Umgebungsvariable übergeben,
* sobald die Plattform Service-Endpunkte bereitstellt.
*/
function createPlatformClient(options = {}) {
const config = loadModuleConfig();
const baseUrl = options.baseUrl ?? config.platformBaseUrl;
const serviceToken = options.serviceToken ?? config.serviceToken;
const timeoutMs = options.timeoutMs ?? 5_000;
async function request(method, requestPath, body) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${baseUrl}${requestPath}`, {
method,
headers: {
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...(serviceToken ? { Authorization: `Bearer ${serviceToken}` } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Plattform-Aufruf fehlgeschlagen: ${method} ${requestPath} → HTTP ${response.status}`);
}
return await response.json();
} finally {
clearTimeout(timeout);
}
}
return {
get: (requestPath) => request('GET', requestPath),
post: (requestPath, body) => request('POST', requestPath, body),
patch: (requestPath, body) => request('PATCH', requestPath, body),
delete: (requestPath) => request('DELETE', requestPath),
};
}
/**
* Erstellt einen HTTP-Server, der den vollständigen Modul-API-Vertrag
* verdrahtet:
* GET /health → Liveness/Readiness
* GET /api/manifest → Manifest zur Laufzeit
* GET /api/me → Aktueller Benutzer (vom Gateway übergeben)
* alles andere → options.routes(req, res, context)
*
* @param {object} options
* manifest – validiertes Manifest (loadManifest)
* routes – (req, res, context) für fachliche Endpunkte
* permissions – Modulrechte für /api/me (Default: [])
* logger – Logger-Instanz (Default: createLogger)
* identityRequired – 401 für fachliche Endpunkte ohne Identität (Default: true)
* @returns {http.Server} Noch nicht gestarteter Server
*/
function createModuleServer(options) {
const manifest = options.manifest;
const routes = options.routes ?? null;
const permissions = options.permissions ?? [];
const logger = options.logger ?? createLogger({ moduleId: manifest.id });
const identityRequired = options.identityRequired ?? true;
const identityKey = options.identityKey ?? process.env.MPM_MODULE_IDENTITY_KEY ?? null;
const requireSignedIdentity = options.requireSignedIdentity ?? process.env.NODE_ENV === 'production';
function requestIdentity(request) {
if (identityKey) {
return extractVerifiedIdentity(request.headers, request, manifest.id, identityKey);
}
return requireSignedIdentity ? null : extractIdentity(request.headers);
}
function sendJson(response, status, body) {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(body));
}
function handleRequest(request, response) {
const url = new URL(request.url ?? '/', `http://127.0.0.1:${manifest.port}`);
const pathname = url.pathname;
if (request.method === 'GET' && pathname === '/health') {
sendJson(response, 200, healthResponse(manifest.id, manifest.version));
return;
}
if (request.method === 'GET' && pathname === '/api/manifest') {
sendJson(response, 200, manifestResponse(manifest));
return;
}
if (request.method === 'GET' && pathname === '/api/me') {
const identity = requestIdentity(request);
if (!identity) {
sendJson(response, 401, { statusCode: 401, message: 'Keine Identität übergeben' });
return;
}
sendJson(response, 200, meResponse(identity, manifest.id, permissions));
return;
}
const identity = requestIdentity(request);
if (identityRequired && routes && !identity) {
sendJson(response, 401, { statusCode: 401, message: 'Keine Identität übergeben' });
return;
}
if (routes) {
try {
routes(request, response, { identity, path: pathname, url, logger });
} catch (error) {
logger.error('Fehler im Routen-Handler', { path: pathname, error: error.message });
if (!response.headersSent) {
sendJson(response, 500, { statusCode: 500, message: 'Interner Modulfehler' });
}
}
return;
}
sendJson(response, 404, { statusCode: 404, message: 'Nicht gefunden' });
}
return http.createServer(handleRequest);
}
module.exports = {
GATEWAY_HEADERS,
PLATFORM_ROLES,
extractIdentity,
extractVerifiedIdentity,
healthResponse,
manifestResponse,
meResponse,
loadManifest,
loadModuleConfig,
createLogger,
createPlatformClient,
createModuleServer,
};

View File

@@ -26,6 +26,10 @@ async function seedAdmin() {
const password = (process.argv[3] || process.env.SEED_ADMIN_PASSWORD || '').trim(); const password = (process.argv[3] || process.env.SEED_ADMIN_PASSWORD || '').trim();
if (!email || !password) { if (!email || !password) {
if (!email && !password) {
console.log('[seed] Keine Admin-Startdaten konfiguriert; überspringe Seed.');
return;
}
console.error( console.error(
'[seed] Verwendung: node prisma/seed-admin.cjs <email> <passwort>\n' + '[seed] Verwendung: node prisma/seed-admin.cjs <email> <passwort>\n' +
' oder: SEED_ADMIN_EMAIL=… SEED_ADMIN_PASSWORD=… node prisma/seed-admin.cjs', ' oder: SEED_ADMIN_EMAIL=… SEED_ADMIN_PASSWORD=… node prisma/seed-admin.cjs',

View File

@@ -1,13 +1,12 @@
/** /**
* Unit-Tests fuer die Kernlogik (plan.md Abschnitt 6 und 19): * Unit-Tests fuer die Kernlogik (plan.md Abschnitt 6 und 19):
* Kollisionspruefung, Invite-Tokens, Validierung, Berechtigungen. * Kollisionspruefung, Validierung und Berechtigungen.
* *
* Bewusst ohne Datenbank: Die Regeln sind reine Funktionen und * Bewusst ohne Datenbank: Die Regeln sind reine Funktionen und
* damit ohne PostgreSQL testbar (schnelle Unit-Tests). * damit ohne PostgreSQL testbar (schnelle Unit-Tests).
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { intervalsOverlap, findConflicts, validateReservationPeriod } from '@/lib/reservations/conflicts'; import { intervalsOverlap, findConflicts, validateReservationPeriod } from '@/lib/reservations/conflicts';
import { generateInviteToken, hashInviteToken, isInviteUsable, defaultInviteExpiry } from '@/lib/invites/tokens';
import { createReservationSchema } from '@/lib/validation'; import { createReservationSchema } from '@/lib/validation';
import { formatDuration } from '@/lib/reservations/service'; import { formatDuration } from '@/lib/reservations/service';
import { import {
@@ -136,43 +135,6 @@ describe('validateReservationPeriod', () => {
}); });
}); });
// ------------------------------------------------------------------- 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 // ------------------------------------------------------------------ Validierung
describe('createReservationSchema', () => { describe('createReservationSchema', () => {