Prepare Kalendartool for MPM and disable invites

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

View File

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

View File

@@ -1,98 +0,0 @@
/**
* POST /api/calendars/[id]/invites - Invite-Link erstellen (nur Owner-Admin).
* GET /api/calendars/[id]/invites - Invite-Uebersicht (Aktiv/Abgelaufen/Verwendet).
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db/client';
import { requireCalendarContext } from '@/lib/api/context';
import { createInviteSchema } from '@/lib/validation';
import { canManageCalendar } from '@/lib/permissions/permissions';
import {
generateInviteToken,
hashInviteToken,
defaultInviteExpiry,
} from '@/lib/invites/tokens';
import { getConfig } from '@/lib/config';
import { errorResponse, forbiddenResponse } from '@/lib/api/responses';
interface RouteParams {
params: Promise<{ id: string }>;
}
export async function POST(
request: NextRequest,
{ params }: RouteParams,
): Promise<NextResponse> {
try {
const { id } = await params;
const context = await requireCalendarContext(id);
if (!canManageCalendar(context.user, context.calendar)) {
return forbiddenResponse();
}
const body = createInviteSchema.parse(await request.json());
const expiry = body.expiresInDays
? new Date(Date.now() + body.expiresInDays * 24 * 60 * 60 * 1000)
: defaultInviteExpiry();
// Klartext-Token wird genau einmal zurueckgegeben; in der DB
// liegt ausschliesslich der Hash (plan.md Abschnitt 9).
const token = generateInviteToken();
const invite = await prisma.invite.create({
data: {
calendarId: id,
tokenHash: hashInviteToken(token),
expiresAt: expiry,
},
select: { id: true, expiresAt: true, createdAt: true },
});
return NextResponse.json(
{
invite,
inviteUrl: `${getConfig().appUrl}/invite/${token}`,
},
{ status: 201 },
);
} catch (error) {
return errorResponse(error);
}
}
export async function GET(
_request: NextRequest,
{ params }: RouteParams,
): Promise<NextResponse> {
try {
const { id } = await params;
const context = await requireCalendarContext(id);
if (!canManageCalendar(context.user, context.calendar)) {
return forbiddenResponse();
}
const invites = await prisma.invite.findMany({
where: { calendarId: id },
orderBy: { createdAt: 'desc' },
select: { id: true, expiresAt: true, usedAt: true, createdAt: true },
});
const now = new Date();
const summary = {
active: invites.filter(
(invite) =>
invite.usedAt === null &&
(invite.expiresAt === null || invite.expiresAt > now),
).length,
expired: invites.filter(
(invite) => invite.usedAt === null && invite.expiresAt !== null && invite.expiresAt <= now,
).length,
used: invites.filter((invite) => invite.usedAt !== null).length,
};
return NextResponse.json({ invites, summary });
} catch (error) {
return errorResponse(error);
}
}

View File

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

View File

@@ -1,45 +0,0 @@
/**
* GET /api/invites/[token] - Invite-Informationen fuer die
* Annahme-Seite: "Du wurdest zum Kalender X eingeladen."
*
* Oeffentlich zugaenglich (vor der Registrierung), gibt aber nur
* den Kalendertitel preis - niemals Reservierungen oder Mitglieder.
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db/client';
import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens';
import { errorResponse, notFoundResponse } from '@/lib/api/responses';
interface RouteParams {
params: Promise<{ token: string }>;
}
export async function GET(
_request: Request,
{ params }: RouteParams,
): Promise<NextResponse> {
try {
const { token } = await params;
const tokenHash = hashInviteToken(token);
const invite = await prisma.invite.findUnique({
where: { tokenHash },
select: {
expiresAt: true,
usedAt: true,
calendar: { select: { title: true, description: true } },
},
});
if (!invite || !isInviteUsable(invite)) {
return notFoundResponse('Dieser Einladungslink ist ungueltig oder abgelaufen.');
}
return NextResponse.json({
calendarTitle: invite.calendar.title,
calendarDescription: invite.calendar.description,
});
} catch (error) {
return errorResponse(error);
}
}

View File

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