diff --git a/README.md b/README.md index 59ca058..8993497 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ State-of-the-art Kalenderapp für Vereine mit Admin-Freigaben, persönlichen Kal - Admins können Termine sofort freigeben oder Vorschläge bestätigen/ablehnen. - Mitglieder schlagen Termine vor; Freigaben laufen über das Admin-Panel. - Neue Registrierungen müssen durch Admins freigeschaltet werden. +- E-Mail-Verifizierung kann durch Superadmins optional deaktiviert werden. - Mehrere Kalenderansichten (Monat, Woche, Liste) mit FullCalendar. - Eine Standardansicht pro Benutzer mit iCal-Abonnement für externe Apps (iOS/Android). - Kategorien zur Strukturierung (nur Admins dürfen Kategorien anlegen). @@ -85,6 +86,8 @@ Admin-Konten werden sofort freigeschaltet, normale Mitglieder bleiben auf `PENDI - `POST /api/categories` - Kategorie anlegen (Admin) - `GET /api/users?status=PENDING` - Offene Registrierungen (Admin) - `PATCH /api/users` - Benutzer freischalten/ändern (Admin) +- `DELETE /api/users?id=...` - Benutzer deaktivieren (Admin) +- `DELETE /api/users?id=...&hard=true` - Benutzer endgültig löschen (Superadmin) - `GET /api/views` - Eigene Ansichten - `POST /api/views` - Ansicht erstellen - `POST /api/views/:id/items` - Termin zur Ansicht @@ -129,6 +132,7 @@ Unter `/settings` können Nutzer ihre E-Mail oder ihr Passwort ändern und den i ## Superadmin Superadmins sehen unter `/admin` zusätzlich die System-Einstellungen und können den Ortsanbieter (Google oder OpenStreetMap/Nominatim) konfigurieren. Außerdem kann die öffentliche Registrierung deaktiviert werden. +Zusätzlich kann die E-Mail-Verifizierung für neue Registrierungen ein- oder ausgeschaltet werden. Der App-Name kann ebenfalls dort gepflegt werden und wird in der Navigation sowie im iCal-Export verwendet. @@ -149,6 +153,7 @@ Wenn keine SMTP-Umgebung gesetzt ist, wird der Link im Server-Log ausgegeben. - Nach der Registrierung wird eine Verifizierungs-Mail gesendet (Token ist 24h gültig). - Verifizierungslink erneut senden unter `/verify`. +- Superadmins können die Verifizierung in den System-Einstellungen deaktivieren. ## Termin-Logik diff --git a/app/api/events/route.ts b/app/api/events/route.ts index 8648652..fa1f060 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -3,7 +3,7 @@ import { getServerSession } from "next-auth"; import { prisma } from "../../../lib/prisma"; import { isAdminSession, requireSession } from "../../../lib/auth-helpers"; import { authOptions } from "../../../lib/auth"; -import { getAccessSettings } from "../../../lib/system-settings"; +import { getAccessSettings, getEmailVerificationRequired } from "../../../lib/system-settings"; export async function GET(request: Request) { const session = await getServerSession(authOptions); @@ -13,7 +13,8 @@ export async function GET(request: Request) { { status: 403 } ); } - if (session?.user?.emailVerified === false) { + const emailVerificationRequired = await getEmailVerificationRequired(); + if (emailVerificationRequired && session?.user?.emailVerified === false) { return NextResponse.json( { error: "E-Mail nicht verifiziert." }, { status: 403 } diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index b05bb75..fecc59d 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from "next/server"; import { prisma } from "../../../lib/prisma"; import { requireSession } from "../../../lib/auth-helpers"; import { sendMail } from "../../../lib/mailer"; +import { getEmailVerificationRequired } from "../../../lib/system-settings"; export async function PATCH(request: Request) { const { session } = await requireSession(); @@ -36,6 +37,7 @@ export async function PATCH(request: Request) { } const data: { email?: string; passwordHash?: string; emailVerified?: boolean } = {}; + let emailVerificationRequired: boolean | null = null; if (normalizedEmail && normalizedEmail !== user.email) { const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } }); @@ -46,7 +48,8 @@ export async function PATCH(request: Request) { ); } data.email = normalizedEmail; - data.emailVerified = false; + emailVerificationRequired = await getEmailVerificationRequired(); + data.emailVerified = !emailVerificationRequired; } if (newPassword) { @@ -63,6 +66,17 @@ export async function PATCH(request: Request) { }); if (data.email) { + const verificationRequired = + emailVerificationRequired ?? (await getEmailVerificationRequired()); + if (!verificationRequired) { + return NextResponse.json({ + id: updated.id, + email: updated.email, + changedEmail: true, + changedPassword: Boolean(data.passwordHash) + }); + } + const token = crypto.randomUUID(); const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); await prisma.verificationToken.create({ diff --git a/app/api/register/route.ts b/app/api/register/route.ts index 37e0dcb..056982e 100644 --- a/app/api/register/route.ts +++ b/app/api/register/route.ts @@ -6,6 +6,7 @@ import { isAdminEmail, isSuperAdminEmail } from "../../../lib/auth"; import { sendMail } from "../../../lib/mailer"; import { checkRateLimit, getRateLimitConfig } from "../../../lib/rate-limit"; import { getClientIp } from "../../../lib/request"; +import { getEmailVerificationRequired } from "../../../lib/system-settings"; export async function POST(request: Request) { const registrationSetting = await prisma.setting.findUnique({ @@ -49,6 +50,9 @@ export async function POST(request: Request) { const passwordHash = await bcrypt.hash(password, 10); const superAdmin = isSuperAdminEmail(normalizedEmail); const admin = isAdminEmail(normalizedEmail) || superAdmin; + const emailVerificationRequired = await getEmailVerificationRequired(); + const shouldVerifyEmail = emailVerificationRequired && !admin; + const emailVerified = admin || !emailVerificationRequired; const user = await prisma.user.create({ data: { @@ -57,7 +61,7 @@ export async function POST(request: Request) { passwordHash, role: superAdmin ? "SUPERADMIN" : admin ? "ADMIN" : "USER", status: admin ? "ACTIVE" : "PENDING", - emailVerified: admin + emailVerified } }); @@ -82,7 +86,7 @@ export async function POST(request: Request) { }); } - if (!admin) { + if (shouldVerifyEmail) { const token = randomUUID(); const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); await prisma.verificationToken.create({ diff --git a/app/api/settings/registration/route.ts b/app/api/settings/registration/route.ts index 3871cbb..df3f434 100644 --- a/app/api/settings/registration/route.ts +++ b/app/api/settings/registration/route.ts @@ -4,9 +4,15 @@ import { prisma } from "../../../../lib/prisma"; export const dynamic = "force-dynamic"; export async function GET() { - const setting = await prisma.setting.findUnique({ - where: { key: "registration_enabled" } + const settings = await prisma.setting.findMany({ + where: { key: { in: ["registration_enabled", "email_verification_required"] } } + }); + const map = new Map(settings.map((record) => [record.key, record.value])); + const registrationEnabled = map.get("registration_enabled") !== "false"; + const emailVerificationRequired = + map.get("email_verification_required") !== "false"; + return NextResponse.json({ + registrationEnabled, + emailVerificationRequired }); - const registrationEnabled = setting?.value !== "false"; - return NextResponse.json({ registrationEnabled }); } diff --git a/app/api/settings/system/route.ts b/app/api/settings/system/route.ts index 15339e4..50072dc 100644 --- a/app/api/settings/system/route.ts +++ b/app/api/settings/system/route.ts @@ -27,7 +27,8 @@ export async function POST(request: Request) { apiKey, provider, registrationEnabled, - publicAccessEnabled + publicAccessEnabled, + emailVerificationRequired } = body || {}; if (!provider || !["google", "osm"].includes(provider)) { @@ -59,6 +60,14 @@ export async function POST(request: Request) { create: { key: "registration_enabled", value: registrationValue } }); + const verificationValue = + emailVerificationRequired === false ? "false" : "true"; + await prisma.setting.upsert({ + where: { key: "email_verification_required" }, + update: { value: verificationValue }, + create: { key: "email_verification_required", value: verificationValue } + }); + const existing = await getSystemSettings(); const nextPublicAccessEnabled = typeof publicAccessEnabled === "boolean" @@ -79,6 +88,7 @@ export async function POST(request: Request) { apiKey: apiKeySetting.value, provider: providerSetting.value, registrationEnabled: registrationValue !== "false", - publicAccessEnabled: nextPublicAccessEnabled + publicAccessEnabled: nextPublicAccessEnabled, + emailVerificationRequired: verificationValue !== "false" }); } diff --git a/app/api/users/route.ts b/app/api/users/route.ts index 93ec82c..216cac6 100644 --- a/app/api/users/route.ts +++ b/app/api/users/route.ts @@ -266,6 +266,7 @@ export async function DELETE(request: Request) { const { searchParams } = new URL(request.url); const userId = searchParams.get("id"); + const hardDelete = searchParams.get("hard") === "true"; if (!userId) { return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); @@ -291,6 +292,71 @@ export async function DELETE(request: Request) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } + if (hardDelete) { + if (!isSuperAdminSession(session)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { email: true } + }); + + if (!user) { + return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 }); + } + + const events = await prisma.event.findMany({ + where: { createdById: userId }, + select: { id: true } + }); + const eventIds = events.map((event) => event.id); + + const views = await prisma.userView.findMany({ + where: { userId }, + select: { id: true } + }); + const viewIds = views.map((view) => view.id); + + await prisma.$transaction(async (tx) => { + if (eventIds.length > 0) { + await tx.userViewItem.deleteMany({ + where: { eventId: { in: eventIds } } + }); + await tx.userViewExclusion.deleteMany({ + where: { eventId: { in: eventIds } } + }); + await tx.event.deleteMany({ + where: { id: { in: eventIds } } + }); + } + + if (viewIds.length > 0) { + await tx.userViewItem.deleteMany({ + where: { viewId: { in: viewIds } } + }); + await tx.userViewCategory.deleteMany({ + where: { viewId: { in: viewIds } } + }); + await tx.userViewExclusion.deleteMany({ + where: { viewId: { in: viewIds } } + }); + await tx.userView.deleteMany({ + where: { id: { in: viewIds } } + }); + } + + await tx.session.deleteMany({ where: { userId } }); + await tx.account.deleteMany({ where: { userId } }); + await tx.passwordResetToken.deleteMany({ where: { userId } }); + await tx.loginAttempt.deleteMany({ where: { email: user.email } }); + await tx.verificationToken.deleteMany({ where: { identifier: user.email } }); + await tx.user.delete({ where: { id: userId } }); + }); + + return NextResponse.json({ ok: true, deleted: true }); + } + await prisma.session.deleteMany({ where: { userId } }); await prisma.account.deleteMany({ where: { userId } }); diff --git a/app/login/page.tsx b/app/login/page.tsx index add4173..da8d38b 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,17 +1,25 @@ "use client"; -import { signIn } from "next-auth/react"; +import { getSession, signIn } from "next-auth/react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; export default function LoginPage() { - const router = useRouter(); const [error, setError] = useState(null); const [showVerifyLink, setShowVerifyLink] = useState(false); const [registrationEnabled, setRegistrationEnabled] = useState( null ); + const [registered, setRegistered] = useState(false); + const [prefillEmail, setPrefillEmail] = useState(""); + const [emailVerificationRequired, setEmailVerificationRequired] = useState(true); + + useEffect(() => { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + setRegistered(params.get("registered") === "1"); + setPrefillEmail(params.get("email") || ""); + }, []); useEffect(() => { const loadRegistration = async () => { @@ -20,8 +28,10 @@ export default function LoginPage() { if (!response.ok) return; const payload = await response.json(); setRegistrationEnabled(payload.registrationEnabled !== false); + setEmailVerificationRequired(payload.emailVerificationRequired !== false); } catch { setRegistrationEnabled(true); + setEmailVerificationRequired(true); } }; loadRegistration(); @@ -38,9 +48,15 @@ export default function LoginPage() { const result = await signIn("credentials", { email, password, - redirect: false + redirect: false, + callbackUrl: "/" }); + if (!result) { + setError("Login fehlgeschlagen."); + return; + } + if (result?.error) { if (result.error === "PENDING") { setError("Dein Konto wartet auf Freischaltung durch einen Admin."); @@ -65,7 +81,12 @@ export default function LoginPage() { if (result?.ok) { setShowVerifyLink(false); - router.push("/"); + const session = await getSession(); + if (!session?.user) { + setError("Login fehlgeschlagen."); + return; + } + window.location.href = result.url || "/"; } }; @@ -75,12 +96,20 @@ export default function LoginPage() {

Bitte anmelden.

+ {registered && ( +
+ {emailVerificationRequired + ? "Account erstellt. Bitte E-Mail bestätigen und auf die Freischaltung durch einen Admin warten." + : "Account erstellt. Bitte auf die Freischaltung durch einen Admin warten."} +
+ )}
{isBlocked ? ( diff --git a/app/register/page.tsx b/app/register/page.tsx index 89550e9..5b2bd9a 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -1,12 +1,11 @@ "use client"; import Link from "next/link"; -import { signIn } from "next-auth/react"; import { useEffect, useState } from "react"; export default function RegisterPage() { const [error, setError] = useState(null); - const [done, setDone] = useState(false); + const [showVerifyLink, setShowVerifyLink] = useState(false); const [registrationEnabled, setRegistrationEnabled] = useState( null ); @@ -28,6 +27,7 @@ export default function RegisterPage() { const onSubmit = async (event: React.FormEvent) => { event.preventDefault(); setError(null); + setShowVerifyLink(false); const formData = new FormData(event.currentTarget); const payload = { @@ -48,12 +48,11 @@ export default function RegisterPage() { return; } - setDone(true); - await signIn("credentials", { - email: payload.email, - password: payload.password, - callbackUrl: "/" - }); + const emailValue = String(payload.email || "").trim().toLowerCase(); + const nextUrl = emailValue + ? `/login?registered=1&email=${encodeURIComponent(emailValue)}` + : "/login?registered=1"; + window.location.href = nextUrl; }; return ( @@ -94,15 +93,20 @@ export default function RegisterPage() { Konto anlegen - {done && ( -

Account erstellt.

- )} {error &&

{error}

} )}

Schon registriert? Login

+ {showVerifyLink && ( +

+ E-Mail nicht bestätigt?{" "} + + Link erneut senden + +

+ )} ); } diff --git a/components/AdminSystemSettings.tsx b/components/AdminSystemSettings.tsx index 703918e..1cb2c60 100644 --- a/components/AdminSystemSettings.tsx +++ b/components/AdminSystemSettings.tsx @@ -7,6 +7,7 @@ export default function AdminSystemSettings() { const [provider, setProvider] = useState("osm"); const [registrationEnabled, setRegistrationEnabled] = useState(true); const [publicAccessEnabled, setPublicAccessEnabled] = useState(true); + const [emailVerificationRequired, setEmailVerificationRequired] = useState(true); const [appName, setAppName] = useState("Vereinskalender"); const [logoFile, setLogoFile] = useState(null); const [logoVersion, setLogoVersion] = useState(() => Date.now()); @@ -28,6 +29,7 @@ export default function AdminSystemSettings() { setProvider(payload.provider || "osm"); setRegistrationEnabled(payload.registrationEnabled !== false); setPublicAccessEnabled(payload.publicAccessEnabled !== false); + setEmailVerificationRequired(payload.emailVerificationRequired !== false); if (appNameResponse.ok) { const appPayload = await appNameResponse.json(); setAppName(appPayload.name || "Vereinskalender"); @@ -67,7 +69,8 @@ export default function AdminSystemSettings() { apiKey, provider, registrationEnabled, - publicAccessEnabled + publicAccessEnabled, + emailVerificationRequired }) }), fetch("/api/settings/app-name", { @@ -252,6 +255,16 @@ export default function AdminSystemSettings() { /> Registrierung erlauben + + )} + + + {event.locationLat && event.locationLng && ( + + + + )} + + + ); + }) + )} + +
@@ -2551,9 +2678,9 @@ function SortIcon({ ); } -function IconMapPin() { +function IconMapPin({ className = "h-4 w-4" }: { className?: string }) { return ( - + diff --git a/lib/auth-helpers.ts b/lib/auth-helpers.ts index a9f6c5c..a6d25b1 100644 --- a/lib/auth-helpers.ts +++ b/lib/auth-helpers.ts @@ -1,6 +1,7 @@ import { getServerSession } from "next-auth"; import { NextResponse } from "next/server"; import { authOptions } from "./auth"; +import { getEmailVerificationRequired } from "./system-settings"; export async function requireSession() { const session = await getServerSession(authOptions); @@ -13,7 +14,8 @@ export async function requireSession() { response: NextResponse.json({ error: "Account nicht freigeschaltet." }, { status: 403 }) }; } - if (session.user.emailVerified === false) { + const emailVerificationRequired = await getEmailVerificationRequired(); + if (emailVerificationRequired && session.user.emailVerified === false) { return { session: null, response: NextResponse.json({ error: "E-Mail nicht verifiziert." }, { status: 403 }) diff --git a/lib/auth.ts b/lib/auth.ts index b30a20e..209a118 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -4,6 +4,7 @@ import type { NextAuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import { prisma } from "./prisma"; import { checkRateLimit, getRateLimitConfig } from "./rate-limit"; +import { getEmailVerificationRequired } from "./system-settings"; const MAX_LOGIN_ATTEMPTS = 5; const LOGIN_WINDOW_MINUTES = 15; @@ -139,7 +140,8 @@ export const authOptions: NextAuthOptions = { throw new Error("PENDING"); } - if (!user.emailVerified) { + const emailVerificationRequired = await getEmailVerificationRequired(); + if (emailVerificationRequired && !user.emailVerified) { throw new Error("EMAIL_NOT_VERIFIED"); } diff --git a/lib/system-settings.ts b/lib/system-settings.ts index 43125e4..0ebcf8b 100644 --- a/lib/system-settings.ts +++ b/lib/system-settings.ts @@ -8,9 +8,11 @@ export type SystemSettings = AccessSettings & { apiKey: string; provider: "google" | "osm"; registrationEnabled: boolean; + emailVerificationRequired: boolean; }; const PUBLIC_ACCESS_KEY = "public_access_enabled"; +const EMAIL_VERIFICATION_KEY = "email_verification_required"; const LEGACY_ACCESS_KEYS = [ "public_events_enabled", "anonymous_access_enabled" @@ -18,7 +20,8 @@ const LEGACY_ACCESS_KEYS = [ const SYSTEM_KEYS = [ "google_places_api_key", "geocoding_provider", - "registration_enabled" + "registration_enabled", + EMAIL_VERIFICATION_KEY ] as const; const getSettingMap = async (keys: readonly string[]) => { @@ -96,12 +99,24 @@ export async function getSystemSettings(): Promise { settings.get("registration_enabled"), true ); + const emailVerificationRequired = readBoolean( + settings.get(EMAIL_VERIFICATION_KEY), + true + ); const publicAccessEnabled = await ensurePublicAccessSetting(settings); return { apiKey, provider, registrationEnabled, - publicAccessEnabled + publicAccessEnabled, + emailVerificationRequired }; } + +export async function getEmailVerificationRequired(): Promise { + const setting = await prisma.setting.findUnique({ + where: { key: EMAIL_VERIFICATION_KEY } + }); + return setting?.value !== "false"; +}