Prepare Kalendartool for MPM and disable invites
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* POST /api/calendars/[id]/invites - Invite-Link erstellen (nur Owner-Admin).
|
||||
* GET /api/calendars/[id]/invites - Invite-Uebersicht (Aktiv/Abgelaufen/Verwendet).
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db/client';
|
||||
import { requireCalendarContext } from '@/lib/api/context';
|
||||
import { createInviteSchema } from '@/lib/validation';
|
||||
import { canManageCalendar } from '@/lib/permissions/permissions';
|
||||
import {
|
||||
generateInviteToken,
|
||||
hashInviteToken,
|
||||
defaultInviteExpiry,
|
||||
} from '@/lib/invites/tokens';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { errorResponse, forbiddenResponse } from '@/lib/api/responses';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const context = await requireCalendarContext(id);
|
||||
|
||||
if (!canManageCalendar(context.user, context.calendar)) {
|
||||
return forbiddenResponse();
|
||||
}
|
||||
|
||||
const body = createInviteSchema.parse(await request.json());
|
||||
const expiry = body.expiresInDays
|
||||
? new Date(Date.now() + body.expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: defaultInviteExpiry();
|
||||
|
||||
// Klartext-Token wird genau einmal zurueckgegeben; in der DB
|
||||
// liegt ausschliesslich der Hash (plan.md Abschnitt 9).
|
||||
const token = generateInviteToken();
|
||||
const invite = await prisma.invite.create({
|
||||
data: {
|
||||
calendarId: id,
|
||||
tokenHash: hashInviteToken(token),
|
||||
expiresAt: expiry,
|
||||
},
|
||||
select: { id: true, expiresAt: true, createdAt: true },
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
invite,
|
||||
inviteUrl: `${getConfig().appUrl}/invite/${token}`,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const context = await requireCalendarContext(id);
|
||||
|
||||
if (!canManageCalendar(context.user, context.calendar)) {
|
||||
return forbiddenResponse();
|
||||
}
|
||||
|
||||
const invites = await prisma.invite.findMany({
|
||||
where: { calendarId: id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, expiresAt: true, usedAt: true, createdAt: true },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const summary = {
|
||||
active: invites.filter(
|
||||
(invite) =>
|
||||
invite.usedAt === null &&
|
||||
(invite.expiresAt === null || invite.expiresAt > now),
|
||||
).length,
|
||||
expired: invites.filter(
|
||||
(invite) => invite.usedAt === null && invite.expiresAt !== null && invite.expiresAt <= now,
|
||||
).length,
|
||||
used: invites.filter((invite) => invite.usedAt !== null).length,
|
||||
};
|
||||
|
||||
return NextResponse.json({ invites, summary });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
* POST /api/calendars/[id]/members - User per E-Mail hinzufuegen (nur Admin).
|
||||
* DELETE /api/calendars/[id]/members - Mitglied entfernen (nur Admin).
|
||||
*
|
||||
* DB-basiertes Einladungssystem: Statt Invite-Links werden Benutzer
|
||||
* direkt per E-Mail-Suche ueber die Datenbank zugeordnet. Der Server
|
||||
* Benutzer werden direkt per E-Mail-Suche ueber die Datenbank
|
||||
* zugeordnet. Der Server
|
||||
* prueft die Verwaltungsberechtigung zentral (canManageCalendar).
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
@@ -141,4 +141,4 @@ export async function DELETE(
|
||||
} catch (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() {
|
||||
const basePath = (process.env.HUB_BASE_PATH ?? "").replace(/\/$/, "");
|
||||
const mpmMode = process.env.MPM_AUTH_MODE === "true";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
name: "Kalendertool",
|
||||
slug: "kalender",
|
||||
version: "1.0",
|
||||
slug: "kalendartool",
|
||||
version: "1.1.0",
|
||||
icon: "/icon.png",
|
||||
auth: { mode: "hub-token", path: "/auth/hub" },
|
||||
basePath: basePath || "/apps/kalender",
|
||||
auth: mpmMode ? { mode: "mpm-gateway" } : { mode: "hub-token", path: "/auth/hub" },
|
||||
basePath: basePath || "/",
|
||||
},
|
||||
{ headers: { "Cache-Control": "public, max-age=60" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export default async function DashboardPage() {
|
||||
<Card>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Du bist noch keinem Kalender zugeordnet. Bitte wende dich an den
|
||||
Administrator fuer einen Einladungslink.
|
||||
Administrator, damit er dich einem Kalender zuordnet.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -143,4 +143,4 @@ export default async function DashboardPage() {
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* "Passwort vergessen"-Seite (plan.md Abschnitt 11).
|
||||
* Antwortet immer mit Erfolgsmeldung (kein Account-Enumeration).
|
||||
*/
|
||||
import { appFetch } from '@/lib/client-fetch';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||
@@ -20,7 +21,7 @@ export default function ForgotPasswordPage() {
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
try {
|
||||
const response = await fetch('/api/auth/forgot-password', {
|
||||
const response = await appFetch('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: formData.get('email') }),
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Invite-Annahmeseite (plan.md Abschnitt 3).
|
||||
*
|
||||
* Serverkomponente: prueft den Token serverseitig und zeigt den
|
||||
* Kalendertitel. Danach Registrierung mit dem Token in der URL.
|
||||
*/
|
||||
import Link from 'next/link';
|
||||
import { prisma } from '@/lib/db/client';
|
||||
import { hashInviteToken, isInviteUsable } from '@/lib/invites/tokens';
|
||||
import { Card } from '@/components/ui/ui';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
export default async function InvitePage({ params }: PageProps) {
|
||||
const { token } = await params;
|
||||
const tokenHash = hashInviteToken(token);
|
||||
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: { tokenHash },
|
||||
select: {
|
||||
expiresAt: true,
|
||||
usedAt: true,
|
||||
calendar: { select: { title: true, description: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite || !isInviteUsable(invite)) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
|
||||
<Card>
|
||||
<h1 className="mb-2 text-xl font-bold">Einladung ungueltig</h1>
|
||||
<p className="text-sm text-slate-600">
|
||||
Dieser Einladungslink ist ungueltig oder abgelaufen. Bitte wende dich an
|
||||
den Administrator.
|
||||
</p>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
|
||||
<Card>
|
||||
<h1 className="mb-2 text-xl font-bold">Einladung</h1>
|
||||
<p className="mb-4 text-sm text-slate-600">
|
||||
Du wurdest zum Kalender{' '}
|
||||
<strong>{invite.calendar.title}</strong> eingeladen.
|
||||
{invite.calendar.description && (
|
||||
<span className="mt-1 block text-slate-500">
|
||||
{invite.calendar.description}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<Link
|
||||
href={`/register?token=${encodeURIComponent(token)}`}
|
||||
className="inline-block rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
||||
>
|
||||
Jetzt registrieren
|
||||
</Link>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
/**
|
||||
* Anmeldeseite (plan.md Abschnitt 11).
|
||||
*/
|
||||
import { appFetch } from '@/lib/client-fetch';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
@@ -20,7 +21,7 @@ export default function LoginPage() {
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
const response = await appFetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Registrierungsseite (plan.md Abschnitt 3 und 11).
|
||||
* Mit Invite-Token (aus URL) wird dem Kalender automatisch zugeordnet.
|
||||
* Lokale Registrierungsseite für den eigenständigen Betrieb.
|
||||
*/
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { appFetch } from '@/lib/client-fetch';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Button, Card, ErrorMessage, Input, Label } from '@/components/ui/ui';
|
||||
|
||||
function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const inviteToken = searchParams.get('token') ?? '';
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -35,14 +33,13 @@ function RegisterForm() {
|
||||
try {
|
||||
const username = String(formData.get('username') ?? '').trim();
|
||||
|
||||
const response = await fetch('/api/auth/register', {
|
||||
const response = await appFetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: formData.get('email'),
|
||||
password,
|
||||
...(username ? { username } : {}),
|
||||
...(inviteToken ? { inviteToken } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -62,12 +59,6 @@ function RegisterForm() {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{inviteToken && (
|
||||
<p className="mb-4 rounded-md bg-slate-100 px-3 py-2 text-sm text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
Du wurdest zu einem Kalender eingeladen. Nach der Registrierung siehst du
|
||||
ausschliesslich diesen Kalender.
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<ErrorMessage message={error} />
|
||||
<div>
|
||||
@@ -128,9 +119,7 @@ export default function RegisterPage() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold">Konto erstellen</h1>
|
||||
<Suspense fallback={<Card>Wird geladen …</Card>}>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
<RegisterForm />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user