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

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

View File

@@ -1,22 +1,19 @@
# PostgreSQL-Verbindung (in docker-compose.yml automatisch korrekt gesetzt)
DATABASE_URL="postgresql://calendar_app:calendar_app@localhost:5432/calendar?schema=public"
# Nur für den eigenständigen Betrieb außerhalb von MPM.
# In MPM werden dieselben Werte sicher über den Konfigurationsdialog gesetzt.
# Session-Signierung – mindestens 32 Zeichen. Erzeugen z. B. mit:
# node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
# WICHTIG: Ohne gesetztes SESSION_SECRET startet die App NICHT mehr
# (docker-compose.yml hat keinen unsicheren Fallback mehr).
# Zufällige alphanumerische Passwörter verwenden (mindestens 32 Zeichen).
CALENDAR_ADMIN_PASSWORD="change-this-database-admin-password"
CALENDAR_DB_PASSWORD="change-this-calendar-database-password"
# Mindestens 32 Zeichen, z. B. mit `openssl rand -base64 48` erzeugen.
SESSION_SECRET="change-me-change-me-change-me-change-me"
# Basis-URL der Anwendung (fuer Invite-Links)
APP_URL="http://localhost:3000"
# Nur für lokale Entwicklung ohne Compose; Passwort URL-kodieren.
# DATABASE_URL="postgresql://calendar_app:PASSWORT@127.0.0.1:5432/calendar?schema=public"
# "false" = Registrierung nur ueber Invite-Links moeglich
# Standalone-Optionen
APP_PORT="3000"
ALLOW_OPEN_REGISTRATION="true"
# Optional: Admin-Account beim ersten Start automatisch anlegen.
# Nicht-destruktiv (wie bei Gitea/Vaultwarden): Das Seed-Script legt
# den Admin nur an, wenn noch keiner existiert. Bestehende User
# werden niemals geloescht. Wenn die Variablen nicht gesetzt sind,
# laeuft das Script ohne Nebeneffekte durch.
# HUB_BASE_PATH="/kalendartool"
# SEED_ADMIN_EMAIL="admin@example.com"
# SEED_ADMIN_PASSWORD="SicheresPasswort10"
# SEED_ADMIN_PASSWORD="change-this-admin-password"

View File

