Aktueller Stand

This commit is contained in:
2026-01-18 00:40:01 +01:00
parent 68b63b8f06
commit 31aef02558
16 changed files with 352 additions and 43 deletions

View File

@@ -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. - Admins können Termine sofort freigeben oder Vorschläge bestätigen/ablehnen.
- Mitglieder schlagen Termine vor; Freigaben laufen über das Admin-Panel. - Mitglieder schlagen Termine vor; Freigaben laufen über das Admin-Panel.
- Neue Registrierungen müssen durch Admins freigeschaltet werden. - Neue Registrierungen müssen durch Admins freigeschaltet werden.
- E-Mail-Verifizierung kann durch Superadmins optional deaktiviert werden.
- Mehrere Kalenderansichten (Monat, Woche, Liste) mit FullCalendar. - Mehrere Kalenderansichten (Monat, Woche, Liste) mit FullCalendar.
- Eine Standardansicht pro Benutzer mit iCal-Abonnement für externe Apps (iOS/Android). - Eine Standardansicht pro Benutzer mit iCal-Abonnement für externe Apps (iOS/Android).
- Kategorien zur Strukturierung (nur Admins dürfen Kategorien anlegen). - 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) - `POST /api/categories` - Kategorie anlegen (Admin)
- `GET /api/users?status=PENDING` - Offene Registrierungen (Admin) - `GET /api/users?status=PENDING` - Offene Registrierungen (Admin)
- `PATCH /api/users` - Benutzer freischalten/ändern (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 - `GET /api/views` - Eigene Ansichten
- `POST /api/views` - Ansicht erstellen - `POST /api/views` - Ansicht erstellen
- `POST /api/views/:id/items` - Termin zur Ansicht - `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 ## 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. 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. 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). - Nach der Registrierung wird eine Verifizierungs-Mail gesendet (Token ist 24h gültig).
- Verifizierungslink erneut senden unter `/verify`. - Verifizierungslink erneut senden unter `/verify`.
- Superadmins können die Verifizierung in den System-Einstellungen deaktivieren.
## Termin-Logik ## Termin-Logik

View File

@@ -3,7 +3,7 @@ import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma"; import { prisma } from "../../../lib/prisma";
import { isAdminSession, requireSession } from "../../../lib/auth-helpers"; import { isAdminSession, requireSession } from "../../../lib/auth-helpers";
import { authOptions } from "../../../lib/auth"; import { authOptions } from "../../../lib/auth";
import { getAccessSettings } from "../../../lib/system-settings"; import { getAccessSettings, getEmailVerificationRequired } from "../../../lib/system-settings";
export async function GET(request: Request) { export async function GET(request: Request) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
@@ -13,7 +13,8 @@ export async function GET(request: Request) {
{ status: 403 } { status: 403 }
); );
} }
if (session?.user?.emailVerified === false) { const emailVerificationRequired = await getEmailVerificationRequired();
if (emailVerificationRequired && session?.user?.emailVerified === false) {
return NextResponse.json( return NextResponse.json(
{ error: "E-Mail nicht verifiziert." }, { error: "E-Mail nicht verifiziert." },
{ status: 403 } { status: 403 }

View File

@@ -4,6 +4,7 @@ import { NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma"; import { prisma } from "../../../lib/prisma";
import { requireSession } from "../../../lib/auth-helpers"; import { requireSession } from "../../../lib/auth-helpers";
import { sendMail } from "../../../lib/mailer"; import { sendMail } from "../../../lib/mailer";
import { getEmailVerificationRequired } from "../../../lib/system-settings";
export async function PATCH(request: Request) { export async function PATCH(request: Request) {
const { session } = await requireSession(); const { session } = await requireSession();
@@ -36,6 +37,7 @@ export async function PATCH(request: Request) {
} }
const data: { email?: string; passwordHash?: string; emailVerified?: boolean } = {}; const data: { email?: string; passwordHash?: string; emailVerified?: boolean } = {};
let emailVerificationRequired: boolean | null = null;
if (normalizedEmail && normalizedEmail !== user.email) { if (normalizedEmail && normalizedEmail !== user.email) {
const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } }); const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } });
@@ -46,7 +48,8 @@ export async function PATCH(request: Request) {
); );
} }
data.email = normalizedEmail; data.email = normalizedEmail;
data.emailVerified = false; emailVerificationRequired = await getEmailVerificationRequired();
data.emailVerified = !emailVerificationRequired;
} }
if (newPassword) { if (newPassword) {
@@ -63,6 +66,17 @@ export async function PATCH(request: Request) {
}); });
if (data.email) { 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 token = crypto.randomUUID();
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
await prisma.verificationToken.create({ await prisma.verificationToken.create({

View File

@@ -6,6 +6,7 @@ import { isAdminEmail, isSuperAdminEmail } from "../../../lib/auth";
import { sendMail } from "../../../lib/mailer"; import { sendMail } from "../../../lib/mailer";
import { checkRateLimit, getRateLimitConfig } from "../../../lib/rate-limit"; import { checkRateLimit, getRateLimitConfig } from "../../../lib/rate-limit";
import { getClientIp } from "../../../lib/request"; import { getClientIp } from "../../../lib/request";
import { getEmailVerificationRequired } from "../../../lib/system-settings";
export async function POST(request: Request) { export async function POST(request: Request) {
const registrationSetting = await prisma.setting.findUnique({ const registrationSetting = await prisma.setting.findUnique({
@@ -49,6 +50,9 @@ export async function POST(request: Request) {
const passwordHash = await bcrypt.hash(password, 10); const passwordHash = await bcrypt.hash(password, 10);
const superAdmin = isSuperAdminEmail(normalizedEmail); const superAdmin = isSuperAdminEmail(normalizedEmail);
const admin = isAdminEmail(normalizedEmail) || superAdmin; const admin = isAdminEmail(normalizedEmail) || superAdmin;
const emailVerificationRequired = await getEmailVerificationRequired();
const shouldVerifyEmail = emailVerificationRequired && !admin;
const emailVerified = admin || !emailVerificationRequired;
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
@@ -57,7 +61,7 @@ export async function POST(request: Request) {
passwordHash, passwordHash,
role: superAdmin ? "SUPERADMIN" : admin ? "ADMIN" : "USER", role: superAdmin ? "SUPERADMIN" : admin ? "ADMIN" : "USER",
status: admin ? "ACTIVE" : "PENDING", 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 token = randomUUID();
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
await prisma.verificationToken.create({ await prisma.verificationToken.create({

View File

@@ -4,9 +4,15 @@ import { prisma } from "../../../../lib/prisma";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET() { export async function GET() {
const setting = await prisma.setting.findUnique({ const settings = await prisma.setting.findMany({
where: { key: "registration_enabled" } 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 });
} }

View File

@@ -27,7 +27,8 @@ export async function POST(request: Request) {
apiKey, apiKey,
provider, provider,
registrationEnabled, registrationEnabled,
publicAccessEnabled publicAccessEnabled,
emailVerificationRequired
} = body || {}; } = body || {};
if (!provider || !["google", "osm"].includes(provider)) { if (!provider || !["google", "osm"].includes(provider)) {
@@ -59,6 +60,14 @@ export async function POST(request: Request) {
create: { key: "registration_enabled", value: registrationValue } 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 existing = await getSystemSettings();
const nextPublicAccessEnabled = const nextPublicAccessEnabled =
typeof publicAccessEnabled === "boolean" typeof publicAccessEnabled === "boolean"
@@ -79,6 +88,7 @@ export async function POST(request: Request) {
apiKey: apiKeySetting.value, apiKey: apiKeySetting.value,
provider: providerSetting.value, provider: providerSetting.value,
registrationEnabled: registrationValue !== "false", registrationEnabled: registrationValue !== "false",
publicAccessEnabled: nextPublicAccessEnabled publicAccessEnabled: nextPublicAccessEnabled,
emailVerificationRequired: verificationValue !== "false"
}); });
} }

View File

@@ -266,6 +266,7 @@ export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const userId = searchParams.get("id"); const userId = searchParams.get("id");
const hardDelete = searchParams.get("hard") === "true";
if (!userId) { if (!userId) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); 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 }); 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.session.deleteMany({ where: { userId } });
await prisma.account.deleteMany({ where: { userId } }); await prisma.account.deleteMany({ where: { userId } });

View File

@@ -1,17 +1,25 @@
"use client"; "use client";
import { signIn } from "next-auth/react"; import { getSession, signIn } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showVerifyLink, setShowVerifyLink] = useState(false); const [showVerifyLink, setShowVerifyLink] = useState(false);
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>( const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(
null 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(() => { useEffect(() => {
const loadRegistration = async () => { const loadRegistration = async () => {
@@ -20,8 +28,10 @@ export default function LoginPage() {
if (!response.ok) return; if (!response.ok) return;
const payload = await response.json(); const payload = await response.json();
setRegistrationEnabled(payload.registrationEnabled !== false); setRegistrationEnabled(payload.registrationEnabled !== false);
setEmailVerificationRequired(payload.emailVerificationRequired !== false);
} catch { } catch {
setRegistrationEnabled(true); setRegistrationEnabled(true);
setEmailVerificationRequired(true);
} }
}; };
loadRegistration(); loadRegistration();
@@ -38,9 +48,15 @@ export default function LoginPage() {
const result = await signIn("credentials", { const result = await signIn("credentials", {
email, email,
password, password,
redirect: false redirect: false,
callbackUrl: "/"
}); });
if (!result) {
setError("Login fehlgeschlagen.");
return;
}
if (result?.error) { if (result?.error) {
if (result.error === "PENDING") { if (result.error === "PENDING") {
setError("Dein Konto wartet auf Freischaltung durch einen Admin."); setError("Dein Konto wartet auf Freischaltung durch einen Admin.");
@@ -65,7 +81,12 @@ export default function LoginPage() {
if (result?.ok) { if (result?.ok) {
setShowVerifyLink(false); 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() {
<p className="mt-1 text-sm text-slate-600"> <p className="mt-1 text-sm text-slate-600">
Bitte anmelden. Bitte anmelden.
</p> </p>
{registered && (
<div className="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
{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."}
</div>
)}
<form onSubmit={onSubmit} className="mt-4 space-y-3"> <form onSubmit={onSubmit} className="mt-4 space-y-3">
<input <input
name="email" name="email"
type="email" type="email"
placeholder="E-Mail" placeholder="E-Mail"
required required
defaultValue={prefillEmail}
className="w-full rounded-xl border border-slate-300 px-3 py-2" className="w-full rounded-xl border border-slate-300 px-3 py-2"
/> />
<input <input

View File

@@ -2,17 +2,19 @@ import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import CalendarBoard from "../components/CalendarBoard"; import CalendarBoard from "../components/CalendarBoard";
import { authOptions } from "../lib/auth"; import { authOptions } from "../lib/auth";
import { getAccessSettings } from "../lib/system-settings"; import { getAccessSettings, getEmailVerificationRequired } from "../lib/system-settings";
export default async function HomePage() { export default async function HomePage() {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const { publicAccessEnabled } = await getAccessSettings(); const { publicAccessEnabled } = await getAccessSettings();
const emailVerificationRequired = await getEmailVerificationRequired();
if (!session?.user && !publicAccessEnabled) { if (!session?.user && !publicAccessEnabled) {
redirect("/login"); redirect("/login");
} }
const isBlocked = const isBlocked =
session?.user && session?.user &&
(session.user.status !== "ACTIVE" || session.user.emailVerified === false); (session.user.status !== "ACTIVE" ||
(emailVerificationRequired && session.user.emailVerified === false));
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{isBlocked ? ( {isBlocked ? (

View File

@@ -1,12 +1,11 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { signIn } from "next-auth/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export default function RegisterPage() { export default function RegisterPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false); const [showVerifyLink, setShowVerifyLink] = useState(false);
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>( const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(
null null
); );
@@ -28,6 +27,7 @@ export default function RegisterPage() {
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
setShowVerifyLink(false);
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
const payload = { const payload = {
@@ -48,12 +48,11 @@ export default function RegisterPage() {
return; return;
} }
setDone(true); const emailValue = String(payload.email || "").trim().toLowerCase();
await signIn("credentials", { const nextUrl = emailValue
email: payload.email, ? `/login?registered=1&email=${encodeURIComponent(emailValue)}`
password: payload.password, : "/login?registered=1";
callbackUrl: "/" window.location.href = nextUrl;
});
}; };
return ( return (
@@ -94,15 +93,20 @@ export default function RegisterPage() {
Konto anlegen Konto anlegen
</button> </button>
</form> </form>
{done && (
<p className="mt-3 text-sm text-emerald-600">Account erstellt.</p>
)}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>} {error && <p className="mt-3 text-sm text-red-600">{error}</p>}
</> </>
)} )}
<p className="mt-4 text-sm text-slate-600"> <p className="mt-4 text-sm text-slate-600">
Schon registriert? <Link href="/login" className="text-brand-700">Login</Link> Schon registriert? <Link href="/login" className="text-brand-700">Login</Link>
</p> </p>
{showVerifyLink && (
<p className="mt-2 text-sm text-slate-600">
E-Mail nicht bestätigt?{" "}
<Link href="/verify" className="text-brand-700">
Link erneut senden
</Link>
</p>
)}
</div> </div>
); );
} }

View File

@@ -7,6 +7,7 @@ export default function AdminSystemSettings() {
const [provider, setProvider] = useState("osm"); const [provider, setProvider] = useState("osm");
const [registrationEnabled, setRegistrationEnabled] = useState(true); const [registrationEnabled, setRegistrationEnabled] = useState(true);
const [publicAccessEnabled, setPublicAccessEnabled] = useState(true); const [publicAccessEnabled, setPublicAccessEnabled] = useState(true);
const [emailVerificationRequired, setEmailVerificationRequired] = useState(true);
const [appName, setAppName] = useState("Vereinskalender"); const [appName, setAppName] = useState("Vereinskalender");
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoVersion, setLogoVersion] = useState(() => Date.now()); const [logoVersion, setLogoVersion] = useState(() => Date.now());
@@ -28,6 +29,7 @@ export default function AdminSystemSettings() {
setProvider(payload.provider || "osm"); setProvider(payload.provider || "osm");
setRegistrationEnabled(payload.registrationEnabled !== false); setRegistrationEnabled(payload.registrationEnabled !== false);
setPublicAccessEnabled(payload.publicAccessEnabled !== false); setPublicAccessEnabled(payload.publicAccessEnabled !== false);
setEmailVerificationRequired(payload.emailVerificationRequired !== false);
if (appNameResponse.ok) { if (appNameResponse.ok) {
const appPayload = await appNameResponse.json(); const appPayload = await appNameResponse.json();
setAppName(appPayload.name || "Vereinskalender"); setAppName(appPayload.name || "Vereinskalender");
@@ -67,7 +69,8 @@ export default function AdminSystemSettings() {
apiKey, apiKey,
provider, provider,
registrationEnabled, registrationEnabled,
publicAccessEnabled publicAccessEnabled,
emailVerificationRequired
}) })
}), }),
fetch("/api/settings/app-name", { fetch("/api/settings/app-name", {
@@ -252,6 +255,16 @@ export default function AdminSystemSettings() {
/> />
Registrierung erlauben Registrierung erlauben
</label> </label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={emailVerificationRequired}
onChange={(event) =>
setEmailVerificationRequired(event.target.checked)
}
/>
E-Mail-Verifizierung erforderlich
</label>
</div> </div>
</div> </div>
<button type="submit" className="btn-accent"> <button type="submit" className="btn-accent">

View File

@@ -140,11 +140,20 @@ export default function AdminUserApprovals({ role }: AdminUserApprovalsProps) {
setEditingUser(null); setEditingUser(null);
}; };
const removeUser = async (user: UserItem) => { const removeUser = async (user: UserItem, mode: "disable" | "delete" = "disable") => {
const ok = window.confirm(`Benutzer ${user.email} deaktivieren?`); if (mode === "delete" && !isSuperAdmin) {
setError("Nur Superadmins können Benutzer endgültig löschen.");
return;
}
const ok = window.confirm(
mode === "delete"
? `Benutzer ${user.email} endgültig löschen? Alle Daten werden entfernt.`
: `Benutzer ${user.email} deaktivieren?`
);
if (!ok) return; if (!ok) return;
const response = await fetch(`/api/users?id=${user.id}`, { const hardParam = mode === "delete" ? "&hard=true" : "";
const response = await fetch(`/api/users?id=${user.id}${hardParam}`, {
method: "DELETE" method: "DELETE"
}); });
@@ -154,7 +163,7 @@ export default function AdminUserApprovals({ role }: AdminUserApprovalsProps) {
return; return;
} }
setStatus("Benutzer deaktiviert."); setStatus(mode === "delete" ? "Benutzer gelöscht." : "Benutzer deaktiviert.");
loadPending(); loadPending();
loadAll(); loadAll();
}; };
@@ -303,8 +312,8 @@ export default function AdminUserApprovals({ role }: AdminUserApprovalsProps) {
</IconButton> </IconButton>
)} )}
<IconButton <IconButton
label="Deaktivieren" label={isSuperAdmin ? "Löschen" : "Deaktivieren"}
onClick={() => removeUser(user)} onClick={() => removeUser(user, isSuperAdmin ? "delete" : "disable")}
> >
<IconTrash /> <IconTrash />
</IconButton> </IconButton>

View File

@@ -1582,7 +1582,134 @@ export default function CalendarBoard() {
</div> </div>
)} )}
</div> </div>
<div className="overflow-x-auto"> <div className="md:hidden space-y-2">
{displayedEvents.length === 0 ? (
<p className="rounded-xl border border-slate-200 bg-white px-3 py-4 text-sm text-slate-600">
Keine Termine für die aktuelle Auswahl.
</p>
) : (
displayedEvents.map((event) => {
const categoryName = event.category?.name || "Ohne Kategorie";
const locationLabel = formatLocation(event.location);
const dateLabel = new Date(event.startAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "short"
});
const bucket = getDateBucket(event.startAt);
const bucketClass =
bucket === "past"
? "bg-slate-50 text-slate-500"
: bucket === "today"
? "bg-amber-50/60"
: bucket === "tomorrow"
? "bg-emerald-50/60"
: "bg-sky-50/40";
return (
<div
key={event.id}
className={`rounded-xl border border-slate-200 p-2 ${bucketClass}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-0.5">
<p className="text-[11px] uppercase tracking-[0.2em] text-slate-500">
{dateLabel}
</p>
<p className="text-sm font-semibold" title={event.title}>
{event.title}
</p>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-slate-600">
<span className="truncate" title={categoryName}>
{categoryName}
</span>
<span></span>
<span className="truncate" title={locationLabel}>
{locationLabel}
</span>
</div>
</div>
{isAdmin && (
<input
type="checkbox"
aria-label={`${event.title} auswählen`}
checked={bulkSelection.has(event.id)}
onChange={() => toggleBulkSelection(event.id)}
/>
)}
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex flex-nowrap items-center gap-1">
{canManageView && (
<ViewToggleButton
isSelected={selectedEventIds.has(event.id)}
onClick={() => toggleEvent(event.id)}
size="sm"
/>
)}
{isAdmin && (
<button
type="button"
className="rounded-full border border-slate-200 p-1.5 text-slate-600"
onClick={() => {
setEditStatus(null);
setEditError(null);
setEditEvent(event);
setIsEditOpen(true);
}}
aria-label="Termin bearbeiten"
>
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
d="M4 20h4l10-10-4-4L4 16v4z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M13 7l4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
<button
type="button"
className="rounded-full border border-slate-200 p-1.5 text-slate-600"
onClick={() => setDetailsEvent(event)}
aria-label="Termin Details"
>
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 8h.01M12 12v4" strokeLinecap="round" />
</svg>
</button>
</div>
{event.locationLat && event.locationLng && (
<a
className="inline-flex items-center justify-center rounded-full border border-slate-200 p-1.5 text-slate-600"
href={`https://maps.google.com/?q=${event.locationLat},${event.locationLng}&z=14`}
target="_blank"
rel="noreferrer"
title="Google Maps"
aria-label="Google Maps"
>
<IconMapPin className="h-3.5 w-3.5" />
</a>
)}
</div>
</div>
);
})
)}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="list-table w-full table-fixed text-left text-sm"> <table className="list-table w-full table-fixed text-left text-sm">
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500"> <thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
<tr> <tr>
@@ -2551,9 +2678,9 @@ function SortIcon({
); );
} }
function IconMapPin() { function IconMapPin({ className = "h-4 w-4" }: { className?: string }) {
return ( return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 21s6-6.5 6-11a6 6 0 10-12 0c0 4.5 6 11 6 11z" strokeLinecap="round" strokeLinejoin="round" /> <path d="M12 21s6-6.5 6-11a6 6 0 10-12 0c0 4.5 6 11 6 11z" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="12" cy="10" r="2.5" /> <circle cx="12" cy="10" r="2.5" />
</svg> </svg>

View File

@@ -1,6 +1,7 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { authOptions } from "./auth"; import { authOptions } from "./auth";
import { getEmailVerificationRequired } from "./system-settings";
export async function requireSession() { export async function requireSession() {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
@@ -13,7 +14,8 @@ export async function requireSession() {
response: NextResponse.json({ error: "Account nicht freigeschaltet." }, { status: 403 }) 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 { return {
session: null, session: null,
response: NextResponse.json({ error: "E-Mail nicht verifiziert." }, { status: 403 }) response: NextResponse.json({ error: "E-Mail nicht verifiziert." }, { status: 403 })

View File

@@ -4,6 +4,7 @@ import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials"; import CredentialsProvider from "next-auth/providers/credentials";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { checkRateLimit, getRateLimitConfig } from "./rate-limit"; import { checkRateLimit, getRateLimitConfig } from "./rate-limit";
import { getEmailVerificationRequired } from "./system-settings";
const MAX_LOGIN_ATTEMPTS = 5; const MAX_LOGIN_ATTEMPTS = 5;
const LOGIN_WINDOW_MINUTES = 15; const LOGIN_WINDOW_MINUTES = 15;
@@ -139,7 +140,8 @@ export const authOptions: NextAuthOptions = {
throw new Error("PENDING"); throw new Error("PENDING");
} }
if (!user.emailVerified) { const emailVerificationRequired = await getEmailVerificationRequired();
if (emailVerificationRequired && !user.emailVerified) {
throw new Error("EMAIL_NOT_VERIFIED"); throw new Error("EMAIL_NOT_VERIFIED");
} }

View File

@@ -8,9 +8,11 @@ export type SystemSettings = AccessSettings & {
apiKey: string; apiKey: string;
provider: "google" | "osm"; provider: "google" | "osm";
registrationEnabled: boolean; registrationEnabled: boolean;
emailVerificationRequired: boolean;
}; };
const PUBLIC_ACCESS_KEY = "public_access_enabled"; const PUBLIC_ACCESS_KEY = "public_access_enabled";
const EMAIL_VERIFICATION_KEY = "email_verification_required";
const LEGACY_ACCESS_KEYS = [ const LEGACY_ACCESS_KEYS = [
"public_events_enabled", "public_events_enabled",
"anonymous_access_enabled" "anonymous_access_enabled"
@@ -18,7 +20,8 @@ const LEGACY_ACCESS_KEYS = [
const SYSTEM_KEYS = [ const SYSTEM_KEYS = [
"google_places_api_key", "google_places_api_key",
"geocoding_provider", "geocoding_provider",
"registration_enabled" "registration_enabled",
EMAIL_VERIFICATION_KEY
] as const; ] as const;
const getSettingMap = async (keys: readonly string[]) => { const getSettingMap = async (keys: readonly string[]) => {
@@ -96,12 +99,24 @@ export async function getSystemSettings(): Promise<SystemSettings> {
settings.get("registration_enabled"), settings.get("registration_enabled"),
true true
); );
const emailVerificationRequired = readBoolean(
settings.get(EMAIL_VERIFICATION_KEY),
true
);
const publicAccessEnabled = await ensurePublicAccessSetting(settings); const publicAccessEnabled = await ensurePublicAccessSetting(settings);
return { return {
apiKey, apiKey,
provider, provider,
registrationEnabled, registrationEnabled,
publicAccessEnabled publicAccessEnabled,
emailVerificationRequired
}; };
} }
export async function getEmailVerificationRequired(): Promise<boolean> {
const setting = await prisma.setting.findUnique({
where: { key: EMAIL_VERIFICATION_KEY }
});
return setting?.value !== "false";
}