Aktueller Stand
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user