@@ -5,6 +5,11 @@
# zurueck und der Client kann nicht initialisiert werden.
FROM node:20-bookworm-slim AS builder
WORKDIR /app
ARG HUB_BASE_PATH=""
ARG MPM_AUTH_MODE="false"
ENV HUB_BASE_PATH=${HUB_BASE_PATH}
ENV MPM_AUTH_MODE=${MPM_AUTH_MODE}
ENV NEXT_PUBLIC_APP_BASE_PATH=${HUB_BASE_PATH}
COPY package.json package-lock.json ./
# ci statt install: reproduzierbarer Build aus dem Lockfile
@@ -50,16 +55,13 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modul
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
# bcryptjs fuer das Admin-Seed-Script (manuell ausfuehrbar, nicht beim Start)
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/bcryptjs ./node_modules/bcryptjs
COPY --from=builder --chown=nextjs:nodejs /app/container-entrypoint.cjs ./container-entrypoint.cjs
COPY --from=builder --chown=nextjs:nodejs /app/module-runtime.js ./module-runtime.js
COPY --from=builder --chown=nextjs:nodejs /app/platform-module-sdk.cjs ./platform-module-sdk.cjs
USER nextjs
EXPOSE 3000
EXPOSE 3000 41030
# Migrationen beim Start anwenden, optional Admin seeden, dann Server starten.
# prisma migrate deploy ist idempotent und sicher fuer wiederholte Starts.
# Das Seed-Script ist NICHT-destruktiv: Es legt den Admin nur an, wenn
# noch keiner existiert (First-Run-Detection). Bestehende User werden
# niemals geloescht. Wenn SEED_ADMIN_* nicht gesetzt sind, laeuft es
# ohne Nebeneffekte durch.
# Direkter node-Aufruf statt npx: im Standalone-Output existiert
# node_modules/.bin nicht, daher wuerde "npx prisma" mit "not found" fehlschlagen.
CMD ["sh", "-c", "node node_modules/prisma/build/index.js migrate deploy && node prisma/seed-admin.cjs && node server.js"]
# Der gemeinsame Entrypoint setzt die Datenbankverbindung, wendet
# Migrationen an und startet anschließend Standalone- oder MPM-Betrieb.
CMD ["node", "container-entrypoint.cjs"]

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
Einfaches Kalender- und Reservierungstool nach `plan.md`: Ein Admin legt Kalender an und lädt Benutzer per Invite-Link ein. Jeder User sieht **ausschließlich seinen einen Kalender** und kann dort Reservierungen erstellen.
Einfaches Kalender- und Reservierungstool: Ein Admin legt Kalender an und ordnet vorhandene Benutzer direkt zu. Jeder User sieht **ausschließlich seinen einen Kalender** und kann dort Reservierungen erstellen.
## Kernfunktionen (MVP)
- **Auth**: Registrierung, Login, Logout, Passwort vergessen – bcrypt-Hashing, signierte HttpOnly-Session-Cookies, Rate-Limiting beim Login
- **Rollen**: `ADMIN` (0..n Kalender) und `USER` (genau 1 Kalender) – serverseitig erzwungen
- **Kalender**: Titel, Beschreibung, Zeitzone, aktiv/inaktiv
- **Invite-System**: nicht erratbare Tokens (32 Bytes), nur SHA-256-Hash in der DB, Ablaufdatum, Übersicht Aktiv/Abgelaufen/Verwendet
- **Mitglieder**: Administratoren ordnen vorhandene Benutzer direkt einem Kalender zu
- **Reservierungen**: Startzeit + Dauer (15-Minuten-Schritte), Titel, Notiz
- **Doppelbuchungsschutz**: atomare Prüfung in serialisierbarer Transaktion (HTTP 409 bei Kollision)
- **Ansichten**: Tag / Woche / Monat
@@ -17,16 +17,40 @@ Einfaches Kalender- und Reservierungstool nach `plan.md`: Ein Admin legt Kalende
Next.js 14 (App Router, TypeScript) · PostgreSQL · Prisma · Tailwind CSS · Docker · Vitest
## Schnellstart mit Docker (empfohlen)
## MPM Marketplace
Das Repository ist als MPM-Modul vorbereitet. `module.json` beschreibt das
Modul und seine Konfigurationsfelder; `compose.yml` enthält App, PostgreSQL
und tägliche Backups. Die Modul-App veröffentlicht keinen Host-Port. MPM
erreicht sie intern über Port `41030` und die Health-Route `/health`.
Die Anmeldung und Rollen kommen im MPM-Betrieb signiert vom MPM-Gateway.
Die lokale Cookie-Anmeldung bleibt für den eigenständigen Betrieb erhalten.
Kalender und Reservierungen liegen in PostgreSQL. Die benannten Volumes
`pgdata` und `pgbackups` bleiben beim Entfernen des Moduls erhalten.
Bei der ersten MPM-Konfiguration werden zwei getrennte, zufällige Passwörter
für PostgreSQL-Administration und App-Zugriff benötigt. Die Anmeldung läuft
über MPM; lokale Konten und Einladungslinks sind im MPM-Betrieb deaktiviert.
Benutzer und Modulzugriff werden in MPM verwaltet. Öffne das Modul zunächst
einmal mit dem neuen Benutzer, damit sein lokales Profil angelegt wird; ein
Kalenderadministrator kann ihn danach direkt einem Kalender zuordnen. Die
Datenbank-Initialisierung wird nur bei einem leeren Volume ausgeführt. Ein
späteres Ändern der Datenbankpasswörter im MPM-Konfigurationsdialog ändert die
Zugangsdaten eines bestehenden PostgreSQL-Volumes nicht automatisch.
## Schnellstart mit Docker (eigenständiger Betrieb)
```bash
# 1. Session-Secret erzeugen und .env anlegen
cp .env.example .env
# SESSION_SECRET in .env setzen (mindestens 32 Zeichen):
# CALENDAR_ADMIN_PASSWORD, CALENDAR_DB_PASSWORD und SESSION_SECRET in .env
# durch zufällige Werte ersetzen. SESSION_SECRET braucht mindestens 32 Zeichen:
node -e "console.log('SESSION_SECRET=' + require('crypto').randomBytes(48).toString('base64url'))"
# 2. Stack starten (PostgreSQL + App)
docker compose up --build
docker compose -f docker-compose.yml up --build
```
Die App läuft dann auf http://localhost:3000. Migrationen werden beim Containerstart automatisch angewendet (`prisma migrate deploy`).
@@ -35,7 +59,7 @@ Die App läuft dann auf http://localhost:3000. Migrationen werden beim Container
```bash
npm install
cp .env.example .env # DATABASE_URL und SESSION_SECRET anpassen
cp .env.example .env # Passwörter und SESSION_SECRET anpassen
npx prisma db push # Schema in die Datenbank schreiben
npm run dev
```
@@ -46,30 +70,27 @@ Voraussetzung: laufendes PostgreSQL (z. B. nur der `db`-Service aus `docker-comp
1. Erste Registrierung unter `/register` → der **erste Benutzer wird automatisch ADMIN**
2. Im Dashboard einen Kalender anlegen
3. Unter „Benutzer einladen" einen Invite-Link erstellen und verschicken
4. Eingeladene Personen registrieren sich über den Link und sehen sofort nur diesen einen Kalender
3. Im Kalender unter „Mitglieder" vorhandene Benutzer zuordnen
## Projektstruktur
```
app/ Next.js App Router (Seiten + API-Routen)
api/auth/ register, login, logout, forgot-password
api/calendars/ CRUD + Reservierungen + Invites
api/calendars/ CRUD + Reservierungen + Mitglieder
api/reservations/ Bearbeiten/Löschen (atomar)
dashboard/ User: 1 Kalender · Admin: alle Kalender
calendar/[id]/ Ansicht Tag/Woche/Monat
invite/[token]/ Invite-Annahme
admin/ Kalenderverwaltung
components/ UI-, Kalender-, Reservierungs-, Admin-Komponenten
lib/
auth/ Session (JWT-Cookies), Passwort-Hashing
permissions/ zentrale Berechtigungsschicht
reservations/ Konfliktlogik + atomarer Service
invites/ Token-Erzeugung/Hashing
api/ Response-Helfer, Kontext-Lader
validation.ts Zod-Schemas
prisma/schema.prisma Datenmodell
tests/ Unit-Tests (Kollisionen, Invites, Rechte)
tests/ Unit-Tests (Kollisionen, Validierung, Rechte)
```
## Tests
@@ -78,7 +99,7 @@ tests/ Unit-Tests (Kollisionen, Invites, Rechte)
npm test
```
Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus plan.md Abschnitt 6), Invite-Token-Hashing/-Ablauf, Eingabevalidierung und die komplette Berechtigungsmatrix.
Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus plan.md Abschnitt 6), Eingabevalidierung und die komplette Berechtigungsmatrix.
## Sicherheitskonzept (Kurzfassung)
@@ -86,7 +107,6 @@ Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus
| --- | --- |
| Doppelbuchungen | `SERIALIZABLE`-Transaktion in `lib/reservations/service.ts` |
| Berechtigungen | zentrale Funktionen in `lib/permissions/permissions.ts`, geprüft in **jeder** API-Route |
| Invite-Tokens | 32 Byte Zufall, nur SHA-256-Hash in der DB |
| Passwörter | bcrypt (12 Runden), nie im Klartext |
| Sessions | HS256-signierte JWTs in HttpOnly/SameSite-Cookies |
| Login-Missbrauch | Rate-Limiting (5 Versuche / 15 min) |
@@ -95,4 +115,4 @@ Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus
## Bewusste MVP-Grenzen (plan.md Abschnitt 18)
Buchungszeitfenster-Regeln, E-Mail-Versand, ICS-Export, wiederkehrende Reservierungen und Statistiken sind bewusst **nicht** Teil von Version 1 – die Struktur (Service-Schicht, Regeln in `lib/`) ist darauf vorbereitet.
Buchungszeitfenster-Regeln, E-Mail-Versand, ICS-Export, wiederkehrende Reservierungen und Statistiken sind bewusst **nicht** Teil von Version 1 – die Struktur (Service-Schicht, Regeln in `lib/`) ist darauf vorbereitet.

