Prepare Kalendartool for MPM and disable invites
This commit is contained in:
@@ -4,4 +4,5 @@ node_modules
|
|||||||
.env
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
*.md
|
*.md
|
||||||
tests
|
tests
|
||||||
|
.env*
|
||||||
|
|||||||
29
.env.example
29
.env.example
@@ -1,22 +1,19 @@
|
|||||||
# PostgreSQL-Verbindung (in docker-compose.yml automatisch korrekt gesetzt)
|
# Nur für den eigenständigen Betrieb außerhalb von MPM.
|
||||||
DATABASE_URL="postgresql://calendar_app:calendar_app@localhost:5432/calendar?schema=public"
|
# In MPM werden dieselben Werte sicher über den Konfigurationsdialog gesetzt.
|
||||||
|
|
||||||
# Session-Signierung – mindestens 32 Zeichen. Erzeugen z. B. mit:
|
# Zufällige alphanumerische Passwörter verwenden (mindestens 32 Zeichen).
|
||||||
# node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
CALENDAR_ADMIN_PASSWORD="change-this-database-admin-password"
|
||||||
# WICHTIG: Ohne gesetztes SESSION_SECRET startet die App NICHT mehr
|
CALENDAR_DB_PASSWORD="change-this-calendar-database-password"
|
||||||
# (docker-compose.yml hat keinen unsicheren Fallback mehr).
|
|
||||||
|
# Mindestens 32 Zeichen, z. B. mit `openssl rand -base64 48` erzeugen.
|
||||||
SESSION_SECRET="change-me-change-me-change-me-change-me"
|
SESSION_SECRET="change-me-change-me-change-me-change-me"
|
||||||
|
|
||||||
# Basis-URL der Anwendung (fuer Invite-Links)
|
# Nur für lokale Entwicklung ohne Compose; Passwort URL-kodieren.
|
||||||
APP_URL="http://localhost:3000"
|
# DATABASE_URL="postgresql://calendar_app:PASSWORT@127.0.0.1:5432/calendar?schema=public"
|
||||||
|
|
||||||
# "false" = Registrierung nur ueber Invite-Links moeglich
|
# Standalone-Optionen
|
||||||
|
APP_PORT="3000"
|
||||||
ALLOW_OPEN_REGISTRATION="true"
|
ALLOW_OPEN_REGISTRATION="true"
|
||||||
|
# HUB_BASE_PATH="/kalendartool"
|
||||||
# Optional: Admin-Account beim ersten Start automatisch anlegen.
|
|
||||||
# Nicht-destruktiv (wie bei Gitea/Vaultwarden): Das Seed-Script legt
|
|
||||||
# den Admin nur an, wenn noch keiner existiert. Bestehende User
|
|
||||||
# werden niemals geloescht. Wenn die Variablen nicht gesetzt sind,
|
|
||||||
# laeuft das Script ohne Nebeneffekte durch.
|
|
||||||
# SEED_ADMIN_EMAIL="admin@example.com"
|
# SEED_ADMIN_EMAIL="admin@example.com"
|
||||||
# SEED_ADMIN_PASSWORD="SicheresPasswort10"
|
# SEED_ADMIN_PASSWORD="change-this-admin-password"
|
||||||
|
|||||||
22
Dockerfile
22
Dockerfile
@@ -5,6 +5,11 @@
|
|||||||
# zurueck und der Client kann nicht initialisiert werden.
|
# zurueck und der Client kann nicht initialisiert werden.
|
||||||
FROM node:20-bookworm-slim AS builder
|
FROM node:20-bookworm-slim AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
ARG HUB_BASE_PATH=""
|
||||||
|
ARG MPM_AUTH_MODE="false"
|
||||||
|
ENV HUB_BASE_PATH=${HUB_BASE_PATH}
|
||||||
|
ENV MPM_AUTH_MODE=${MPM_AUTH_MODE}
|
||||||
|
ENV NEXT_PUBLIC_APP_BASE_PATH=${HUB_BASE_PATH}
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
# ci statt install: reproduzierbarer Build aus dem Lockfile
|
# ci statt install: reproduzierbarer Build aus dem Lockfile
|
||||||
@@ -50,16 +55,13 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modul
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||||
# bcryptjs fuer das Admin-Seed-Script (manuell ausfuehrbar, nicht beim Start)
|
# bcryptjs fuer das Admin-Seed-Script (manuell ausfuehrbar, nicht beim Start)
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/bcryptjs ./node_modules/bcryptjs
|
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/bcryptjs ./node_modules/bcryptjs
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/container-entrypoint.cjs ./container-entrypoint.cjs
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/module-runtime.js ./module-runtime.js
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/platform-module-sdk.cjs ./platform-module-sdk.cjs
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000 41030
|
||||||
|
|
||||||
# Migrationen beim Start anwenden, optional Admin seeden, dann Server starten.
|
# Der gemeinsame Entrypoint setzt die Datenbankverbindung, wendet
|
||||||
# prisma migrate deploy ist idempotent und sicher fuer wiederholte Starts.
|
# Migrationen an und startet anschließend Standalone- oder MPM-Betrieb.
|
||||||
# Das Seed-Script ist NICHT-destruktiv: Es legt den Admin nur an, wenn
|
CMD ["node", "container-entrypoint.cjs"]
|
||||||
# noch keiner existiert (First-Run-Detection). Bestehende User werden
|
|
||||||
# niemals geloescht. Wenn SEED_ADMIN_* nicht gesetzt sind, laeuft es
|
|
||||||
# ohne Nebeneffekte durch.
|
|
||||||
# Direkter node-Aufruf statt npx: im Standalone-Output existiert
|
|
||||||
# node_modules/.bin nicht, daher wuerde "npx prisma" mit "not found" fehlschlagen.
|
|
||||||
CMD ["sh", "-c", "node node_modules/prisma/build/index.js migrate deploy && node prisma/seed-admin.cjs && node server.js"]
|
|
||||||
|
|||||||
3
Dockerfile.postgres
Normal file
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
|
||||||
50
README.md
50
README.md
@@ -1,13 +1,13 @@
|
|||||||
# Kalendartool
|
# Kalendartool
|
||||||
|
|
||||||
Einfaches Kalender- und Reservierungstool nach `plan.md`: Ein Admin legt Kalender an und lädt Benutzer per Invite-Link ein. Jeder User sieht **ausschließlich seinen einen Kalender** und kann dort Reservierungen erstellen.
|
Einfaches Kalender- und Reservierungstool: Ein Admin legt Kalender an und ordnet vorhandene Benutzer direkt zu. Jeder User sieht **ausschließlich seinen einen Kalender** und kann dort Reservierungen erstellen.
|
||||||
|
|
||||||
## Kernfunktionen (MVP)
|
## Kernfunktionen (MVP)
|
||||||
|
|
||||||
- **Auth**: Registrierung, Login, Logout, Passwort vergessen – bcrypt-Hashing, signierte HttpOnly-Session-Cookies, Rate-Limiting beim Login
|
- **Auth**: Registrierung, Login, Logout, Passwort vergessen – bcrypt-Hashing, signierte HttpOnly-Session-Cookies, Rate-Limiting beim Login
|
||||||
- **Rollen**: `ADMIN` (0..n Kalender) und `USER` (genau 1 Kalender) – serverseitig erzwungen
|
- **Rollen**: `ADMIN` (0..n Kalender) und `USER` (genau 1 Kalender) – serverseitig erzwungen
|
||||||
- **Kalender**: Titel, Beschreibung, Zeitzone, aktiv/inaktiv
|
- **Kalender**: Titel, Beschreibung, Zeitzone, aktiv/inaktiv
|
||||||
- **Invite-System**: nicht erratbare Tokens (32 Bytes), nur SHA-256-Hash in der DB, Ablaufdatum, Übersicht Aktiv/Abgelaufen/Verwendet
|
- **Mitglieder**: Administratoren ordnen vorhandene Benutzer direkt einem Kalender zu
|
||||||
- **Reservierungen**: Startzeit + Dauer (15-Minuten-Schritte), Titel, Notiz
|
- **Reservierungen**: Startzeit + Dauer (15-Minuten-Schritte), Titel, Notiz
|
||||||
- **Doppelbuchungsschutz**: atomare Prüfung in serialisierbarer Transaktion (HTTP 409 bei Kollision)
|
- **Doppelbuchungsschutz**: atomare Prüfung in serialisierbarer Transaktion (HTTP 409 bei Kollision)
|
||||||
- **Ansichten**: Tag / Woche / Monat
|
- **Ansichten**: Tag / Woche / Monat
|
||||||
@@ -17,16 +17,40 @@ Einfaches Kalender- und Reservierungstool nach `plan.md`: Ein Admin legt Kalende
|
|||||||
|
|
||||||
Next.js 14 (App Router, TypeScript) · PostgreSQL · Prisma · Tailwind CSS · Docker · Vitest
|
Next.js 14 (App Router, TypeScript) · PostgreSQL · Prisma · Tailwind CSS · Docker · Vitest
|
||||||
|
|
||||||
## Schnellstart mit Docker (empfohlen)
|
## MPM Marketplace
|
||||||
|
|
||||||
|
Das Repository ist als MPM-Modul vorbereitet. `module.json` beschreibt das
|
||||||
|
Modul und seine Konfigurationsfelder; `compose.yml` enthält App, PostgreSQL
|
||||||
|
und tägliche Backups. Die Modul-App veröffentlicht keinen Host-Port. MPM
|
||||||
|
erreicht sie intern über Port `41030` und die Health-Route `/health`.
|
||||||
|
|
||||||
|
Die Anmeldung und Rollen kommen im MPM-Betrieb signiert vom MPM-Gateway.
|
||||||
|
Die lokale Cookie-Anmeldung bleibt für den eigenständigen Betrieb erhalten.
|
||||||
|
Kalender und Reservierungen liegen in PostgreSQL. Die benannten Volumes
|
||||||
|
`pgdata` und `pgbackups` bleiben beim Entfernen des Moduls erhalten.
|
||||||
|
|
||||||
|
Bei der ersten MPM-Konfiguration werden zwei getrennte, zufällige Passwörter
|
||||||
|
für PostgreSQL-Administration und App-Zugriff benötigt. Die Anmeldung läuft
|
||||||
|
über MPM; lokale Konten und Einladungslinks sind im MPM-Betrieb deaktiviert.
|
||||||
|
|
||||||
|
Benutzer und Modulzugriff werden in MPM verwaltet. Öffne das Modul zunächst
|
||||||
|
einmal mit dem neuen Benutzer, damit sein lokales Profil angelegt wird; ein
|
||||||
|
Kalenderadministrator kann ihn danach direkt einem Kalender zuordnen. Die
|
||||||
|
Datenbank-Initialisierung wird nur bei einem leeren Volume ausgeführt. Ein
|
||||||
|
späteres Ändern der Datenbankpasswörter im MPM-Konfigurationsdialog ändert die
|
||||||
|
Zugangsdaten eines bestehenden PostgreSQL-Volumes nicht automatisch.
|
||||||
|
|
||||||
|
## Schnellstart mit Docker (eigenständiger Betrieb)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Session-Secret erzeugen und .env anlegen
|
# 1. Session-Secret erzeugen und .env anlegen
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# SESSION_SECRET in .env setzen (mindestens 32 Zeichen):
|
# CALENDAR_ADMIN_PASSWORD, CALENDAR_DB_PASSWORD und SESSION_SECRET in .env
|
||||||
|
# durch zufällige Werte ersetzen. SESSION_SECRET braucht mindestens 32 Zeichen:
|
||||||
node -e "console.log('SESSION_SECRET=' + require('crypto').randomBytes(48).toString('base64url'))"
|
node -e "console.log('SESSION_SECRET=' + require('crypto').randomBytes(48).toString('base64url'))"
|
||||||
|
|
||||||
# 2. Stack starten (PostgreSQL + App)
|
# 2. Stack starten (PostgreSQL + App)
|
||||||
docker compose up --build
|
docker compose -f docker-compose.yml up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Die App läuft dann auf http://localhost:3000. Migrationen werden beim Containerstart automatisch angewendet (`prisma migrate deploy`).
|
Die App läuft dann auf http://localhost:3000. Migrationen werden beim Containerstart automatisch angewendet (`prisma migrate deploy`).
|
||||||
@@ -35,7 +59,7 @@ Die App läuft dann auf http://localhost:3000. Migrationen werden beim Container
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env # DATABASE_URL und SESSION_SECRET anpassen
|
cp .env.example .env # Passwörter und SESSION_SECRET anpassen
|
||||||
npx prisma db push # Schema in die Datenbank schreiben
|
npx prisma db push # Schema in die Datenbank schreiben
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
@@ -46,30 +70,27 @@ Voraussetzung: laufendes PostgreSQL (z. B. nur der `db`-Service aus `docker-comp
|
|||||||
|
|
||||||
1. Erste Registrierung unter `/register` → der **erste Benutzer wird automatisch ADMIN**
|
1. Erste Registrierung unter `/register` → der **erste Benutzer wird automatisch ADMIN**
|
||||||
2. Im Dashboard einen Kalender anlegen
|
2. Im Dashboard einen Kalender anlegen
|
||||||
3. Unter „Benutzer einladen" einen Invite-Link erstellen und verschicken
|
3. Im Kalender unter „Mitglieder" vorhandene Benutzer zuordnen
|
||||||
4. Eingeladene Personen registrieren sich über den Link und sehen sofort nur diesen einen Kalender
|
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
app/ Next.js App Router (Seiten + API-Routen)
|
app/ Next.js App Router (Seiten + API-Routen)
|
||||||
api/auth/ register, login, logout, forgot-password
|
api/auth/ register, login, logout, forgot-password
|
||||||
api/calendars/ CRUD + Reservierungen + Invites
|
api/calendars/ CRUD + Reservierungen + Mitglieder
|
||||||
api/reservations/ Bearbeiten/Löschen (atomar)
|
api/reservations/ Bearbeiten/Löschen (atomar)
|
||||||
dashboard/ User: 1 Kalender · Admin: alle Kalender
|
dashboard/ User: 1 Kalender · Admin: alle Kalender
|
||||||
calendar/[id]/ Ansicht Tag/Woche/Monat
|
calendar/[id]/ Ansicht Tag/Woche/Monat
|
||||||
invite/[token]/ Invite-Annahme
|
|
||||||
admin/ Kalenderverwaltung
|
admin/ Kalenderverwaltung
|
||||||
components/ UI-, Kalender-, Reservierungs-, Admin-Komponenten
|
components/ UI-, Kalender-, Reservierungs-, Admin-Komponenten
|
||||||
lib/
|
lib/
|
||||||
auth/ Session (JWT-Cookies), Passwort-Hashing
|
auth/ Session (JWT-Cookies), Passwort-Hashing
|
||||||
permissions/ zentrale Berechtigungsschicht
|
permissions/ zentrale Berechtigungsschicht
|
||||||
reservations/ Konfliktlogik + atomarer Service
|
reservations/ Konfliktlogik + atomarer Service
|
||||||
invites/ Token-Erzeugung/Hashing
|
|
||||||
api/ Response-Helfer, Kontext-Lader
|
api/ Response-Helfer, Kontext-Lader
|
||||||
validation.ts Zod-Schemas
|
validation.ts Zod-Schemas
|
||||||
prisma/schema.prisma Datenmodell
|
prisma/schema.prisma Datenmodell
|
||||||
tests/ Unit-Tests (Kollisionen, Invites, Rechte)
|
tests/ Unit-Tests (Kollisionen, Validierung, Rechte)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
@@ -78,7 +99,7 @@ tests/ Unit-Tests (Kollisionen, Invites, Rechte)
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus plan.md Abschnitt 6), Invite-Token-Hashing/-Ablauf, Eingabevalidierung und die komplette Berechtigungsmatrix.
|
Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus plan.md Abschnitt 6), Eingabevalidierung und die komplette Berechtigungsmatrix.
|
||||||
|
|
||||||
## Sicherheitskonzept (Kurzfassung)
|
## Sicherheitskonzept (Kurzfassung)
|
||||||
|
|
||||||
@@ -86,7 +107,6 @@ Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Doppelbuchungen | `SERIALIZABLE`-Transaktion in `lib/reservations/service.ts` |
|
| Doppelbuchungen | `SERIALIZABLE`-Transaktion in `lib/reservations/service.ts` |
|
||||||
| Berechtigungen | zentrale Funktionen in `lib/permissions/permissions.ts`, geprüft in **jeder** API-Route |
|
| Berechtigungen | zentrale Funktionen in `lib/permissions/permissions.ts`, geprüft in **jeder** API-Route |
|
||||||
| Invite-Tokens | 32 Byte Zufall, nur SHA-256-Hash in der DB |
|
|
||||||
| Passwörter | bcrypt (12 Runden), nie im Klartext |
|
| Passwörter | bcrypt (12 Runden), nie im Klartext |
|
||||||
| Sessions | HS256-signierte JWTs in HttpOnly/SameSite-Cookies |
|
| Sessions | HS256-signierte JWTs in HttpOnly/SameSite-Cookies |
|
||||||
| Login-Missbrauch | Rate-Limiting (5 Versuche / 15 min) |
|
| Login-Missbrauch | Rate-Limiting (5 Versuche / 15 min) |
|
||||||
@@ -95,4 +115,4 @@ Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus
|
|||||||
|
|
||||||
## Bewusste MVP-Grenzen (plan.md Abschnitt 18)
|
## Bewusste MVP-Grenzen (plan.md Abschnitt 18)
|
||||||
|
|
||||||
Buchungszeitfenster-Regeln, E-Mail-Versand, ICS-Export, wiederkehrende Reservierungen und Statistiken sind bewusst **nicht** Teil von Version 1 – die Struktur (Service-Schicht, Regeln in `lib/`) ist darauf vorbereitet.
|
Buchungszeitfenster-Regeln, E-Mail-Versand, ICS-Export, wiederkehrende Reservierungen und Statistiken sind bewusst **nicht** Teil von Version 1 – die Struktur (Service-Schicht, Regeln in `lib/`) ist darauf vorbereitet.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Admin: Kalenderverwaltung (plan.md Abschnitt 13: /admin/calendars/[id]).
|
* Admin: Kalenderverwaltung (plan.md Abschnitt 13: /admin/calendars/[id]).
|
||||||
* Bearbeiten, deaktivieren, Invite-Links erstellen, Benutzer einsehen.
|
* Bearbeiten, deaktivieren und Mitglieder verwalten.
|
||||||
*/
|
*/
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { prisma } from '@/lib/db/client';
|
import { prisma } from '@/lib/db/client';
|
||||||
@@ -9,7 +9,7 @@ import { canManageCalendar } from '@/lib/permissions/permissions';
|
|||||||
import { displayName } from '@/lib/privacy/display';
|
import { displayName } from '@/lib/privacy/display';
|
||||||
import { Card } from '@/components/ui/ui';
|
import { Card } from '@/components/ui/ui';
|
||||||
import { EditCalendarForm } from '@/components/admin/EditCalendarForm';
|
import { EditCalendarForm } from '@/components/admin/EditCalendarForm';
|
||||||
import { InviteManager } from '@/components/admin/InviteManager';
|
import { MemberSection } from '@/components/admin/MemberSection';
|
||||||
import { AppShell } from '@/components/layout/AppShell';
|
import { AppShell } from '@/components/layout/AppShell';
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -26,29 +26,18 @@ export default async function AdminCalendarPage({ params }: PageProps) {
|
|||||||
redirect('/dashboard');
|
redirect('/dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [members, invites, reservations] = await Promise.all([
|
const reservations = await prisma.reservation.findMany({
|
||||||
prisma.calendarMember.findMany({
|
where: { calendarId: id, status: 'CONFIRMED' },
|
||||||
where: { calendarId: id },
|
orderBy: { startAt: 'desc' },
|
||||||
select: { user: { select: { email: true, username: true, createdAt: true } } },
|
take: 50,
|
||||||
}),
|
select: {
|
||||||
prisma.invite.findMany({
|
id: true,
|
||||||
where: { calendarId: id },
|
title: true,
|
||||||
orderBy: { createdAt: 'desc' },
|
startAt: true,
|
||||||
select: { id: true, expiresAt: true, usedAt: true, createdAt: true },
|
endAt: true,
|
||||||
}),
|
user: { select: { email: true, username: true } },
|
||||||
prisma.reservation.findMany({
|
},
|
||||||
where: { calendarId: id, status: 'CONFIRMED' },
|
});
|
||||||
orderBy: { startAt: 'desc' },
|
|
||||||
take: 50,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
startAt: true,
|
|
||||||
endAt: true,
|
|
||||||
user: { select: { email: true, username: true } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell role={context.user.role} userEmail={context.user.email} username={context.user.username}>
|
<AppShell role={context.user.role} userEmail={context.user.email} username={context.user.username}>
|
||||||
@@ -62,28 +51,8 @@ export default async function AdminCalendarPage({ params }: PageProps) {
|
|||||||
initialIsActive={context.calendar.isActive}
|
initialIsActive={context.calendar.isActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InviteManager calendarId={id} invites={invites} />
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<h2 className="mb-3 text-lg font-semibold">Mitglieder ({members.length})</h2>
|
<MemberSection calendarId={id} />
|
||||||
{members.length === 0 ? (
|
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
Noch keine Mitglieder. Erstelle einen Invite-Link.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-1 text-sm">
|
|
||||||
{members.map((member) => (
|
|
||||||
<li key={member.user.email}>
|
|
||||||
{displayName(member.user.username, member.user.email)}
|
|
||||||
{member.user.username && (
|
|
||||||
<span className="ml-2 text-xs text-slate-500">
|
|
||||||
{member.user.email}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -112,4 +81,4 @@ export default async function AdminCalendarPage({ params }: PageProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Admin: Kalenderverwaltung (Cards-Ansicht).
|
* Admin: Kalenderverwaltung (Cards-Ansicht).
|
||||||
* Zeigt alle Kalender als Karten - eigene und die anderer Benutzer.
|
* Zeigt alle Kalender als Karten - eigene und die anderer Benutzer.
|
||||||
* Verwaltung direkt im Bearbeiten-Modal oder auf der Detailseite
|
* Verwaltung direkt im Bearbeiten-Modal oder auf der Detailseite
|
||||||
* /admin/calendars/[id] (Invites, Mitglieder, Reservierungen).
|
* /admin/calendars/[id] (Mitglieder, Reservierungen).
|
||||||
*/
|
*/
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { prisma } from '@/lib/db/client';
|
import { prisma } from '@/lib/db/client';
|
||||||
@@ -55,4 +55,4 @@ export default async function ManageCalendarsPage() {
|
|||||||
/>
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* POST /api/auth/register
|
* POST /api/auth/register
|
||||||
*
|
*
|
||||||
* Registrierung (plan.md Abschnitt 11). Zwei Wege:
|
* Lokale Registrierung für den eigenständigen Betrieb. Sie ist nur
|
||||||
* 1. Mit Invite-Token -> User wird dem Kalender des Invites zugeordnet.
|
* verfügbar, wenn ALLOW_OPEN_REGISTRATION=true; der erste
|
||||||
* 2. Ohne Token -> nur wenn ALLOW_OPEN_REGISTRATION=true; der erste
|
|
||||||
* Benutzer wird ADMIN, weitere offene Registrierungen werden
|
* Benutzer wird ADMIN, weitere offene Registrierungen werden
|
||||||
* dann automatisch zu USER ohne Kalender (oder abgelehnt, je
|
* dann automatisch zu USER ohne Kalender (oder abgelehnt, je
|
||||||
* nach Konfiguration).
|
* nach Konfiguration).
|
||||||
*
|
*
|
||||||
* Sicherheit:
|
* Sicherheit:
|
||||||
* - Rate-Limit pro IP (Brute-Force-/Enumeration-Schutz).
|
* - Rate-Limit pro IP (Brute-Force-/Enumeration-Schutz).
|
||||||
* - SERIALIZABLE-Transaktion: Ein Invite kann trotz paralleler
|
* - SERIALIZABLE-Transaktion: Der "erste Benutzer
|
||||||
* Registrierungen nur EINMAL verwendet werden; der "erste Benutzer
|
|
||||||
* wird ADMIN"-Wettlauf kann nicht mehrere Admins erzeugen.
|
* wird ADMIN"-Wettlauf kann nicht mehrere Admins erzeugen.
|
||||||
* - Bei bereits registrierter E-Mail wird bewusst mit 400 und einer
|
* - Bei bereits registrierter E-Mail wird bewusst mit 400 und einer
|
||||||
* generischen Meldung geantwortet (kein 409), damit die Antwort
|
* generischen Meldung geantwortet (kein 409), damit die Antwort
|
||||||
@@ -22,7 +20,6 @@ import { prisma } from '@/lib/db/client';
|
|||||||
import { hashPassword } from '@/lib/auth/password';
|
import { hashPassword } from '@/lib/auth/password';
|
||||||
import { createSessionToken, setSessionCookie } from '@/lib/auth/session';
|
import { createSessionToken, setSessionCookie } from '@/lib/auth/session';
|
||||||
import { registerSchema } from '@/lib/validation';
|
import { registerSchema } from '@/lib/validation';
|
||||||
import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens';
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { errorResponse } from '@/lib/api/responses';
|
import { errorResponse } from '@/lib/api/responses';
|
||||||
import {
|
import {
|
||||||
@@ -65,39 +62,9 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|||||||
const passwordHash = await hashPassword(body.password);
|
const passwordHash = await hashPassword(body.password);
|
||||||
|
|
||||||
const createdUser = await withSerializableTx(async (tx) => {
|
const createdUser = await withSerializableTx(async (tx) => {
|
||||||
let calendarId: string | null = null;
|
if (!config.allowOpenRegistration) {
|
||||||
|
|
||||||
if (body.inviteToken) {
|
|
||||||
const tokenHash = hashInviteToken(body.inviteToken);
|
|
||||||
const invite = await tx.invite.findUnique({
|
|
||||||
where: { tokenHash },
|
|
||||||
select: { id: true, calendarId: true, expiresAt: true, usedAt: true },
|
|
||||||
});
|
|
||||||
if (!invite || !isInviteUsable(invite)) {
|
|
||||||
throw new RegistrationError(
|
|
||||||
'Dieser Einladungslink ist ungueltig oder abgelaufen.',
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
calendarId = invite.calendarId;
|
|
||||||
|
|
||||||
// Bedingungsloses Update: schlaegt fehl, wenn eine parallele
|
|
||||||
// Transaktion den Invite inzwischen verbraucht hat (SERIALIZABLE
|
|
||||||
// bricht eine der beiden ab; der Retry sieht dann usedAt und
|
|
||||||
// bricht hier sauber ab).
|
|
||||||
const updated = await tx.invite.updateMany({
|
|
||||||
where: { id: invite.id, usedAt: null },
|
|
||||||
data: { usedAt: new Date() },
|
|
||||||
});
|
|
||||||
if (updated.count === 0) {
|
|
||||||
throw new RegistrationError(
|
|
||||||
'Dieser Einladungslink ist ungueltig oder abgelaufen.',
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (!config.allowOpenRegistration) {
|
|
||||||
throw new RegistrationError(
|
throw new RegistrationError(
|
||||||
'Registrierung ist nur ueber Einladungslinks moeglich.',
|
'Registrierung ist derzeit deaktiviert.',
|
||||||
403,
|
403,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,9 +81,6 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|||||||
passwordHash,
|
passwordHash,
|
||||||
role,
|
role,
|
||||||
...(body.username ? { username: body.username } : {}),
|
...(body.username ? { username: body.username } : {}),
|
||||||
...(calendarId
|
|
||||||
? { memberships: { create: { calendarId } } }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
select: { id: true, email: true, role: true, username: true },
|
select: { id: true, email: true, role: true, username: true },
|
||||||
});
|
});
|
||||||
@@ -156,4 +120,4 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|||||||
}
|
}
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
* POST /api/calendars/[id]/members - User per E-Mail hinzufuegen (nur Admin).
|
||||||
* DELETE /api/calendars/[id]/members - Mitglied entfernen (nur Admin).
|
* DELETE /api/calendars/[id]/members - Mitglied entfernen (nur Admin).
|
||||||
*
|
*
|
||||||
* DB-basiertes Einladungssystem: Statt Invite-Links werden Benutzer
|
* Benutzer werden direkt per E-Mail-Suche ueber die Datenbank
|
||||||
* direkt per E-Mail-Suche ueber die Datenbank zugeordnet. Der Server
|
* zugeordnet. Der Server
|
||||||
* prueft die Verwaltungsberechtigung zentral (canManageCalendar).
|
* prueft die Verwaltungsberechtigung zentral (canManageCalendar).
|
||||||
*/
|
*/
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
@@ -141,4 +141,4 @@ export async function DELETE(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error);
|
return errorResponse(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,16 +10,17 @@ import { NextResponse } from "next/server";
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const basePath = (process.env.HUB_BASE_PATH ?? "").replace(/\/$/, "");
|
const basePath = (process.env.HUB_BASE_PATH ?? "").replace(/\/$/, "");
|
||||||
|
const mpmMode = process.env.MPM_AUTH_MODE === "true";
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
name: "Kalendertool",
|
name: "Kalendertool",
|
||||||
slug: "kalender",
|
slug: "kalendartool",
|
||||||
version: "1.0",
|
version: "1.1.0",
|
||||||
icon: "/icon.png",
|
icon: "/icon.png",
|
||||||
auth: { mode: "hub-token", path: "/auth/hub" },
|
auth: mpmMode ? { mode: "mpm-gateway" } : { mode: "hub-token", path: "/auth/hub" },
|
||||||
basePath: basePath || "/apps/kalender",
|
basePath: basePath || "/",
|
||||||
},
|
},
|
||||||
{ headers: { "Cache-Control": "public, max-age=60" } }
|
{ headers: { "Cache-Control": "public, max-age=60" } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default async function DashboardPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
Du bist noch keinem Kalender zugeordnet. Bitte wende dich an den
|
Du bist noch keinem Kalender zugeordnet. Bitte wende dich an den
|
||||||
Administrator fuer einen Einladungslink.
|
Administrator, damit er dich einem Kalender zuordnet.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -143,4 +143,4 @@ export default async function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* "Passwort vergessen"-Seite (plan.md Abschnitt 11).
|
* "Passwort vergessen"-Seite (plan.md Abschnitt 11).
|
||||||
* Antwortet immer mit Erfolgsmeldung (kein Account-Enumeration).
|
* Antwortet immer mit Erfolgsmeldung (kein Account-Enumeration).
|
||||||
*/
|
*/
|
||||||
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
@@ -20,7 +21,7 @@ export default function ForgotPasswordPage() {
|
|||||||
|
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/forgot-password', {
|
const response = await appFetch('/api/auth/forgot-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email: formData.get('email') }),
|
body: JSON.stringify({ email: formData.get('email') }),
|
||||||
|
|||||||
@@ -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).
|
* Anmeldeseite (plan.md Abschnitt 11).
|
||||||
*/
|
*/
|
||||||
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -20,7 +21,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/login', {
|
const response = await appFetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registrierungsseite (plan.md Abschnitt 3 und 11).
|
* Lokale Registrierungsseite für den eigenständigen Betrieb.
|
||||||
* Mit Invite-Token (aus URL) wird dem Kalender automatisch zugeordnet.
|
|
||||||
*/
|
*/
|
||||||
import { Suspense, useState } from 'react';
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
|
|
||||||
function RegisterForm() {
|
function RegisterForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const inviteToken = searchParams.get('token') ?? '';
|
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -35,14 +33,13 @@ function RegisterForm() {
|
|||||||
try {
|
try {
|
||||||
const username = String(formData.get('username') ?? '').trim();
|
const username = String(formData.get('username') ?? '').trim();
|
||||||
|
|
||||||
const response = await fetch('/api/auth/register', {
|
const response = await appFetch('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: formData.get('email'),
|
email: formData.get('email'),
|
||||||
password,
|
password,
|
||||||
...(username ? { username } : {}),
|
...(username ? { username } : {}),
|
||||||
...(inviteToken ? { inviteToken } : {}),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,12 +59,6 @@ function RegisterForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
{inviteToken && (
|
|
||||||
<p className="mb-4 rounded-md bg-slate-100 px-3 py-2 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
|
||||||
Du wurdest zu einem Kalender eingeladen. Nach der Registrierung siehst du
|
|
||||||
ausschliesslich diesen Kalender.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<ErrorMessage message={error} />
|
<ErrorMessage message={error} />
|
||||||
<div>
|
<div>
|
||||||
@@ -128,9 +119,7 @@ export default function RegisterPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
|
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
|
||||||
<h1 className="mb-6 text-center text-2xl font-bold">Konto erstellen</h1>
|
<h1 className="mb-6 text-center text-2xl font-bold">Konto erstellen</h1>
|
||||||
<Suspense fallback={<Card>Wird geladen …</Card>}>
|
<RegisterForm />
|
||||||
<RegisterForm />
|
|
||||||
</Suspense>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* - "Kalender erstellen" oeffnet ein Erstellen-Modal (POST /api/calendars).
|
* - "Kalender erstellen" oeffnet ein Erstellen-Modal (POST /api/calendars).
|
||||||
* - Alle Modals sind per X-Icon, ESC und Hintergrund-Klick schliessbar.
|
* - Alle Modals sind per X-Icon, ESC und Hintergrund-Klick schliessbar.
|
||||||
*/
|
*/
|
||||||
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
@@ -66,7 +67,7 @@ export function CalendarManager({
|
|||||||
setIsPending(true);
|
setIsPending(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/calendars', {
|
const response = await appFetch('/api/calendars', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(values),
|
body: JSON.stringify(values),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/** Formular zum Erstellen eines Kalenders (nur Admin). */
|
/** Formular zum Erstellen eines Kalenders (nur Admin). */
|
||||||
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
@@ -17,7 +18,7 @@ export function CreateCalendarForm() {
|
|||||||
|
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/calendars', {
|
const response = await appFetch('/api/calendars', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/** Formular zum Bearbeiten/Deaktivieren eines Kalenders (Admin). */
|
/** Formular zum Bearbeiten/Deaktivieren eines Kalenders (Admin). */
|
||||||
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
@@ -29,7 +30,7 @@ export function EditCalendarForm({
|
|||||||
|
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/calendars/${calendarId}`, {
|
const response = await appFetch(`/api/calendars/${calendarId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
* Dashboard verwendet (DRY). Schliessen via X-Icon, ESC oder
|
* Dashboard verwendet (DRY). Schliessen via X-Icon, ESC oder
|
||||||
* Hintergrund-Klick; waehrend des Speicherns ist Schliessen blockiert.
|
* Hintergrund-Klick; waehrend des Speicherns ist Schliessen blockiert.
|
||||||
*/
|
*/
|
||||||
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
@@ -61,7 +62,7 @@ export function EditCalendarModal({
|
|||||||
|
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/calendars/${calendar.id}`, {
|
const response = await appFetch(`/api/calendars/${calendar.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -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';
|
'use client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mitgliederverwaltung fuer Modals (DB-basiertes Einladungssystem).
|
* Mitgliederverwaltung fuer Kalender.
|
||||||
*
|
*
|
||||||
* Statt Invite-Links werden bestehende Benutzer direkt per E-Mail
|
* Vorhandene Benutzer werden direkt per E-Mail ueber die Datenbank
|
||||||
* ueber die Datenbank gesucht und dem Kalender zugeordnet:
|
* gesucht und dem Kalender zugeordnet:
|
||||||
* - Suchfeld mit Live-Vorschlaegen (GET /api/users/search?q=...)
|
* - Suchfeld mit Live-Vorschlaegen (GET /api/users/search?q=...)
|
||||||
* - Hinzufuegen per Klick (POST /api/calendars/[id]/members)
|
* - Hinzufuegen per Klick (POST /api/calendars/[id]/members)
|
||||||
* - Mitgliederliste mit Entfernen-Button (DELETE ...?memberId=...)
|
* - Mitgliederliste mit Entfernen-Button (DELETE ...?memberId=...)
|
||||||
*
|
*
|
||||||
* Nur fuer Admins gerendert; der Server prueft die Berechtigung final.
|
* Nur fuer Admins gerendert; der Server prueft die Berechtigung final.
|
||||||
*/
|
*/
|
||||||
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { ErrorMessage } from '@/components/ui/ui';
|
import { ErrorMessage } from '@/components/ui/ui';
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/calendars/${calendarId}/members`);
|
const response = await appFetch(`/api/calendars/${calendarId}/members`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||||
setError(data.error ?? 'Mitglieder konnten nicht geladen werden.');
|
setError(data.error ?? 'Mitglieder konnten nicht geladen werden.');
|
||||||
@@ -75,7 +76,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
|
|||||||
setAddingEmail(email);
|
setAddingEmail(email);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/calendars/${calendarId}/members`, {
|
const response = await appFetch(`/api/calendars/${calendarId}/members`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
@@ -100,7 +101,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
|
|||||||
setRemovingId(memberId);
|
setRemovingId(memberId);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await appFetch(
|
||||||
`/api/calendars/${calendarId}/members?memberId=${encodeURIComponent(memberId)}`,
|
`/api/calendars/${calendarId}/members?memberId=${encodeURIComponent(memberId)}`,
|
||||||
{ method: 'DELETE' },
|
{ method: 'DELETE' },
|
||||||
);
|
);
|
||||||
@@ -130,7 +131,7 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
|
|||||||
searchTimer.current = setTimeout(async () => {
|
searchTimer.current = setTimeout(async () => {
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await appFetch(
|
||||||
`/api/users/search?q=${encodeURIComponent(nextQuery.trim())}`,
|
`/api/users/search?q=${encodeURIComponent(nextQuery.trim())}`,
|
||||||
);
|
);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -237,4 +238,4 @@ export function MemberSection({ calendarId }: { calendarId: string }) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* Fehler werden inline angezeigt. Der eigene Account ist geschuetzt
|
* Fehler werden inline angezeigt. Der eigene Account ist geschuetzt
|
||||||
* (Server lehnt ab).
|
* (Server lehnt ab).
|
||||||
*/
|
*/
|
||||||
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Button, Card, ErrorMessage, Badge } from '@/components/ui/ui';
|
import { Button, Card, ErrorMessage, Badge } from '@/components/ui/ui';
|
||||||
import { ModalShell } from '@/components/ui/ModalShell';
|
import { ModalShell } from '@/components/ui/ModalShell';
|
||||||
@@ -32,7 +33,7 @@ export function UserManagement({ currentUserId }: { currentUserId: string }) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/users', { cache: 'no-store' });
|
const response = await appFetch('/api/users', { cache: 'no-store' });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = (await response.json().catch(() => null)) as
|
const data = (await response.json().catch(() => null)) as
|
||||||
| { error?: string }
|
| { error?: string }
|
||||||
@@ -65,7 +66,7 @@ export function UserManagement({ currentUserId }: { currentUserId: string }) {
|
|||||||
setPendingId(userId);
|
setPendingId(userId);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/users/${userId}`, {
|
const response = await appFetch(`/api/users/${userId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ role: nextRole }),
|
body: JSON.stringify({ role: nextRole }),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
/** Logout-Button: beendet die Session per API und laedt neu. */
|
/** Logout-Button: beendet die Session per API und laedt neu. */
|
||||||
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button } from '@/components/ui/ui';
|
import { Button } from '@/components/ui/ui';
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ export function LogoutButton() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
await appFetch('/api/auth/logout', { method: 'POST' });
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* - Loeschen ausschliesslich durch Admins (Server prueft final)
|
* - Loeschen ausschliesslich durch Admins (Server prueft final)
|
||||||
* - Legende: eigene vs. fremde Reservierungen
|
* - Legende: eigene vs. fremde Reservierungen
|
||||||
*/
|
*/
|
||||||
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ModalShell } from '@/components/ui/ModalShell';
|
import { ModalShell } from '@/components/ui/ModalShell';
|
||||||
import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
import { Button, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
@@ -189,7 +190,7 @@ export function CalendarModal({
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await appFetch(
|
||||||
`/api/calendars/${calendarId}/reservations?from=${encodeURIComponent(window.from)}&to=${encodeURIComponent(window.to)}`,
|
`/api/calendars/${calendarId}/reservations?from=${encodeURIComponent(window.from)}&to=${encodeURIComponent(window.to)}`,
|
||||||
);
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -231,7 +232,7 @@ export function CalendarModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/calendars/${calendarId}/reservations`, {
|
const response = await appFetch(`/api/calendars/${calendarId}/reservations`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -261,7 +262,7 @@ export function CalendarModal({
|
|||||||
setDeletingId(reservationId);
|
setDeletingId(reservationId);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/reservations/${reservationId}`, {
|
const response = await appFetch(`/api/reservations/${reservationId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
* Der Zustand wird in localStorage persistiert, damit die Wahl
|
* Der Zustand wird in localStorage persistiert, damit die Wahl
|
||||||
* ueber Seitenwechsel hinweg erhalten bleibt.
|
* ueber Seitenwechsel hinweg erhalten bleibt.
|
||||||
*/
|
*/
|
||||||
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
@@ -115,7 +116,7 @@ export function Sidebar({
|
|||||||
async function logout() {
|
async function logout() {
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' });
|
await appFetch('/api/auth/logout', { method: 'POST' });
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -140,7 +141,7 @@ export function Sidebar({
|
|||||||
Kalendartool
|
Kalendartool
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
{process.env.NEXT_PUBLIC_MPM_AUTH_MODE !== 'true' && <button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleCollapsed}
|
onClick={toggleCollapsed}
|
||||||
aria-expanded={!isCollapsed}
|
aria-expanded={!isCollapsed}
|
||||||
@@ -148,7 +149,7 @@ export function Sidebar({
|
|||||||
className="rounded-md p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
className="rounded-md p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||||
>
|
>
|
||||||
{isCollapsed ? '»' : '«'}
|
{isCollapsed ? '»' : '«'}
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
|
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
|
||||||
@@ -231,4 +232,4 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
* Wird aus der Sidebar ueber das Profil-Badge geoeffnet; nach dem
|
* Wird aus der Sidebar ueber das Profil-Badge geoeffnet; nach dem
|
||||||
* Speichern aktualisiert router.refresh() die Sidebar sofort.
|
* Speichern aktualisiert router.refresh() die Sidebar sofort.
|
||||||
*/
|
*/
|
||||||
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button, ErrorMessage, Input, Label, Badge } from '@/components/ui/ui';
|
import { Button, ErrorMessage, Input, Label, Badge } from '@/components/ui/ui';
|
||||||
@@ -35,7 +36,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/me/profile', { cache: 'no-store' });
|
const response = await appFetch('/api/me/profile', { cache: 'no-store' });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Profil konnte nicht geladen werden.');
|
throw new Error('Profil konnte nicht geladen werden.');
|
||||||
}
|
}
|
||||||
@@ -66,7 +67,7 @@ export function ProfileModal({ onClose }: { onClose: () => void }) {
|
|||||||
const username = String(formData.get('username') ?? '').trim();
|
const username = String(formData.get('username') ?? '').trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/me/profile', {
|
const response = await appFetch('/api/me/profile', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username: username || null }),
|
body: JSON.stringify({ username: username || null }),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* geprueft und der Benutzer bei Ueberschreitung informiert.
|
* geprueft und der Benutzer bei Ueberschreitung informiert.
|
||||||
* Der Server prueft Kollisionen; 409 wird verstaendlich angezeigt.
|
* Der Server prueft Kollisionen; 409 wird verstaendlich angezeigt.
|
||||||
*/
|
*/
|
||||||
|
import { appFetch } from '@/lib/client-fetch';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||||
@@ -133,7 +134,7 @@ export function NewReservationButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/calendars/${calendarId}/reservations`, {
|
const response = await appFetch(`/api/calendars/${calendarId}/reservations`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
67
compose.yml
Normal file
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:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: calendar
|
POSTGRES_USER: calendar_admin
|
||||||
POSTGRES_PASSWORD: calendar
|
POSTGRES_PASSWORD: ${CALENDAR_ADMIN_PASSWORD:?CALENDAR_ADMIN_PASSWORD fehlt}
|
||||||
POSTGRES_DB: calendar
|
POSTGRES_DB: calendar
|
||||||
# KEIN Port-Mapping: Die DB ist nur im internen Compose-Netz
|
CALENDAR_DB_PASSWORD: ${CALENDAR_DB_PASSWORD:?CALENDAR_DB_PASSWORD fehlt}
|
||||||
# erreichbar (App verbindet ueber den Hostnamen "db"). Fuer
|
|
||||||
# lokalen Zugriff bei Bedarf: docker compose exec db psql ...
|
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
# Legt den least-privilege-App-User an (nur bei initialer
|
|
||||||
# Volume-Initialisierung; siehe Script fuer Bestands-Volumes).
|
|
||||||
- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U calendar -d calendar"]
|
test: ["CMD-SHELL", "pg_isready -U calendar_admin -d calendar"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
# Taeglicher DB-Backup-Service: erstellt um 02:00 Uhr ein
|
|
||||||
# pg_dump-Backup im Named Volume "pgbackups". Aeltere Backups werden
|
|
||||||
# automatisch nach 7 Tagen geloescht (Retention). Die Backups
|
|
||||||
# ueberleben Neustarts des Stacks, da sie im Volume liegen.
|
|
||||||
db-backup:
|
db-backup:
|
||||||
image: prodrigestivill/postgres-backup-local:16-alpine
|
image: prodrigestivill/postgres-backup-local:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -33,46 +28,39 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_HOST: db
|
POSTGRES_HOST: db
|
||||||
POSTGRES_DB: calendar
|
POSTGRES_DB: calendar
|
||||||
POSTGRES_USER: calendar
|
POSTGRES_USER: calendar_admin
|
||||||
POSTGRES_PASSWORD: calendar
|
POSTGRES_PASSWORD: ${CALENDAR_ADMIN_PASSWORD:?CALENDAR_ADMIN_PASSWORD fehlt}
|
||||||
# Taeglich um 02:00 Uhr (Cron).
|
|
||||||
SCHEDULE: "@daily"
|
SCHEDULE: "@daily"
|
||||||
# Backups nach 7 Tagen automatisch loeschen.
|
|
||||||
BACKUP_KEEP_DAYS: 7
|
BACKUP_KEEP_DAYS: 7
|
||||||
# Zusaetzlich die letzten 4 Wochensicherungen behalten.
|
|
||||||
BACKUP_KEEP_WEEKS: 4
|
BACKUP_KEEP_WEEKS: 4
|
||||||
# Monats-Sicherungen der letzten 3 Monate behalten.
|
|
||||||
BACKUP_KEEP_MONTHS: 3
|
BACKUP_KEEP_MONTHS: 3
|
||||||
# Kompression aktivieren (platzsparend).
|
|
||||||
POSTGRES_EXTRA_OPTS: "-Z6"
|
POSTGRES_EXTRA_OPTS: "-Z6"
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
volumes:
|
volumes:
|
||||||
- pgbackups:/backups
|
- pgbackups:/backups
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
HUB_BASE_PATH: ${HUB_BASE_PATH:-}
|
||||||
|
MPM_AUTH_MODE: "false"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://calendar_app:calendar_app@db:5432/calendar?schema=public
|
MPM_AUTH_MODE: "false"
|
||||||
# KEIN Fallback-Secret: Ohne gesetztes SESSION_SECRET failt die
|
HUB_BASE_PATH: ${HUB_BASE_PATH:-}
|
||||||
# App hart beim Start (lib/config.ts), statt JWTs mit einem
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET fehlt}
|
||||||
# oeffentlich bekannten Secret zu signieren.
|
CALENDAR_DB_PASSWORD: ${CALENDAR_DB_PASSWORD:?CALENDAR_DB_PASSWORD fehlt}
|
||||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET muss gesetzt sein (z. B. in .env, siehe .env.example)}
|
|
||||||
APP_URL: ${APP_URL:-http://localhost:3000}
|
|
||||||
ALLOW_OPEN_REGISTRATION: ${ALLOW_OPEN_REGISTRATION:-true}
|
ALLOW_OPEN_REGISTRATION: ${ALLOW_OPEN_REGISTRATION:-true}
|
||||||
# Optional: Admin-Account beim ersten Start anlegen (First-Run).
|
|
||||||
# Nicht-destruktiv: Legt den Admin nur an, wenn noch keiner
|
|
||||||
# existiert. Bestehende User werden niemals geloescht.
|
|
||||||
# Wenn NICHT gesetzt, laeuft das Seed-Script ohne Nebeneffekte durch.
|
|
||||||
SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-}
|
SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-}
|
||||||
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-}
|
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
pgbackups:
|
pgbackups:
|
||||||
|
|||||||
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.
|
* Kryptografie, keine Klartext-Sessions in der Datenbank.
|
||||||
*/
|
*/
|
||||||
import { SignJWT, jwtVerify } from 'jose';
|
import { SignJWT, jwtVerify } from 'jose';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies, headers } from 'next/headers';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { provisionMpmUser } from '@/lib/auth/mpm-provisioning';
|
||||||
import type { AuthenticatedUser } from '@/lib/permissions/permissions';
|
import type { AuthenticatedUser } from '@/lib/permissions/permissions';
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'calendar_session';
|
const SESSION_COOKIE_NAME = 'calendar_session';
|
||||||
@@ -62,6 +63,18 @@ export async function clearSessionCookie(): Promise<void> {
|
|||||||
* Wirft UnauthorizedError, wenn keine gueltige Session existiert.
|
* Wirft UnauthorizedError, wenn keine gueltige Session existiert.
|
||||||
*/
|
*/
|
||||||
export async function getAuthenticatedUser(): Promise<AuthenticatedUser> {
|
export async function getAuthenticatedUser(): Promise<AuthenticatedUser> {
|
||||||
|
if (process.env.MPM_AUTH_MODE === 'true') {
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
const userId = requestHeaders.get('x-kalendartool-mpm-user-id');
|
||||||
|
const username = requestHeaders.get('x-kalendartool-mpm-username');
|
||||||
|
const displayName = requestHeaders.get('x-kalendartool-mpm-display-name');
|
||||||
|
const role = requestHeaders.get('x-kalendartool-mpm-role');
|
||||||
|
if (!userId || !username || !role || !['ADMIN', 'USER'].includes(role)) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
}
|
||||||
|
return provisionMpmUser({ userId, username, displayName, role: role as 'ADMIN' | 'USER' });
|
||||||
|
}
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -98,4 +111,4 @@ export async function tryGetAuthenticatedUser(): Promise<AuthenticatedUser | nul
|
|||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
export interface AppConfig {
|
||||||
sessionSecret: string;
|
sessionSecret: string;
|
||||||
appUrl: string;
|
/** Wenn false, sind neue lokale Registrierungen deaktiviert. */
|
||||||
/** Wenn false, ist die Registrierung nur ueber Invite-Links moeglich. */
|
|
||||||
allowOpenRegistration: boolean;
|
allowOpenRegistration: boolean;
|
||||||
/** MultiToolApp-Plattform: Basis-URL (JWKS-Abruf). null = SSO deaktiviert. */
|
/** MultiToolApp-Plattform: Basis-URL (JWKS-Abruf). null = SSO deaktiviert. */
|
||||||
hubUrl: string | null;
|
hubUrl: string | null;
|
||||||
@@ -49,7 +48,6 @@ export function getConfig(): AppConfig {
|
|||||||
}
|
}
|
||||||
cachedConfig = {
|
cachedConfig = {
|
||||||
sessionSecret: requireSessionSecret(),
|
sessionSecret: requireSessionSecret(),
|
||||||
appUrl: process.env.APP_URL?.trim() || 'http://localhost:3000',
|
|
||||||
allowOpenRegistration:
|
allowOpenRegistration:
|
||||||
(process.env.ALLOW_OPEN_REGISTRATION ?? 'true').toLowerCase() === 'true',
|
(process.env.ALLOW_OPEN_REGISTRATION ?? 'true').toLowerCase() === 'true',
|
||||||
hubUrl: process.env.HUB_URL?.trim() || null,
|
hubUrl: process.env.HUB_URL?.trim() || null,
|
||||||
@@ -57,4 +55,4 @@ export function getConfig(): AppConfig {
|
|||||||
hubIssuer: process.env.HUB_ISSUER?.trim() || null,
|
hubIssuer: process.env.HUB_ISSUER?.trim() || null,
|
||||||
};
|
};
|
||||||
return cachedConfig;
|
return cachedConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*
|
*
|
||||||
* Postgres bricht bei Parallelitaets-Anomalien unter SERIALIZABLE eine
|
* Postgres bricht bei Parallelitaets-Anomalien unter SERIALIZABLE eine
|
||||||
* der beteiligten Transaktionen ab. Wiederholbare Operationen (z. B.
|
* der beteiligten Transaktionen ab. Wiederholbare Operationen (z. B.
|
||||||
* Registrierung mit Invite-Verbrauch, Rollen-Aenderung) werden dann
|
* Registrierung und Rollen-Aenderung) werden dann
|
||||||
* einfach erneut ausgefuehrt - der Konflikt loest sich auf, weil die
|
* einfach erneut ausgefuehrt - der Konflikt loest sich auf, weil die
|
||||||
* andere Transaktion inzwischen committet hat.
|
* andere Transaktion inzwischen committet hat.
|
||||||
*/
|
*/
|
||||||
@@ -40,4 +40,4 @@ export async function withSerializableTx<T>(
|
|||||||
// committet, ein erneuter Lauf sieht den aktuellen Stand.
|
// committet, ein erneuter Lauf sieht den aktuellen Stand.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* loeschen)? Admins duerfen alle Kalender verwalten (auch die anderer
|
||||||
* Benutzer), normale User keinen.
|
* Benutzer), normale User keinen.
|
||||||
*/
|
*/
|
||||||
@@ -138,4 +138,4 @@ export function canModifyReservation(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return reservation.userId === user.id;
|
return reservation.userId === user.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export const registerSchema = z
|
|||||||
email: emailSchema,
|
email: emailSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
username: usernameSchema.optional(),
|
username: usernameSchema.optional(),
|
||||||
inviteToken: z.string().trim().min(10).max(200).optional(),
|
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -117,16 +116,8 @@ export const updateReservationSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
export const createInviteSchema = z
|
|
||||||
.object({
|
|
||||||
calendarId: z.string().trim().min(1),
|
|
||||||
expiresInDays: z.number().int().min(1).max(90).optional(),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mitglied per E-Mail zu einem Kalender hinzufuegen (DB-basiertes
|
* Vorhandenen Benutzer per E-Mail zu einem Kalender hinzufuegen.
|
||||||
* Einladungssystem, ersetzt den Token-Link-Flow in der Verwaltung).
|
|
||||||
*/
|
*/
|
||||||
export const addMemberSchema = z
|
export const addMemberSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -156,4 +147,4 @@ export const updateUsernameSchema = z
|
|||||||
.object({
|
.object({
|
||||||
username: usernameSchema.nullable(),
|
username: usernameSchema.nullable(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|||||||
@@ -45,6 +45,27 @@ function isPublicPath(pathname: string): boolean {
|
|||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (process.env.MPM_AUTH_MODE === 'true') {
|
||||||
|
const userId = request.headers.get('x-kalendartool-mpm-user-id');
|
||||||
|
const role = request.headers.get('x-kalendartool-mpm-role');
|
||||||
|
if (userId && role && ['ADMIN', 'USER'].includes(role)) {
|
||||||
|
const basePath = (process.env.HUB_BASE_PATH ?? '').replace(/\/$/, '');
|
||||||
|
const localPath = basePath && pathname.startsWith(basePath)
|
||||||
|
? pathname.slice(basePath.length) || '/'
|
||||||
|
: pathname;
|
||||||
|
if (['/login', '/register', '/forgot-password', '/auth/hub'].includes(localPath)) {
|
||||||
|
const dashboardUrl = request.nextUrl.clone();
|
||||||
|
dashboardUrl.pathname = `${basePath}/dashboard`;
|
||||||
|
return NextResponse.redirect(dashboardUrl);
|
||||||
|
}
|
||||||
|
if (localPath.startsWith('/api/auth/')) {
|
||||||
|
return NextResponse.json({ statusCode: 404, message: 'Nicht gefunden' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
return NextResponse.json({ statusCode: 401, message: 'Nicht authentifiziert' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
if (isPublicPath(pathname)) {
|
if (isPublicPath(pathname)) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
@@ -74,4 +95,4 @@ export async function middleware(request: NextRequest) {
|
|||||||
export const config = {
|
export const config = {
|
||||||
// Alles ausser statischen Dateien.
|
// Alles ausser statischen Dateien.
|
||||||
matcher: ['/((?!favicon.ico|.*\\.(?:png|jpg|jpeg|svg|ico|webp|txt)).*)'],
|
matcher: ['/((?!favicon.ico|.*\\.(?:png|jpg|jpeg|svg|ico|webp|txt)).*)'],
|
||||||
};
|
};
|
||||||
|
|||||||
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.
|
// Proxy-Modus: App läuft unter /apps/<slug> innerhalb der Hub-Domain.
|
||||||
// basePath '' (falsy) = Standalone-Betrieb ohne Pfad-Präfix.
|
// basePath '' (falsy) = Standalone-Betrieb ohne Pfad-Präfix.
|
||||||
...(hubBasePath ? { basePath: hubBasePath } : {}),
|
...(hubBasePath ? { basePath: hubBasePath } : {}),
|
||||||
|
// Client-side fetch() calls use absolute API paths and do not inherit
|
||||||
|
// Next.js' basePath automatically. Bake the prefix into browser bundles.
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_APP_BASE_PATH: hubBasePath,
|
||||||
|
NEXT_PUBLIC_MPM_AUTH_MODE: process.env.MPM_AUTH_MODE ?? 'false',
|
||||||
|
},
|
||||||
// Manifest-Route: /.well-known/app-manifest → /api/manifest
|
// Manifest-Route: /.well-known/app-manifest → /api/manifest
|
||||||
// (Next.js App Router routed keine Dot-Ordner; mit beforeFiles-Phase
|
// (Next.js App Router routed keine Dot-Ordner; mit beforeFiles-Phase
|
||||||
// fangen wir den Pfad ab, bevor basePath zuschlägt.)
|
// fangen wir den Pfad ab, bevor basePath zuschlägt.)
|
||||||
@@ -70,4 +76,4 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
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();
|
const password = (process.argv[3] || process.env.SEED_ADMIN_PASSWORD || '').trim();
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
|
if (!email && !password) {
|
||||||
|
console.log('[seed] Keine Admin-Startdaten konfiguriert; überspringe Seed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error(
|
console.error(
|
||||||
'[seed] Verwendung: node prisma/seed-admin.cjs <email> <passwort>\n' +
|
'[seed] Verwendung: node prisma/seed-admin.cjs <email> <passwort>\n' +
|
||||||
' oder: SEED_ADMIN_EMAIL=… SEED_ADMIN_PASSWORD=… node prisma/seed-admin.cjs',
|
' oder: SEED_ADMIN_EMAIL=… SEED_ADMIN_PASSWORD=… node prisma/seed-admin.cjs',
|
||||||
@@ -75,4 +79,4 @@ seedAdmin()
|
|||||||
})
|
})
|
||||||
.finally(async function () {
|
.finally(async function () {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Unit-Tests fuer die Kernlogik (plan.md Abschnitt 6 und 19):
|
* Unit-Tests fuer die Kernlogik (plan.md Abschnitt 6 und 19):
|
||||||
* Kollisionspruefung, Invite-Tokens, Validierung, Berechtigungen.
|
* Kollisionspruefung, Validierung und Berechtigungen.
|
||||||
*
|
*
|
||||||
* Bewusst ohne Datenbank: Die Regeln sind reine Funktionen und
|
* Bewusst ohne Datenbank: Die Regeln sind reine Funktionen und
|
||||||
* damit ohne PostgreSQL testbar (schnelle Unit-Tests).
|
* damit ohne PostgreSQL testbar (schnelle Unit-Tests).
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { intervalsOverlap, findConflicts, validateReservationPeriod } from '@/lib/reservations/conflicts';
|
import { intervalsOverlap, findConflicts, validateReservationPeriod } from '@/lib/reservations/conflicts';
|
||||||
import { generateInviteToken, hashInviteToken, isInviteUsable, defaultInviteExpiry } from '@/lib/invites/tokens';
|
|
||||||
import { createReservationSchema } from '@/lib/validation';
|
import { createReservationSchema } from '@/lib/validation';
|
||||||
import { formatDuration } from '@/lib/reservations/service';
|
import { formatDuration } from '@/lib/reservations/service';
|
||||||
import {
|
import {
|
||||||
@@ -136,43 +135,6 @@ describe('validateReservationPeriod', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------------- Invites
|
|
||||||
|
|
||||||
describe('Invite-Tokens', () => {
|
|
||||||
it('erzeugt nicht erratbare Tokens', () => {
|
|
||||||
const token = generateInviteToken();
|
|
||||||
expect(token).toHaveLength(43); // 32 Bytes base64url
|
|
||||||
expect(token).not.toContain('=');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hasht deterministisch und speichert nie den Klartext', () => {
|
|
||||||
const token = generateInviteToken();
|
|
||||||
const hash = hashInviteToken(token);
|
|
||||||
expect(hash).toHaveLength(64);
|
|
||||||
expect(hash).not.toBe(token);
|
|
||||||
expect(hashInviteToken(token)).toBe(hash);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('erkennt abgelaufene und verwendete Invites', () => {
|
|
||||||
const now = new Date('2026-09-18T12:00:00Z');
|
|
||||||
expect(
|
|
||||||
isInviteUsable({ expiresAt: new Date('2026-09-01T00:00:00Z'), usedAt: null }, now),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
isInviteUsable({ expiresAt: null, usedAt: new Date('2026-09-01T00:00:00Z') }, now),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
isInviteUsable({ expiresAt: new Date('2026-09-30T00:00:00Z'), usedAt: null }, now),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('setzt die Standard-Ablaufzeit auf 7 Tage', () => {
|
|
||||||
const now = new Date('2026-09-18T12:00:00Z');
|
|
||||||
const expiry = defaultInviteExpiry(now);
|
|
||||||
expect(expiry.getTime() - now.getTime()).toBe(7 * 24 * 60 * 60 * 1000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Validierung
|
// ------------------------------------------------------------------ Validierung
|
||||||
|
|
||||||
describe('createReservationSchema', () => {
|
describe('createReservationSchema', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user