diff --git a/.dockerignore b/.dockerignore
index 5086180..aa486b6 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -4,4 +4,5 @@ node_modules
.env
.env*.local
*.md
-tests
\ No newline at end of file
+tests
+.env*
diff --git a/.env.example b/.env.example
index fcff9f9..ac24a14 100644
--- a/.env.example
+++ b/.env.example
@@ -1,22 +1,19 @@
-# PostgreSQL-Verbindung (in docker-compose.yml automatisch korrekt gesetzt)
-DATABASE_URL="postgresql://calendar_app:calendar_app@localhost:5432/calendar?schema=public"
+# Nur für den eigenständigen Betrieb außerhalb von MPM.
+# In MPM werden dieselben Werte sicher über den Konfigurationsdialog gesetzt.
-# Session-Signierung – mindestens 32 Zeichen. Erzeugen z. B. mit:
-# node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
-# WICHTIG: Ohne gesetztes SESSION_SECRET startet die App NICHT mehr
-# (docker-compose.yml hat keinen unsicheren Fallback mehr).
+# Zufällige alphanumerische Passwörter verwenden (mindestens 32 Zeichen).
+CALENDAR_ADMIN_PASSWORD="change-this-database-admin-password"
+CALENDAR_DB_PASSWORD="change-this-calendar-database-password"
+
+# Mindestens 32 Zeichen, z. B. mit `openssl rand -base64 48` erzeugen.
SESSION_SECRET="change-me-change-me-change-me-change-me"
-# Basis-URL der Anwendung (fuer Invite-Links)
-APP_URL="http://localhost:3000"
+# Nur für lokale Entwicklung ohne Compose; Passwort URL-kodieren.
+# DATABASE_URL="postgresql://calendar_app:PASSWORT@127.0.0.1:5432/calendar?schema=public"
-# "false" = Registrierung nur ueber Invite-Links moeglich
+# Standalone-Optionen
+APP_PORT="3000"
ALLOW_OPEN_REGISTRATION="true"
-
-# Optional: Admin-Account beim ersten Start automatisch anlegen.
-# Nicht-destruktiv (wie bei Gitea/Vaultwarden): Das Seed-Script legt
-# den Admin nur an, wenn noch keiner existiert. Bestehende User
-# werden niemals geloescht. Wenn die Variablen nicht gesetzt sind,
-# laeuft das Script ohne Nebeneffekte durch.
+# HUB_BASE_PATH="/kalendartool"
# SEED_ADMIN_EMAIL="admin@example.com"
-# SEED_ADMIN_PASSWORD="SicheresPasswort10"
\ No newline at end of file
+# SEED_ADMIN_PASSWORD="change-this-admin-password"
diff --git a/Dockerfile b/Dockerfile
index 9e47140..5b42f6d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,6 +5,11 @@
# zurueck und der Client kann nicht initialisiert werden.
FROM node:20-bookworm-slim AS builder
WORKDIR /app
+ARG HUB_BASE_PATH=""
+ARG MPM_AUTH_MODE="false"
+ENV HUB_BASE_PATH=${HUB_BASE_PATH}
+ENV MPM_AUTH_MODE=${MPM_AUTH_MODE}
+ENV NEXT_PUBLIC_APP_BASE_PATH=${HUB_BASE_PATH}
COPY package.json package-lock.json ./
# ci statt install: reproduzierbarer Build aus dem Lockfile
@@ -50,16 +55,13 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modul
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
# bcryptjs fuer das Admin-Seed-Script (manuell ausfuehrbar, nicht beim Start)
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/bcryptjs ./node_modules/bcryptjs
+COPY --from=builder --chown=nextjs:nodejs /app/container-entrypoint.cjs ./container-entrypoint.cjs
+COPY --from=builder --chown=nextjs:nodejs /app/module-runtime.js ./module-runtime.js
+COPY --from=builder --chown=nextjs:nodejs /app/platform-module-sdk.cjs ./platform-module-sdk.cjs
USER nextjs
-EXPOSE 3000
+EXPOSE 3000 41030
-# Migrationen beim Start anwenden, optional Admin seeden, dann Server starten.
-# prisma migrate deploy ist idempotent und sicher fuer wiederholte Starts.
-# Das Seed-Script ist NICHT-destruktiv: Es legt den Admin nur an, wenn
-# noch keiner existiert (First-Run-Detection). Bestehende User werden
-# niemals geloescht. Wenn SEED_ADMIN_* nicht gesetzt sind, laeuft es
-# ohne Nebeneffekte durch.
-# Direkter node-Aufruf statt npx: im Standalone-Output existiert
-# node_modules/.bin nicht, daher wuerde "npx prisma" mit "not found" fehlschlagen.
-CMD ["sh", "-c", "node node_modules/prisma/build/index.js migrate deploy && node prisma/seed-admin.cjs && node server.js"]
\ No newline at end of file
+# Der gemeinsame Entrypoint setzt die Datenbankverbindung, wendet
+# Migrationen an und startet anschließend Standalone- oder MPM-Betrieb.
+CMD ["node", "container-entrypoint.cjs"]
diff --git a/Dockerfile.postgres b/Dockerfile.postgres
new file mode 100644
index 0000000..4f865c8
--- /dev/null
+++ b/Dockerfile.postgres
@@ -0,0 +1,3 @@
+FROM postgres:16-alpine
+COPY docker/postgres/init/01-app-user.sh /docker-entrypoint-initdb.d/01-app-user.sh
+RUN chmod 0555 /docker-entrypoint-initdb.d/01-app-user.sh
diff --git a/README.md b/README.md
index ced997b..b0ad5d8 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,13 @@
# Kalendartool
-Einfaches Kalender- und Reservierungstool nach `plan.md`: Ein Admin legt Kalender an und lädt Benutzer per Invite-Link ein. Jeder User sieht **ausschließlich seinen einen Kalender** und kann dort Reservierungen erstellen.
+Einfaches Kalender- und Reservierungstool: Ein Admin legt Kalender an und ordnet vorhandene Benutzer direkt zu. Jeder User sieht **ausschließlich seinen einen Kalender** und kann dort Reservierungen erstellen.
## Kernfunktionen (MVP)
- **Auth**: Registrierung, Login, Logout, Passwort vergessen – bcrypt-Hashing, signierte HttpOnly-Session-Cookies, Rate-Limiting beim Login
- **Rollen**: `ADMIN` (0..n Kalender) und `USER` (genau 1 Kalender) – serverseitig erzwungen
- **Kalender**: Titel, Beschreibung, Zeitzone, aktiv/inaktiv
-- **Invite-System**: nicht erratbare Tokens (32 Bytes), nur SHA-256-Hash in der DB, Ablaufdatum, Übersicht Aktiv/Abgelaufen/Verwendet
+- **Mitglieder**: Administratoren ordnen vorhandene Benutzer direkt einem Kalender zu
- **Reservierungen**: Startzeit + Dauer (15-Minuten-Schritte), Titel, Notiz
- **Doppelbuchungsschutz**: atomare Prüfung in serialisierbarer Transaktion (HTTP 409 bei Kollision)
- **Ansichten**: Tag / Woche / Monat
@@ -17,16 +17,40 @@ Einfaches Kalender- und Reservierungstool nach `plan.md`: Ein Admin legt Kalende
Next.js 14 (App Router, TypeScript) · PostgreSQL · Prisma · Tailwind CSS · Docker · Vitest
-## Schnellstart mit Docker (empfohlen)
+## MPM Marketplace
+
+Das Repository ist als MPM-Modul vorbereitet. `module.json` beschreibt das
+Modul und seine Konfigurationsfelder; `compose.yml` enthält App, PostgreSQL
+und tägliche Backups. Die Modul-App veröffentlicht keinen Host-Port. MPM
+erreicht sie intern über Port `41030` und die Health-Route `/health`.
+
+Die Anmeldung und Rollen kommen im MPM-Betrieb signiert vom MPM-Gateway.
+Die lokale Cookie-Anmeldung bleibt für den eigenständigen Betrieb erhalten.
+Kalender und Reservierungen liegen in PostgreSQL. Die benannten Volumes
+`pgdata` und `pgbackups` bleiben beim Entfernen des Moduls erhalten.
+
+Bei der ersten MPM-Konfiguration werden zwei getrennte, zufällige Passwörter
+für PostgreSQL-Administration und App-Zugriff benötigt. Die Anmeldung läuft
+über MPM; lokale Konten und Einladungslinks sind im MPM-Betrieb deaktiviert.
+
+Benutzer und Modulzugriff werden in MPM verwaltet. Öffne das Modul zunächst
+einmal mit dem neuen Benutzer, damit sein lokales Profil angelegt wird; ein
+Kalenderadministrator kann ihn danach direkt einem Kalender zuordnen. Die
+Datenbank-Initialisierung wird nur bei einem leeren Volume ausgeführt. Ein
+späteres Ändern der Datenbankpasswörter im MPM-Konfigurationsdialog ändert die
+Zugangsdaten eines bestehenden PostgreSQL-Volumes nicht automatisch.
+
+## Schnellstart mit Docker (eigenständiger Betrieb)
```bash
# 1. Session-Secret erzeugen und .env anlegen
cp .env.example .env
-# SESSION_SECRET in .env setzen (mindestens 32 Zeichen):
+# CALENDAR_ADMIN_PASSWORD, CALENDAR_DB_PASSWORD und SESSION_SECRET in .env
+# durch zufällige Werte ersetzen. SESSION_SECRET braucht mindestens 32 Zeichen:
node -e "console.log('SESSION_SECRET=' + require('crypto').randomBytes(48).toString('base64url'))"
# 2. Stack starten (PostgreSQL + App)
-docker compose up --build
+docker compose -f docker-compose.yml up --build
```
Die App läuft dann auf http://localhost:3000. Migrationen werden beim Containerstart automatisch angewendet (`prisma migrate deploy`).
@@ -35,7 +59,7 @@ Die App läuft dann auf http://localhost:3000. Migrationen werden beim Container
```bash
npm install
-cp .env.example .env # DATABASE_URL und SESSION_SECRET anpassen
+cp .env.example .env # Passwörter und SESSION_SECRET anpassen
npx prisma db push # Schema in die Datenbank schreiben
npm run dev
```
@@ -46,30 +70,27 @@ Voraussetzung: laufendes PostgreSQL (z. B. nur der `db`-Service aus `docker-comp
1. Erste Registrierung unter `/register` → der **erste Benutzer wird automatisch ADMIN**
2. Im Dashboard einen Kalender anlegen
-3. Unter „Benutzer einladen" einen Invite-Link erstellen und verschicken
-4. Eingeladene Personen registrieren sich über den Link und sehen sofort nur diesen einen Kalender
+3. Im Kalender unter „Mitglieder" vorhandene Benutzer zuordnen
## Projektstruktur
```
app/ Next.js App Router (Seiten + API-Routen)
api/auth/ register, login, logout, forgot-password
- api/calendars/ CRUD + Reservierungen + Invites
+ api/calendars/ CRUD + Reservierungen + Mitglieder
api/reservations/ Bearbeiten/Löschen (atomar)
dashboard/ User: 1 Kalender · Admin: alle Kalender
calendar/[id]/ Ansicht Tag/Woche/Monat
- invite/[token]/ Invite-Annahme
admin/ Kalenderverwaltung
components/ UI-, Kalender-, Reservierungs-, Admin-Komponenten
lib/
auth/ Session (JWT-Cookies), Passwort-Hashing
permissions/ zentrale Berechtigungsschicht
reservations/ Konfliktlogik + atomarer Service
- invites/ Token-Erzeugung/Hashing
api/ Response-Helfer, Kontext-Lader
validation.ts Zod-Schemas
prisma/schema.prisma Datenmodell
-tests/ Unit-Tests (Kollisionen, Invites, Rechte)
+tests/ Unit-Tests (Kollisionen, Validierung, Rechte)
```
## Tests
@@ -78,7 +99,7 @@ tests/ Unit-Tests (Kollisionen, Invites, Rechte)
npm test
```
-Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus plan.md Abschnitt 6), Invite-Token-Hashing/-Ablauf, Eingabevalidierung und die komplette Berechtigungsmatrix.
+Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus plan.md Abschnitt 6), Eingabevalidierung und die komplette Berechtigungsmatrix.
## Sicherheitskonzept (Kurzfassung)
@@ -86,7 +107,6 @@ Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus
| --- | --- |
| Doppelbuchungen | `SERIALIZABLE`-Transaktion in `lib/reservations/service.ts` |
| Berechtigungen | zentrale Funktionen in `lib/permissions/permissions.ts`, geprüft in **jeder** API-Route |
-| Invite-Tokens | 32 Byte Zufall, nur SHA-256-Hash in der DB |
| Passwörter | bcrypt (12 Runden), nie im Klartext |
| Sessions | HS256-signierte JWTs in HttpOnly/SameSite-Cookies |
| Login-Missbrauch | Rate-Limiting (5 Versuche / 15 min) |
@@ -95,4 +115,4 @@ Getestet werden insbesondere die **Kollisionslogik** (Überlappungsvarianten aus
## Bewusste MVP-Grenzen (plan.md Abschnitt 18)
-Buchungszeitfenster-Regeln, E-Mail-Versand, ICS-Export, wiederkehrende Reservierungen und Statistiken sind bewusst **nicht** Teil von Version 1 – die Struktur (Service-Schicht, Regeln in `lib/`) ist darauf vorbereitet.
\ No newline at end of file
+Buchungszeitfenster-Regeln, E-Mail-Versand, ICS-Export, wiederkehrende Reservierungen und Statistiken sind bewusst **nicht** Teil von Version 1 – die Struktur (Service-Schicht, Regeln in `lib/`) ist darauf vorbereitet.
diff --git a/app/admin/calendars/[id]/page.tsx b/app/admin/calendars/[id]/page.tsx
index 74b1309..fb05d45 100644
--- a/app/admin/calendars/[id]/page.tsx
+++ b/app/admin/calendars/[id]/page.tsx
@@ -1,6 +1,6 @@
/**
* Admin: Kalenderverwaltung (plan.md Abschnitt 13: /admin/calendars/[id]).
- * Bearbeiten, deaktivieren, Invite-Links erstellen, Benutzer einsehen.
+ * Bearbeiten, deaktivieren und Mitglieder verwalten.
*/
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/db/client';
@@ -9,7 +9,7 @@ import { canManageCalendar } from '@/lib/permissions/permissions';
import { displayName } from '@/lib/privacy/display';
import { Card } from '@/components/ui/ui';
import { EditCalendarForm } from '@/components/admin/EditCalendarForm';
-import { InviteManager } from '@/components/admin/InviteManager';
+import { MemberSection } from '@/components/admin/MemberSection';
import { AppShell } from '@/components/layout/AppShell';
interface PageProps {
@@ -26,29 +26,18 @@ export default async function AdminCalendarPage({ params }: PageProps) {
redirect('/dashboard');
}
- const [members, invites, reservations] = await Promise.all([
- prisma.calendarMember.findMany({
- where: { calendarId: id },
- select: { user: { select: { email: true, username: true, createdAt: true } } },
- }),
- prisma.invite.findMany({
- where: { calendarId: id },
- orderBy: { createdAt: 'desc' },
- select: { id: true, expiresAt: true, usedAt: true, createdAt: true },
- }),
- prisma.reservation.findMany({
- where: { calendarId: id, status: 'CONFIRMED' },
- orderBy: { startAt: 'desc' },
- take: 50,
- select: {
- id: true,
- title: true,
- startAt: true,
- endAt: true,
- user: { select: { email: true, username: true } },
- },
- }),
- ]);
+ const reservations = await prisma.reservation.findMany({
+ where: { calendarId: id, status: 'CONFIRMED' },
+ orderBy: { startAt: 'desc' },
+ take: 50,
+ select: {
+ id: true,
+ title: true,
+ startAt: true,
+ endAt: true,
+ user: { select: { email: true, username: true } },
+ },
+ });
return (
@@ -62,28 +51,8 @@ export default async function AdminCalendarPage({ params }: PageProps) {
initialIsActive={context.calendar.isActive}
/>
-
-
-
Mitglieder ({members.length})
- {members.length === 0 ? (
-
- Noch keine Mitglieder. Erstelle einen Invite-Link.
-
- )}
+
@@ -112,4 +81,4 @@ export default async function AdminCalendarPage({ params }: PageProps) {
);
-}
\ No newline at end of file
+}
diff --git a/app/admin/calendars/page.tsx b/app/admin/calendars/page.tsx
index 31919c3..a87e262 100644
--- a/app/admin/calendars/page.tsx
+++ b/app/admin/calendars/page.tsx
@@ -2,7 +2,7 @@
* Admin: Kalenderverwaltung (Cards-Ansicht).
* Zeigt alle Kalender als Karten - eigene und die anderer Benutzer.
* Verwaltung direkt im Bearbeiten-Modal oder auf der Detailseite
- * /admin/calendars/[id] (Invites, Mitglieder, Reservierungen).
+ * /admin/calendars/[id] (Mitglieder, Reservierungen).
*/
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/db/client';
@@ -55,4 +55,4 @@ export default async function ManageCalendarsPage() {
/>
);
-}
\ No newline at end of file
+}
diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts
index 90dd17a..03c2e6b 100644
--- a/app/api/auth/register/route.ts
+++ b/app/api/auth/register/route.ts
@@ -1,17 +1,15 @@
/**
* POST /api/auth/register
*
- * Registrierung (plan.md Abschnitt 11). Zwei Wege:
- * 1. Mit Invite-Token -> User wird dem Kalender des Invites zugeordnet.
- * 2. Ohne Token -> nur wenn ALLOW_OPEN_REGISTRATION=true; der erste
+ * Lokale Registrierung für den eigenständigen Betrieb. Sie ist nur
+ * verfügbar, wenn ALLOW_OPEN_REGISTRATION=true; der erste
* Benutzer wird ADMIN, weitere offene Registrierungen werden
* dann automatisch zu USER ohne Kalender (oder abgelehnt, je
* nach Konfiguration).
*
* Sicherheit:
* - Rate-Limit pro IP (Brute-Force-/Enumeration-Schutz).
- * - SERIALIZABLE-Transaktion: Ein Invite kann trotz paralleler
- * Registrierungen nur EINMAL verwendet werden; der "erste Benutzer
+ * - SERIALIZABLE-Transaktion: Der "erste Benutzer
* wird ADMIN"-Wettlauf kann nicht mehrere Admins erzeugen.
* - Bei bereits registrierter E-Mail wird bewusst mit 400 und einer
* generischen Meldung geantwortet (kein 409), damit die Antwort
@@ -22,7 +20,6 @@ import { prisma } from '@/lib/db/client';
import { hashPassword } from '@/lib/auth/password';
import { createSessionToken, setSessionCookie } from '@/lib/auth/session';
import { registerSchema } from '@/lib/validation';
-import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens';
import { getConfig } from '@/lib/config';
import { errorResponse } from '@/lib/api/responses';
import {
@@ -65,39 +62,9 @@ export async function POST(request: NextRequest): Promise {
const passwordHash = await hashPassword(body.password);
const createdUser = await withSerializableTx(async (tx) => {
- let calendarId: string | null = null;
-
- if (body.inviteToken) {
- const tokenHash = hashInviteToken(body.inviteToken);
- const invite = await tx.invite.findUnique({
- where: { tokenHash },
- select: { id: true, calendarId: true, expiresAt: true, usedAt: true },
- });
- if (!invite || !isInviteUsable(invite)) {
- throw new RegistrationError(
- 'Dieser Einladungslink ist ungueltig oder abgelaufen.',
- 400,
- );
- }
- calendarId = invite.calendarId;
-
- // Bedingungsloses Update: schlaegt fehl, wenn eine parallele
- // Transaktion den Invite inzwischen verbraucht hat (SERIALIZABLE
- // bricht eine der beiden ab; der Retry sieht dann usedAt und
- // bricht hier sauber ab).
- const updated = await tx.invite.updateMany({
- where: { id: invite.id, usedAt: null },
- data: { usedAt: new Date() },
- });
- if (updated.count === 0) {
- throw new RegistrationError(
- 'Dieser Einladungslink ist ungueltig oder abgelaufen.',
- 400,
- );
- }
- } else if (!config.allowOpenRegistration) {
+ if (!config.allowOpenRegistration) {
throw new RegistrationError(
- 'Registrierung ist nur ueber Einladungslinks moeglich.',
+ 'Registrierung ist derzeit deaktiviert.',
403,
);
}
@@ -114,9 +81,6 @@ export async function POST(request: NextRequest): Promise {
passwordHash,
role,
...(body.username ? { username: body.username } : {}),
- ...(calendarId
- ? { memberships: { create: { calendarId } } }
- : {}),
},
select: { id: true, email: true, role: true, username: true },
});
@@ -156,4 +120,4 @@ export async function POST(request: NextRequest): Promise {
}
return errorResponse(error);
}
-}
\ No newline at end of file
+}
diff --git a/app/api/calendars/[id]/invites/route.ts b/app/api/calendars/[id]/invites/route.ts
deleted file mode 100644
index c9581a9..0000000
--- a/app/api/calendars/[id]/invites/route.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * POST /api/calendars/[id]/invites - Invite-Link erstellen (nur Owner-Admin).
- * GET /api/calendars/[id]/invites - Invite-Uebersicht (Aktiv/Abgelaufen/Verwendet).
- */
-import { NextRequest, NextResponse } from 'next/server';
-import { prisma } from '@/lib/db/client';
-import { requireCalendarContext } from '@/lib/api/context';
-import { createInviteSchema } from '@/lib/validation';
-import { canManageCalendar } from '@/lib/permissions/permissions';
-import {
- generateInviteToken,
- hashInviteToken,
- defaultInviteExpiry,
-} from '@/lib/invites/tokens';
-import { getConfig } from '@/lib/config';
-import { errorResponse, forbiddenResponse } from '@/lib/api/responses';
-
-interface RouteParams {
- params: Promise<{ id: string }>;
-}
-
-export async function POST(
- request: NextRequest,
- { params }: RouteParams,
-): Promise {
- try {
- const { id } = await params;
- const context = await requireCalendarContext(id);
-
- if (!canManageCalendar(context.user, context.calendar)) {
- return forbiddenResponse();
- }
-
- const body = createInviteSchema.parse(await request.json());
- const expiry = body.expiresInDays
- ? new Date(Date.now() + body.expiresInDays * 24 * 60 * 60 * 1000)
- : defaultInviteExpiry();
-
- // Klartext-Token wird genau einmal zurueckgegeben; in der DB
- // liegt ausschliesslich der Hash (plan.md Abschnitt 9).
- const token = generateInviteToken();
- const invite = await prisma.invite.create({
- data: {
- calendarId: id,
- tokenHash: hashInviteToken(token),
- expiresAt: expiry,
- },
- select: { id: true, expiresAt: true, createdAt: true },
- });
-
- return NextResponse.json(
- {
- invite,
- inviteUrl: `${getConfig().appUrl}/invite/${token}`,
- },
- { status: 201 },
- );
- } catch (error) {
- return errorResponse(error);
- }
-}
-
-export async function GET(
- _request: NextRequest,
- { params }: RouteParams,
-): Promise {
- try {
- const { id } = await params;
- const context = await requireCalendarContext(id);
-
- if (!canManageCalendar(context.user, context.calendar)) {
- return forbiddenResponse();
- }
-
- const invites = await prisma.invite.findMany({
- where: { calendarId: id },
- orderBy: { createdAt: 'desc' },
- select: { id: true, expiresAt: true, usedAt: true, createdAt: true },
- });
-
- const now = new Date();
- const summary = {
- active: invites.filter(
- (invite) =>
- invite.usedAt === null &&
- (invite.expiresAt === null || invite.expiresAt > now),
- ).length,
- expired: invites.filter(
- (invite) => invite.usedAt === null && invite.expiresAt !== null && invite.expiresAt <= now,
- ).length,
- used: invites.filter((invite) => invite.usedAt !== null).length,
- };
-
- return NextResponse.json({ invites, summary });
- } catch (error) {
- return errorResponse(error);
- }
-}
\ No newline at end of file
diff --git a/app/api/calendars/[id]/members/route.ts b/app/api/calendars/[id]/members/route.ts
index 851592c..93cf190 100644
--- a/app/api/calendars/[id]/members/route.ts
+++ b/app/api/calendars/[id]/members/route.ts
@@ -3,8 +3,8 @@
* POST /api/calendars/[id]/members - User per E-Mail hinzufuegen (nur Admin).
* DELETE /api/calendars/[id]/members - Mitglied entfernen (nur Admin).
*
- * DB-basiertes Einladungssystem: Statt Invite-Links werden Benutzer
- * direkt per E-Mail-Suche ueber die Datenbank zugeordnet. Der Server
+ * Benutzer werden direkt per E-Mail-Suche ueber die Datenbank
+ * zugeordnet. Der Server
* prueft die Verwaltungsberechtigung zentral (canManageCalendar).
*/
import { NextRequest, NextResponse } from 'next/server';
@@ -141,4 +141,4 @@ export async function DELETE(
} catch (error) {
return errorResponse(error);
}
-}
\ No newline at end of file
+}
diff --git a/app/api/invites/[token]/route.ts b/app/api/invites/[token]/route.ts
deleted file mode 100644
index 1b0da5f..0000000
--- a/app/api/invites/[token]/route.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * GET /api/invites/[token] - Invite-Informationen fuer die
- * Annahme-Seite: "Du wurdest zum Kalender X eingeladen."
- *
- * Oeffentlich zugaenglich (vor der Registrierung), gibt aber nur
- * den Kalendertitel preis - niemals Reservierungen oder Mitglieder.
- */
-import { NextResponse } from 'next/server';
-import { prisma } from '@/lib/db/client';
-import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens';
-import { errorResponse, notFoundResponse } from '@/lib/api/responses';
-
-interface RouteParams {
- params: Promise<{ token: string }>;
-}
-
-export async function GET(
- _request: Request,
- { params }: RouteParams,
-): Promise {
- try {
- const { token } = await params;
- const tokenHash = hashInviteToken(token);
-
- const invite = await prisma.invite.findUnique({
- where: { tokenHash },
- select: {
- expiresAt: true,
- usedAt: true,
- calendar: { select: { title: true, description: true } },
- },
- });
-
- if (!invite || !isInviteUsable(invite)) {
- return notFoundResponse('Dieser Einladungslink ist ungueltig oder abgelaufen.');
- }
-
- return NextResponse.json({
- calendarTitle: invite.calendar.title,
- calendarDescription: invite.calendar.description,
- });
- } catch (error) {
- return errorResponse(error);
- }
-}
\ No newline at end of file
diff --git a/app/api/manifest/route.ts b/app/api/manifest/route.ts
index 29e9623..2caf414 100644
--- a/app/api/manifest/route.ts
+++ b/app/api/manifest/route.ts
@@ -10,16 +10,17 @@ import { NextResponse } from "next/server";
export async function GET() {
const basePath = (process.env.HUB_BASE_PATH ?? "").replace(/\/$/, "");
+ const mpmMode = process.env.MPM_AUTH_MODE === "true";
return NextResponse.json(
{
name: "Kalendertool",
- slug: "kalender",
- version: "1.0",
+ slug: "kalendartool",
+ version: "1.1.0",
icon: "/icon.png",
- auth: { mode: "hub-token", path: "/auth/hub" },
- basePath: basePath || "/apps/kalender",
+ auth: mpmMode ? { mode: "mpm-gateway" } : { mode: "hub-token", path: "/auth/hub" },
+ basePath: basePath || "/",
},
{ headers: { "Cache-Control": "public, max-age=60" } }
);
-}
\ No newline at end of file
+}
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index 68fa3e7..7403b5d 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -108,7 +108,7 @@ export default async function DashboardPage() {
Du bist noch keinem Kalender zugeordnet. Bitte wende dich an den
- Administrator fuer einen Einladungslink.
+ Administrator, damit er dich einem Kalender zuordnet.
) : (
@@ -143,4 +143,4 @@ export default async function DashboardPage() {
)}
);
-}
\ No newline at end of file
+}
diff --git a/app/forgot-password/page.tsx b/app/forgot-password/page.tsx
index 22e19af..27554b4 100644
--- a/app/forgot-password/page.tsx
+++ b/app/forgot-password/page.tsx
@@ -4,6 +4,7 @@
* "Passwort vergessen"-Seite (plan.md Abschnitt 11).
* Antwortet immer mit Erfolgsmeldung (kein Account-Enumeration).
*/
+import { appFetch } from '@/lib/client-fetch';
import { useState } from 'react';
import Link from 'next/link';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
@@ -20,7 +21,7 @@ export default function ForgotPasswordPage() {
const formData = new FormData(event.currentTarget);
try {
- const response = await fetch('/api/auth/forgot-password', {
+ const response = await appFetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: formData.get('email') }),
diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx
deleted file mode 100644
index 0065268..0000000
--- a/app/invite/[token]/page.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-/**
- * Invite-Annahmeseite (plan.md Abschnitt 3).
- *
- * Serverkomponente: prueft den Token serverseitig und zeigt den
- * Kalendertitel. Danach Registrierung mit dem Token in der URL.
- */
-import Link from 'next/link';
-import { prisma } from '@/lib/db/client';
-import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens';
-import { Card } from '@/components/ui/ui';
-
-interface PageProps {
- params: Promise<{ token: string }>;
-}
-
-export default async function InvitePage({ params }: PageProps) {
- const { token } = await params;
- const tokenHash = hashInviteToken(token);
-
- const invite = await prisma.invite.findUnique({
- where: { tokenHash },
- select: {
- expiresAt: true,
- usedAt: true,
- calendar: { select: { title: true, description: true } },
- },
- });
-
- if (!invite || !isInviteUsable(invite)) {
- return (
-
-
-
Einladung ungueltig
-
- Dieser Einladungslink ist ungueltig oder abgelaufen. Bitte wende dich an
- den Administrator.
-
-
-
- );
- }
-
- return (
-
-
-
Einladung
-
- Du wurdest zum Kalender{' '}
- {invite.calendar.title} eingeladen.
- {invite.calendar.description && (
-
- {invite.calendar.description}
-
- )}
-
-
- Jetzt registrieren
-
-
-
- );
-}
\ No newline at end of file
diff --git a/app/login/page.tsx b/app/login/page.tsx
index 75f4c5f..73f7644 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -3,6 +3,7 @@
/**
* Anmeldeseite (plan.md Abschnitt 11).
*/
+import { appFetch } from '@/lib/client-fetch';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
@@ -20,7 +21,7 @@ export default function LoginPage() {
const formData = new FormData(event.currentTarget);
try {
- const response = await fetch('/api/auth/login', {
+ const response = await appFetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
diff --git a/app/register/page.tsx b/app/register/page.tsx
index 33354a2..bff4c1a 100644
--- a/app/register/page.tsx
+++ b/app/register/page.tsx
@@ -1,18 +1,16 @@
'use client';
/**
- * Registrierungsseite (plan.md Abschnitt 3 und 11).
- * Mit Invite-Token (aus URL) wird dem Kalender automatisch zugeordnet.
+ * Lokale Registrierungsseite für den eigenständigen Betrieb.
*/
-import { Suspense, useState } from 'react';
-import { useRouter, useSearchParams } from 'next/navigation';
+import { appFetch } from '@/lib/client-fetch';
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
function RegisterForm() {
const router = useRouter();
- const searchParams = useSearchParams();
- const inviteToken = searchParams.get('token') ?? '';
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
@@ -35,14 +33,13 @@ function RegisterForm() {
try {
const username = String(formData.get('username') ?? '').trim();
- const response = await fetch('/api/auth/register', {
+ const response = await appFetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.get('email'),
password,
...(username ? { username } : {}),
- ...(inviteToken ? { inviteToken } : {}),
}),
});
@@ -62,12 +59,6 @@ function RegisterForm() {
return (
- {inviteToken && (
-
- Du wurdest zu einem Kalender eingeladen. Nach der Registrierung siehst du
- ausschliesslich diesen Kalender.
-