View File

@@ -1,6 +1,6 @@
/**
* Admin: Kalenderverwaltung (plan.md Abschnitt 13: /admin/calendars/[id]).
* Bearbeiten, deaktivieren, Invite-Links erstellen, Benutzer einsehen.
* Bearbeiten, deaktivieren und Mitglieder verwalten.
*/
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/db/client';
@@ -9,7 +9,7 @@ import { canManageCalendar } from '@/lib/permissions/permissions';
import { displayName } from '@/lib/privacy/display';
import { Card } from '@/components/ui/ui';
import { EditCalendarForm } from '@/components/admin/EditCalendarForm';
import { InviteManager } from '@/components/admin/InviteManager';
import { MemberSection } from '@/components/admin/MemberSection';
import { AppShell } from '@/components/layout/AppShell';
interface PageProps {
@@ -26,29 +26,18 @@ export default async function AdminCalendarPage({ params }: PageProps) {
redirect('/dashboard');
}
const [members, invites, reservations] = await Promise.all([
prisma.calendarMember.findMany({
where: { calendarId: id },
select: { user: { select: { email: true, username: true, createdAt: true } } },
}),
prisma.invite.findMany({
where: { calendarId: id },
orderBy: { createdAt: 'desc' },
select: { id: true, expiresAt: true, usedAt: true, createdAt: true },
}),
prisma.reservation.findMany({
where: { calendarId: id, status: 'CONFIRMED' },
orderBy: { startAt: 'desc' },
take: 50,
select: {
id: true,
title: true,
startAt: true,
endAt: true,
user: { select: { email: true, username: true } },
},
}),
]);
const reservations = await prisma.reservation.findMany({
where: { calendarId: id, status: 'CONFIRMED' },
orderBy: { startAt: 'desc' },
take: 50,
select: {
id: true,
title: true,
startAt: true,
endAt: true,
user: { select: { email: true, username: true } },
},
});
return (
<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}
/>
<InviteManager calendarId={id} invites={invites} />
<Card>
<h2 className="mb-3 text-lg font-semibold">Mitglieder ({members.length})</h2>
{members.length === 0 ? (
<p className="text-sm text-slate-500">
Noch keine Mitglieder. Erstelle einen Invite-Link.
</p>
) : (
<ul className="space-y-1 text-sm">
{members.map((member) => (
<li key={member.user.email}>
{displayName(member.user.username, member.user.email)}
{member.user.username && (
<span className="ml-2 text-xs text-slate-500">
{member.user.email}
</span>
)}
</li>
))}
</ul>
)}
<MemberSection calendarId={id} />
</Card>
<Card>
@@ -112,4 +81,4 @@ export default async function AdminCalendarPage({ params }: PageProps) {
</Card>
</AppShell>
);
}
}

