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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,65 +0,0 @@
/**
* Invite-Annahmeseite (plan.md Abschnitt 3).
*
* Serverkomponente: prueft den Token serverseitig und zeigt den
* Kalendertitel. Danach Registrierung mit dem Token in der URL.
*/
import Link from 'next/link';
import { prisma } from '@/lib/db/client';
import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens';
import { Card } from '@/components/ui/ui';
interface PageProps {
params: Promise<{ token: string }>;
}
export default async function InvitePage({ params }: PageProps) {
const { token } = await params;
const tokenHash = hashInviteToken(token);
const invite = await prisma.invite.findUnique({
where: { tokenHash },
select: {
expiresAt: true,
usedAt: true,
calendar: { select: { title: true, description: true } },
},
});
if (!invite || !isInviteUsable(invite)) {
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
<Card>
<h1 className="mb-2 text-xl font-bold">Einladung ungueltig</h1>
<p className="text-sm text-slate-600">
Dieser Einladungslink ist ungueltig oder abgelaufen. Bitte wende dich an
den Administrator.
</p>
</Card>
</main>
);
}
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
<Card>
<h1 className="mb-2 text-xl font-bold">Einladung</h1>
<p className="mb-4 text-sm text-slate-600">
Du wurdest zum Kalender{' '}
<strong>{invite.calendar.title}</strong> eingeladen.
{invite.calendar.description && (
<span className="mt-1 block text-slate-500">
{invite.calendar.description}
</span>
)}
</p>
<Link
href={`/register?token=${encodeURIComponent(token)}`}
className="inline-block rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
>
Jetzt registrieren
</Link>
</Card>
</main>
);
}

View File

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

View File

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