Prepare Kalendartool for MPM and disable invites
This commit is contained in:
@@ -5,3 +5,4 @@ node_modules
|
||||
.env*.local
|
||||
*.md
|
||||
tests
|
||||
.env*
|
||||
|
||||
29
.env.example
29
.env.example
@@ -1,22 +1,19 @@
|
||||
# PostgreSQL-Verbindung (in docker-compose.yml automatisch korrekt gesetzt)
|
||||
DATABASE_URL="postgresql://calendar_app:calendar_app@localhost:5432/calendar?schema=public"
|
||||
# Nur für den eigenständigen Betrieb außerhalb von MPM.
|
||||
# In MPM werden dieselben Werte sicher über den Konfigurationsdialog gesetzt.
|
||||
|
||||
# Session-Signierung – mindestens 32 Zeichen. Erzeugen z. B. mit:
|
||||
# node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
||||
# WICHTIG: Ohne gesetztes SESSION_SECRET startet die App NICHT mehr
|
||||
# (docker-compose.yml hat keinen unsicheren Fallback mehr).
|
||||
# Zufällige alphanumerische Passwörter verwenden (mindestens 32 Zeichen).
|
||||
CALENDAR_ADMIN_PASSWORD="change-this-database-admin-password"
|
||||
CALENDAR_DB_PASSWORD="change-this-calendar-database-password"
|
||||
|
||||
# Mindestens 32 Zeichen, z. B. mit `openssl rand -base64 48` erzeugen.
|
||||
SESSION_SECRET="change-me-change-me-change-me-change-me"
|
||||
|
||||
# Basis-URL der Anwendung (fuer Invite-Links)
|
||||
APP_URL="http://localhost:3000"
|
||||
# Nur für lokale Entwicklung ohne Compose; Passwort URL-kodieren.
|
||||
# DATABASE_URL="postgresql://calendar_app:PASSWORT@127.0.0.1:5432/calendar?schema=public"
|
||||
|
||||
# "false" = Registrierung nur ueber Invite-Links moeglich
|
||||
# Standalone-Optionen
|
||||
APP_PORT="3000"
|
||||
ALLOW_OPEN_REGISTRATION="true"
|
||||
|
||||
# Optional: Admin-Account beim ersten Start automatisch anlegen.
|
||||
# Nicht-destruktiv (wie bei Gitea/Vaultwarden): Das Seed-Script legt
|
||||
# den Admin nur an, wenn noch keiner existiert. Bestehende User
|
||||
# werden niemals geloescht. Wenn die Variablen nicht gesetzt sind,
|
||||
# laeuft das Script ohne Nebeneffekte durch.
|
||||
# HUB_BASE_PATH="/kalendartool"
|
||||
# SEED_ADMIN_EMAIL="admin@example.com"
|
||||
# SEED_ADMIN_PASSWORD="SicheresPasswort10"
|
||||
# SEED_ADMIN_PASSWORD="change-this-admin-password"
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@@ -5,6 +5,11 @@
|
||||
# zurueck und der Client kann nicht initialisiert werden.
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
ARG HUB_BASE_PATH=""
|
||||
ARG MPM_AUTH_MODE="false"
|
||||
ENV HUB_BASE_PATH=${HUB_BASE_PATH}
|
||||
ENV MPM_AUTH_MODE=${MPM_AUTH_MODE}
|
||||
ENV NEXT_PUBLIC_APP_BASE_PATH=${HUB_BASE_PATH}
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
# ci statt install: reproduzierbarer Build aus dem Lockfile
|
||||
@@ -50,16 +55,13 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modul
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||
# bcryptjs fuer das Admin-Seed-Script (manuell ausfuehrbar, nicht beim Start)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/bcryptjs ./node_modules/bcryptjs
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/container-entrypoint.cjs ./container-entrypoint.cjs
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/module-runtime.js ./module-runtime.js
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/platform-module-sdk.cjs ./platform-module-sdk.cjs
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
EXPOSE 3000 41030
|
||||
|
||||
# Migrationen beim Start anwenden, optional Admin seeden, dann Server starten.
|
||||
# prisma migrate deploy ist idempotent und sicher fuer wiederholte Starts.
|
||||
# Das Seed-Script ist NICHT-destruktiv: Es legt den Admin nur an, wenn
|
||||
# noch keiner existiert (First-Run-Detection). Bestehende User werden
|
||||
# niemals geloescht. Wenn SEED_ADMIN_* nicht gesetzt sind, laeuft es
|
||||
# ohne Nebeneffekte durch.
|
||||
# Direkter node-Aufruf statt npx: im Standalone-Output existiert
|
||||
# node_modules/.bin nicht, daher wuerde "npx prisma" mit "not found" fehlschlagen.
|
||||
CMD ["sh", "-c", "node node_modules/prisma/build/index.js migrate deploy && node prisma/seed-admin.cjs && node server.js"]
|
||||
# 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
3
Dockerfile.postgres
Normal 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
|
||||
48
README.md
48
README.md
@@ -1,13 +1,13 @@
|
||||
# Kalendartool
|
||||
|
||||
Einfaches Kalender- und Reservierungstool nach `plan.md`: Ein Admin legt Kalender an und lädt Benutzer per Invite-Link ein. Jeder User sieht **ausschließlich seinen einen Kalender** und kann dort Reservierungen erstellen.
|
||||
Einfaches Kalender- und Reservierungstool: Ein Admin legt Kalender an und ordnet vorhandene Benutzer direkt zu. Jeder User sieht **ausschließlich seinen einen Kalender** und kann dort Reservierungen erstellen.
|
||||
|
||||
## Kernfunktionen (MVP)
|
||||
|
||||
- **Auth**: Registrierung, Login, Logout, Passwort vergessen – bcrypt-Hashing, signierte HttpOnly-Session-Cookies, Rate-Limiting beim Login
|
||||
- **Rollen**: `ADMIN` (0..n Kalender) und `USER` (genau 1 Kalender) – serverseitig erzwungen
|
||||
- **Kalender**: Titel, Beschreibung, Zeitzone, aktiv/inaktiv
|
||||
- **Invite-System**: nicht erratbare Tokens (32 Bytes), nur SHA-256-Hash in der DB, Ablaufdatum, Übersicht Aktiv/Abgelaufen/Verwendet
|
||||
- **Mitglieder**: Administratoren ordnen vorhandene Benutzer direkt einem Kalender zu
|
||||
- **Reservierungen**: Startzeit + Dauer (15-Minuten-Schritte), Titel, Notiz
|
||||
- **Doppelbuchungsschutz**: atomare Prüfung in serialisierbarer Transaktion (HTTP 409 bei Kollision)
|
||||
- **Ansichten**: Tag / Woche / Monat
|
||||
@@ -17,16 +17,40 @@ Einfaches Kalender- und Reservierungstool nach `plan.md`: Ein Admin legt Kalende
|
||||
|
||||
Next.js 14 (App Router, TypeScript) · PostgreSQL · Prisma · Tailwind CSS · Docker · Vitest
|
||||
|
||||
## Schnellstart mit Docker (empfohlen)
|
||||
## MPM Marketplace
|
||||
|
||||
Das Repository ist als MPM-Modul vorbereitet. `module.json` beschreibt das
|
||||
Modul und seine Konfigurationsfelder; `compose.yml` enthält App, PostgreSQL
|
||||
und tägliche Backups. Die Modul-App veröffentlicht keinen Host-Port. MPM
|
||||
erreicht sie intern über Port `41030` und die Health-Route `/health`.
|
||||
|
||||
Die Anmeldung und Rollen kommen im MPM-Betrieb signiert vom MPM-Gateway.
|
||||
Die lokale Cookie-Anmeldung bleibt für den eigenständigen Betrieb erhalten.
|
||||
Kalender und Reservierungen liegen in PostgreSQL. Die benannten Volumes
|
||||
`pgdata` und `pgbackups` bleiben beim Entfernen des Moduls erhalten.
|
||||
|
||||
Bei der ersten MPM-Konfiguration werden zwei getrennte, zufällige Passwörter
|
||||
für PostgreSQL-Administration und App-Zugriff benötigt. Die Anmeldung läuft
|
||||
über MPM; lokale Konten und Einladungslinks sind im MPM-Betrieb deaktiviert.
|
||||
|
||||
Benutzer und Modulzugriff werden in MPM verwaltet. Öffne das Modul zunächst
|
||||
einmal mit dem neuen Benutzer, damit sein lokales Profil angelegt wird; ein
|
||||
Kalenderadministrator kann ihn danach direkt einem Kalender zuordnen. Die
|
||||
Datenbank-Initialisierung wird nur bei einem leeren Volume ausgeführt. Ein
|
||||
späteres Ändern der Datenbankpasswörter im MPM-Konfigurationsdialog ändert die
|
||||
Zugangsdaten eines bestehenden PostgreSQL-Volumes nicht automatisch.
|
||||
|
||||
## Schnellstart mit Docker (eigenständiger Betrieb)
|
||||
|
||||
```bash
|
||||
# 1. Session-Secret erzeugen und .env anlegen
|
||||
cp .env.example .env
|
||||
# SESSION_SECRET in .env setzen (mindestens 32 Zeichen):
|
||||
# CALENDAR_ADMIN_PASSWORD, CALENDAR_DB_PASSWORD und SESSION_SECRET in .env
|
||||
# durch zufällige Werte ersetzen. SESSION_SECRET braucht mindestens 32 Zeichen:
|
||||
node -e "console.log('SESSION_SECRET=' + require('crypto').randomBytes(48).toString('base64url'))"
|
||||
|
||||
# 2. Stack starten (PostgreSQL + App)
|
||||
docker compose up --build
|
||||
docker compose -f docker-compose.yml up --build
|
||||
```
|
||||
|
||||
Die App läuft dann auf http://localhost:3000. Migrationen werden beim Containerstart automatisch angewendet (`prisma migrate deploy`).
|
||||
@@ -35,7 +59,7 @@ Die App läuft dann auf http://localhost:3000. Migrationen werden beim Container
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # DATABASE_URL und SESSION_SECRET anpassen
|
||||
cp .env.example .env # Passwörter und SESSION_SECRET anpassen
|
||||
npx prisma db push # Schema in die Datenbank schreiben
|
||||
npm run dev
|
||||
```
|
||||
@@ -46,30 +70,27 @@ Voraussetzung: laufendes PostgreSQL (z. B. nur der `db`-Service aus `docker-comp
|
||||
|
||||
1. Erste Registrierung unter `/register` → der **erste Benutzer wird automatisch ADMIN**
|
||||
2. Im Dashboard einen Kalender anlegen
|
||||
3. Unter „Benutzer einladen" einen Invite-Link erstellen und verschicken
|
||||
4. Eingeladene Personen registrieren sich über den Link und sehen sofort nur diesen einen Kalender
|
||||
3. Im Kalender unter „Mitglieder" vorhandene Benutzer zuordnen
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
app/ Next.js App Router (Seiten + API-Routen)
|
||||
api/auth/ register, login, logout, forgot-password
|
||||
api/calendars/ CRUD + Reservierungen + Invites
|
||||
api/calendars/ CRUD + Reservierungen + Mitglieder
|
||||
api/reservations/ Bearbeiten/Löschen (atomar)
|
||||
dashboard/ User: 1 Kalender · Admin: alle Kalender
|
||||
calendar/[id]/ Ansicht Tag/Woche/Monat
|
||||
invite/[token]/ Invite-Annahme
|
||||
admin/ Kalenderverwaltung
|
||||
components/ UI-, Kalender-, Reservierungs-, Admin-Komponenten
|
||||
lib/
|
||||
auth/ Session (JWT-Cookies), Passwort-Hashing
|
||||
permissions/ zentrale Berechtigungsschicht
|
||||
reservations/ Konfliktlogik + atomarer Service
|
||||
invites/ Token-Erzeugung/Hashing
|
||||
api/ Response-Helfer, Kontext-Lader
|
||||
validation.ts Zod-Schemas
|
||||
prisma/schema.prisma Datenmodell
|
||||
tests/ Unit-Tests (Kollisionen, Invites, Rechte)
|
||||
tests/ Unit-Tests (Kollisionen, Validierung, Rechte)
|
||||
```
|
||||
|
||||
## Tests
|
||||
@@ -78,7 +99,7 @@ tests/ Unit-Tests (Kollisionen, Invites, Rechte)
|
||||
npm test
|
||||
```
|
||||
|
||||
Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus plan.md Abschnitt 6), Invite-Token-Hashing/-Ablauf, Eingabevalidierung und die komplette Berechtigungsmatrix.
|
||||
Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus plan.md Abschnitt 6), Eingabevalidierung und die komplette Berechtigungsmatrix.
|
||||
|
||||
## Sicherheitskonzept (Kurzfassung)
|
||||
|
||||
@@ -86,7 +107,6 @@ Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus
|
||||
| --- | --- |
|
||||
| Doppelbuchungen | `SERIALIZABLE`-Transaktion in `lib/reservations/service.ts` |
|
||||
| Berechtigungen | zentrale Funktionen in `lib/permissions/permissions.ts`, geprüft in **jeder** API-Route |
|
||||
| Invite-Tokens | 32 Byte Zufall, nur SHA-256-Hash in der DB |
|
||||
| Passwörter | bcrypt (12 Runden), nie im Klartext |
|
||||
| Sessions | HS256-signierte JWTs in HttpOnly/SameSite-Cookies |
|
||||
| Login-Missbrauch | Rate-Limiting (5 Versuche / 15 min) |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,16 @@ 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" } }
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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') }),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
67
compose.yml
Normal 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
24
container-entrypoint.cjs
Normal 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']);
|
||||
}
|
||||
@@ -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,45 +28,38 @@ 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:
|
||||
|
||||
16
docker/postgres/init/01-app-user.sh
Normal file
16
docker/postgres/init/01-app-user.sh
Normal 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
|
||||
@@ -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;
|
||||
45
lib/auth/mpm-provisioning.ts
Normal file
45
lib/auth/mpm-provisioning.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
8
lib/client-fetch.ts
Normal file
8
lib/client-fetch.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
102
module-runtime.js
Normal file
102
module-runtime.js
Normal 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
35
module.json
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.)
|
||||
|
||||
381
platform-module-sdk.cjs
Normal file
381
platform-module-sdk.cjs
Normal 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,
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user