View File

@@ -2,7 +2,7 @@
* Admin: Kalenderverwaltung (Cards-Ansicht).
* Zeigt alle Kalender als Karten - eigene und die anderer Benutzer.
* Verwaltung direkt im Bearbeiten-Modal oder auf der Detailseite
* /admin/calendars/[id] (Invites, Mitglieder, Reservierungen).
* /admin/calendars/[id] (Mitglieder, Reservierungen).
*/
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/db/client';
@@ -55,4 +55,4 @@ export default async function ManageCalendarsPage() {
/>
</AppShell>
);
}
}

View File

@@ -1,17 +1,15 @@
/**
* POST /api/auth/register
*
* Registrierung (plan.md Abschnitt 11). Zwei Wege:
* 1. Mit Invite-Token -> User wird dem Kalender des Invites zugeordnet.
* 2. Ohne Token -> nur wenn ALLOW_OPEN_REGISTRATION=true; der erste
* Lokale Registrierung für den eigenständigen Betrieb. Sie ist nur
* verfügbar, wenn ALLOW_OPEN_REGISTRATION=true; der erste
* Benutzer wird ADMIN, weitere offene Registrierungen werden
* dann automatisch zu USER ohne Kalender (oder abgelehnt, je
* nach Konfiguration).
*
* Sicherheit:
* - Rate-Limit pro IP (Brute-Force-/Enumeration-Schutz).
* - SERIALIZABLE-Transaktion: Ein Invite kann trotz paralleler
* Registrierungen nur EINMAL verwendet werden; der "erste Benutzer
* - SERIALIZABLE-Transaktion: Der "erste Benutzer
* wird ADMIN"-Wettlauf kann nicht mehrere Admins erzeugen.
* - Bei bereits registrierter E-Mail wird bewusst mit 400 und einer
* generischen Meldung geantwortet (kein 409), damit die Antwort
@@ -22,7 +20,6 @@ import { prisma } from '@/lib/db/client';
import { hashPassword } from '@/lib/auth/password';
import { createSessionToken, setSessionCookie } from '@/lib/auth/session';
import { registerSchema } from '@/lib/validation';
import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens';
import { getConfig } from '@/lib/config';
import { errorResponse } from '@/lib/api/responses';
import {
@@ -65,39 +62,9 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
const passwordHash = await hashPassword(body.password);
const createdUser = await withSerializableTx(async (tx) => {
let calendarId: string | null = null;
if (body.inviteToken) {
const tokenHash = hashInviteToken(body.inviteToken);
const invite = await tx.invite.findUnique({
where: { tokenHash },
select: { id: true, calendarId: true, expiresAt: true, usedAt: true },
});
if (!invite || !isInviteUsable(invite)) {
throw new RegistrationError(
'Dieser Einladungslink ist ungueltig oder abgelaufen.',
400,
);
}
calendarId = invite.calendarId;
// Bedingungsloses Update: schlaegt fehl, wenn eine parallele
// Transaktion den Invite inzwischen verbraucht hat (SERIALIZABLE
// bricht eine der beiden ab; der Retry sieht dann usedAt und
// bricht hier sauber ab).
const updated = await tx.invite.updateMany({
where: { id: invite.id, usedAt: null },
data: { usedAt: new Date() },
});
if (updated.count === 0) {
throw new RegistrationError(
'Dieser Einladungslink ist ungueltig oder abgelaufen.',
400,
);
}
} else if (!config.allowOpenRegistration) {
if (!config.allowOpenRegistration) {
throw new RegistrationError(
'Registrierung ist nur ueber Einladungslinks moeglich.',
'Registrierung ist derzeit deaktiviert.',
403,
);
}
@@ -114,9 +81,6 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
passwordHash,
role,
...(body.username ? { username: body.username } : {}),
...(calendarId
? { memberships: { create: { calendarId } } }
: {}),
},
select: { id: true, email: true, role: true, username: true },
});
@@ -156,4 +120,4 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
}
return errorResponse(error);
}
}
}

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).
* DELETE /api/calendars/[id]/members - Mitglied entfernen (nur Admin).
*
* DB-basiertes Einladungssystem: Statt Invite-Links werden Benutzer
* direkt per E-Mail-Suche ueber die Datenbank zugeordnet. Der Server
* Benutzer werden direkt per E-Mail-Suche ueber die Datenbank
* zugeordnet. Der Server
* prueft die Verwaltungsberechtigung zentral (canManageCalendar).
*/
import { NextRequest, NextResponse } from 'next/server';
@@ -141,4 +141,4 @@ export async function DELETE(
} catch (error) {
return errorResponse(error);
}
}
}

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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:
db:
image: postgres:16-alpine
build:
context: .
dockerfile: Dockerfile.postgres
restart: unless-stopped
environment:
POSTGRES_USER: calendar
POSTGRES_PASSWORD: calendar
POSTGRES_USER: calendar_admin
POSTGRES_PASSWORD: ${CALENDAR_ADMIN_PASSWORD:?CALENDAR_ADMIN_PASSWORD fehlt}
POSTGRES_DB: calendar
# KEIN Port-Mapping: Die DB ist nur im internen Compose-Netz
# erreichbar (App verbindet ueber den Hostnamen "db"). Fuer
# lokalen Zugriff bei Bedarf: docker compose exec db psql ...
CALENDAR_DB_PASSWORD: ${CALENDAR_DB_PASSWORD:?CALENDAR_DB_PASSWORD fehlt}
volumes:
- pgdata:/var/lib/postgresql/data
# Legt den least-privilege-App-User an (nur bei initialer
# Volume-Initialisierung; siehe Script fuer Bestands-Volumes).
- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U calendar -d calendar"]
test: ["CMD-SHELL", "pg_isready -U calendar_admin -d calendar"]
interval: 5s
timeout: 5s
retries: 10
# Taeglicher DB-Backup-Service: erstellt um 02:00 Uhr ein
# pg_dump-Backup im Named Volume "pgbackups". Aeltere Backups werden
# automatisch nach 7 Tagen geloescht (Retention). Die Backups
# ueberleben Neustarts des Stacks, da sie im Volume liegen.
db-backup:
image: prodrigestivill/postgres-backup-local:16-alpine
restart: unless-stopped
@@ -33,46 +28,39 @@ services:
environment:
POSTGRES_HOST: db
POSTGRES_DB: calendar
POSTGRES_USER: calendar
POSTGRES_PASSWORD: calendar
# Taeglich um 02:00 Uhr (Cron).
POSTGRES_USER: calendar_admin
POSTGRES_PASSWORD: ${CALENDAR_ADMIN_PASSWORD:?CALENDAR_ADMIN_PASSWORD fehlt}
SCHEDULE: "@daily"
# Backups nach 7 Tagen automatisch loeschen.
BACKUP_KEEP_DAYS: 7
# Zusaetzlich die letzten 4 Wochensicherungen behalten.
BACKUP_KEEP_WEEKS: 4
# Monats-Sicherungen der letzten 3 Monate behalten.
BACKUP_KEEP_MONTHS: 3
# Kompression aktivieren (platzsparend).
POSTGRES_EXTRA_OPTS: "-Z6"
TZ: Europe/Berlin
volumes:
- pgbackups:/backups
app:
build: .
build:
context: .
args:
HUB_BASE_PATH: ${HUB_BASE_PATH:-}
MPM_AUTH_MODE: "false"
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql://calendar_app:calendar_app@db:5432/calendar?schema=public
# KEIN Fallback-Secret: Ohne gesetztes SESSION_SECRET failt die
# App hart beim Start (lib/config.ts), statt JWTs mit einem
# oeffentlich bekannten Secret zu signieren.
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET muss gesetzt sein (z. B. in .env, siehe .env.example)}
APP_URL: ${APP_URL:-http://localhost:3000}
MPM_AUTH_MODE: "false"
HUB_BASE_PATH: ${HUB_BASE_PATH:-}
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET fehlt}
CALENDAR_DB_PASSWORD: ${CALENDAR_DB_PASSWORD:?CALENDAR_DB_PASSWORD fehlt}
ALLOW_OPEN_REGISTRATION: ${ALLOW_OPEN_REGISTRATION:-true}
# Optional: Admin-Account beim ersten Start anlegen (First-Run).
# Nicht-destruktiv: Legt den Admin nur an, wenn noch keiner
# existiert. Bestehende User werden niemals geloescht.
# Wenn NICHT gesetzt, laeuft das Seed-Script ohne Nebeneffekte durch.
SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-}
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-}
NODE_ENV: production
ports:
- "3000:3000"
- "${APP_PORT:-3000}:3000"
volumes:
pgdata:
pgbackups:
pgbackups:

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.
*/
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
import { cookies, headers } from 'next/headers';
import { getConfig } from '@/lib/config';
import { provisionMpmUser } from '@/lib/auth/mpm-provisioning';
import type { AuthenticatedUser } from '@/lib/permissions/permissions';
const SESSION_COOKIE_NAME = 'calendar_session';
@@ -62,6 +63,18 @@ export async function clearSessionCookie(): Promise<void> {
* Wirft UnauthorizedError, wenn keine gueltige Session existiert.
*/
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 token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (!token) {
@@ -98,4 +111,4 @@ export async function tryGetAuthenticatedUser(): Promise<AuthenticatedUser | nul
} catch {
return null;
}
}
}

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

View File

@@ -4,7 +4,7 @@
*
* Postgres bricht bei Parallelitaets-Anomalien unter SERIALIZABLE eine
* 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
* andere Transaktion inzwischen committet hat.
*/
@@ -40,4 +40,4 @@ export async function withSerializableTx<T>(
// committet, ein erneuter Lauf sieht den aktuellen Stand.
}
}
}
}

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
* Benutzer), normale User keinen.
*/
@@ -138,4 +138,4 @@ export function canModifyReservation(
return true;
}
return reservation.userId === user.id;
}
}

View File

@@ -46,7 +46,6 @@ export const registerSchema = z
email: emailSchema,
password: passwordSchema,
username: usernameSchema.optional(),
inviteToken: z.string().trim().min(10).max(200).optional(),
})
.strict();
@@ -117,16 +116,8 @@ export const updateReservationSchema = z
})
.strict();
export const createInviteSchema = z
.object({
calendarId: z.string().trim().min(1),
expiresInDays: z.number().int().min(1).max(90).optional(),
})
.strict();
/**
* Mitglied per E-Mail zu einem Kalender hinzufuegen (DB-basiertes
* Einladungssystem, ersetzt den Token-Link-Flow in der Verwaltung).
* Vorhandenen Benutzer per E-Mail zu einem Kalender hinzufuegen.
*/
export const addMemberSchema = z
.object({
@@ -156,4 +147,4 @@ export const updateUsernameSchema = z
.object({
username: usernameSchema.nullable(),
})
.strict();
.strict();

View File

@@ -45,6 +45,27 @@ function isPublicPath(pathname: string): boolean {
export async function middleware(request: NextRequest) {
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)) {
return NextResponse.next();
}
@@ -74,4 +95,4 @@ export async function middleware(request: NextRequest) {
export const config = {
// Alles ausser statischen Dateien.
matcher: ['/((?!favicon.ico|.*\\.(?:png|jpg|jpeg|svg|ico|webp|txt)).*)'],
};
};

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.
// basePath '' (falsy) = Standalone-Betrieb ohne Pfad-Präfix.
...(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
// (Next.js App Router routed keine Dot-Ordner; mit beforeFiles-Phase
// fangen wir den Pfad ab, bevor basePath zuschlägt.)
@@ -70,4 +76,4 @@ const nextConfig = {
},
};
export default nextConfig;
export default nextConfig;

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();
if (!email || !password) {
if (!email && !password) {
console.log('[seed] Keine Admin-Startdaten konfiguriert; überspringe Seed.');
return;
}
console.error(
'[seed] Verwendung: node prisma/seed-admin.cjs <email> <passwort>\n' +
' oder: SEED_ADMIN_EMAIL=… SEED_ADMIN_PASSWORD=… node prisma/seed-admin.cjs',
@@ -75,4 +79,4 @@ seedAdmin()
})
.finally(async function () {
await prisma.$disconnect();
});
});

View File

@@ -1,13 +1,12 @@
/**
* 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
* damit ohne PostgreSQL testbar (schnelle Unit-Tests).
*/
import { describe, expect, it } from 'vitest';
import { intervalsOverlap, findConflicts, validateReservationPeriod } from '@/lib/reservations/conflicts';
import { generateInviteToken, hashInviteToken, isInviteUsable, defaultInviteExpiry } from '@/lib/invites/tokens';
import { createReservationSchema } from '@/lib/validation';
import { formatDuration } from '@/lib/reservations/service';
import {
@@ -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
describe('createReservationSchema', () => {