Aktueller Stand
This commit is contained in:
@@ -1,16 +1,22 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import AdminPanel from "../../components/AdminPanel";
|
||||
import AdminSystemSettings from "../../components/AdminSystemSettings";
|
||||
import { authOptions } from "../../lib/auth";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session?.user?.role !== "ADMIN") {
|
||||
if (session?.user?.role !== "ADMIN" && session?.user?.role !== "SUPERADMIN") {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-slate-300 bg-white p-8 text-center">
|
||||
<p className="text-slate-700">Nur fuer Admins.</p>
|
||||
<div className="card-muted text-center">
|
||||
<p className="text-slate-700">Nur für Admins.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <AdminPanel />;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AdminPanel />
|
||||
{session?.user?.role === "SUPERADMIN" && <AdminSystemSettings />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
5
app/admin/settings/page.tsx
Normal file
5
app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
redirect("/admin");
|
||||
}
|
||||
16
app/admin/users/page.tsx
Normal file
16
app/admin/users/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import AdminUserApprovals from "../../../components/AdminUserApprovals";
|
||||
import { authOptions } from "../../../lib/auth";
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session?.user?.role !== "ADMIN" && session?.user?.role !== "SUPERADMIN") {
|
||||
return (
|
||||
<div className="card-muted text-center">
|
||||
<p className="text-slate-700">Nur für Admins.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <AdminUserApprovals role={session?.user?.role ?? null} />;
|
||||
}
|
||||
67
app/api/branding/logo/route.ts
Normal file
67
app/api/branding/logo/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "prisma", "data");
|
||||
|
||||
const resolveLogoPath = (relativePath: string) => {
|
||||
const absolutePath = path.join(DATA_DIR, relativePath);
|
||||
if (!absolutePath.startsWith(DATA_DIR)) {
|
||||
throw new Error("Ungültiger Pfad.");
|
||||
}
|
||||
return absolutePath;
|
||||
};
|
||||
|
||||
const getLogoSettings = async () => {
|
||||
const pathSetting = await prisma.setting.findUnique({
|
||||
where: { key: "app_logo_path" }
|
||||
});
|
||||
const typeSetting = await prisma.setting.findUnique({
|
||||
where: { key: "app_logo_type" }
|
||||
});
|
||||
|
||||
if (!pathSetting?.value || !typeSetting?.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: pathSetting.value, type: typeSetting.value };
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const settings = await getLogoSettings();
|
||||
if (!settings) {
|
||||
return NextResponse.json({ error: "Kein Logo vorhanden." }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const absolutePath = resolveLogoPath(settings.path);
|
||||
const file = await fs.readFile(absolutePath);
|
||||
return new NextResponse(file, {
|
||||
headers: {
|
||||
"Content-Type": settings.type,
|
||||
"Cache-Control": "no-store"
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Logo konnte nicht geladen werden." }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function HEAD() {
|
||||
const settings = await getLogoSettings();
|
||||
if (!settings) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": settings.type,
|
||||
"Cache-Control": "no-store"
|
||||
}
|
||||
});
|
||||
}
|
||||
120
app/api/categories/route.ts
Normal file
120
app/api/categories/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { isAdminSession, requireSession } from "../../../lib/auth-helpers";
|
||||
|
||||
export async function GET() {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const categories = await prisma.category.findMany({
|
||||
orderBy: { name: "asc" }
|
||||
});
|
||||
|
||||
return NextResponse.json(categories);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name } = body || {};
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Name erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.category.findUnique({ where: { name } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 });
|
||||
}
|
||||
|
||||
const category = await prisma.category.create({
|
||||
data: { name }
|
||||
});
|
||||
|
||||
const views = await prisma.userView.findMany({
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (views.length > 0) {
|
||||
await prisma.userViewCategory.createMany({
|
||||
data: views.map((view) => ({
|
||||
viewId: view.id,
|
||||
categoryId: category.id
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(category, { status: 201 });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id, name } = body || {};
|
||||
|
||||
if (!id || !name) {
|
||||
return NextResponse.json({ error: "ID und Name erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const trimmed = String(name).trim();
|
||||
if (!trimmed) {
|
||||
return NextResponse.json({ error: "Name erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.category.findUnique({ where: { name: trimmed } });
|
||||
if (existing && existing.id !== id) {
|
||||
return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 });
|
||||
}
|
||||
|
||||
const category = await prisma.category.update({
|
||||
where: { id },
|
||||
data: { name: trimmed }
|
||||
});
|
||||
|
||||
return NextResponse.json(category);
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "ID erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.event.updateMany({
|
||||
where: { categoryId: id },
|
||||
data: { categoryId: null }
|
||||
});
|
||||
|
||||
await prisma.category.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -13,16 +13,76 @@ export async function PATCH(request: Request, context: { params: { id: string }
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { status } = body || {};
|
||||
const {
|
||||
status,
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
locationPlaceId,
|
||||
locationLat,
|
||||
locationLng,
|
||||
startAt,
|
||||
endAt,
|
||||
categoryId
|
||||
} = body || {};
|
||||
|
||||
if (!status || !["APPROVED", "REJECTED"].includes(status)) {
|
||||
return NextResponse.json({ error: "Status ungueltig." }, { status: 400 });
|
||||
if (status && ["APPROVED", "REJECTED"].includes(status)) {
|
||||
const event = await prisma.event.update({
|
||||
where: { id: context.params.id },
|
||||
data: { status }
|
||||
});
|
||||
|
||||
return NextResponse.json(event);
|
||||
}
|
||||
|
||||
if (!title || !startAt || !categoryId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Titel, Start und Kategorie sind erforderlich." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const startDate = new Date(startAt);
|
||||
const endDate = endAt ? new Date(endAt) : null;
|
||||
|
||||
const event = await prisma.event.update({
|
||||
where: { id: context.params.id },
|
||||
data: { status }
|
||||
data: {
|
||||
title,
|
||||
description: description || null,
|
||||
location: location || null,
|
||||
locationPlaceId: locationPlaceId || null,
|
||||
locationLat: locationLat ? Number(locationLat) : null,
|
||||
locationLng: locationLng ? Number(locationLng) : null,
|
||||
startAt: startDate,
|
||||
endAt: endDate,
|
||||
category: { connect: { id: categoryId } }
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(event);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
context: { params: { id: string } }
|
||||
) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.userViewItem.deleteMany({
|
||||
where: { eventId: context.params.id }
|
||||
});
|
||||
|
||||
await prisma.event.delete({
|
||||
where: { id: context.params.id }
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export async function GET(request: Request) {
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
where,
|
||||
orderBy: { startAt: "asc" }
|
||||
orderBy: { startAt: "asc" },
|
||||
include: { category: true }
|
||||
});
|
||||
|
||||
return NextResponse.json(events);
|
||||
@@ -38,24 +39,68 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, description, location, startAt, endAt } = body || {};
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
locationPlaceId,
|
||||
locationLat,
|
||||
locationLng,
|
||||
startAt,
|
||||
endAt,
|
||||
categoryId
|
||||
} = body || {};
|
||||
|
||||
if (!title || !startAt || !endAt) {
|
||||
if (!title || !startAt) {
|
||||
return NextResponse.json(
|
||||
{ error: "Titel, Start und Ende sind erforderlich." },
|
||||
{ error: "Titel und Start sind erforderlich." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!categoryId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Kategorie ist erforderlich." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const startDate = new Date(startAt);
|
||||
const endDate = endAt
|
||||
? new Date(endAt)
|
||||
: new Date(startDate.getTime() + 3 * 60 * 60 * 1000);
|
||||
const creatorEmail = session.user?.email || "";
|
||||
|
||||
const existing = await prisma.event.findFirst({
|
||||
where: {
|
||||
title,
|
||||
startAt: startDate,
|
||||
location: location || null,
|
||||
categoryId,
|
||||
createdBy: { email: creatorEmail }
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Ein identischer Termin existiert bereits." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const event = await prisma.event.create({
|
||||
data: {
|
||||
title,
|
||||
description: description || null,
|
||||
location: location || null,
|
||||
startAt: new Date(startAt),
|
||||
endAt: new Date(endAt),
|
||||
locationPlaceId: locationPlaceId || null,
|
||||
locationLat: locationLat ? Number(locationLat) : null,
|
||||
locationLng: locationLng ? Number(locationLng) : null,
|
||||
startAt: startDate,
|
||||
endAt: endDate,
|
||||
status: isAdminSession(session) ? "APPROVED" : "PENDING",
|
||||
createdBy: { connect: { email: session.user?.email || "" } }
|
||||
createdBy: { connect: { email: creatorEmail } },
|
||||
category: { connect: { id: categoryId } }
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@ export async function GET(
|
||||
) {
|
||||
const view = await prisma.userView.findUnique({
|
||||
where: { token: context.params.token },
|
||||
include: { items: { include: { event: true } }, user: true }
|
||||
include: {
|
||||
items: { include: { event: true } },
|
||||
categories: true,
|
||||
exclusions: true,
|
||||
user: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!view) {
|
||||
@@ -20,17 +25,36 @@ export async function GET(
|
||||
timezone: "Europe/Berlin"
|
||||
});
|
||||
|
||||
view.items
|
||||
const excludedIds = new Set(view.exclusions.map((item) => item.eventId));
|
||||
const explicitEvents = view.items
|
||||
.map((item) => item.event)
|
||||
.filter((event) => event.status === "APPROVED")
|
||||
.forEach((event) => {
|
||||
.filter((event) => event.status === "APPROVED");
|
||||
|
||||
const categoryIds = view.categories.map((item) => item.categoryId);
|
||||
const categoryEvents =
|
||||
categoryIds.length > 0
|
||||
? await prisma.event.findMany({
|
||||
where: { categoryId: { in: categoryIds }, status: "APPROVED" }
|
||||
})
|
||||
: [];
|
||||
|
||||
const combined = [...explicitEvents, ...categoryEvents].filter(
|
||||
(event, index, all) =>
|
||||
all.findIndex((item) => item.id === event.id) === index &&
|
||||
!excludedIds.has(event.id)
|
||||
);
|
||||
|
||||
combined.forEach((event) => {
|
||||
const start = event.startAt;
|
||||
const end =
|
||||
event.endAt || new Date(event.startAt.getTime() + 3 * 60 * 60 * 1000);
|
||||
calendar.createEvent({
|
||||
id: event.id,
|
||||
summary: event.title,
|
||||
description: event.description || undefined,
|
||||
location: event.location || undefined,
|
||||
start: event.startAt,
|
||||
end: event.endAt
|
||||
start,
|
||||
end
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
143
app/api/ical/import/route.ts
Normal file
143
app/api/ical/import/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { parseICS } from "node-ical";
|
||||
import { isAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
const asText = (value: unknown) => {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value.trim();
|
||||
return String(value).trim();
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!isAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Nur für Admins." }, { status: 403 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
const categoryId = asText(formData.get("categoryId"));
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bitte eine iCal-Datei hochladen." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!categoryId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bitte eine Kategorie auswählen." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const category = await prisma.category.findUnique({
|
||||
where: { id: categoryId }
|
||||
});
|
||||
if (!category) {
|
||||
return NextResponse.json(
|
||||
{ error: "Kategorie nicht gefunden." },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: "Datei ist zu groß (max. 5 MB)." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: Record<string, any>;
|
||||
try {
|
||||
const raw = await file.text();
|
||||
parsed = parseICS(raw);
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: "iCal-Datei konnte nicht gelesen werden." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.values(parsed).filter(
|
||||
(entry) => entry && entry.type === "VEVENT"
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Keine Termine in der iCal-Datei gefunden." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
let duplicates = 0;
|
||||
let skipped = 0;
|
||||
let recurringSkipped = 0;
|
||||
|
||||
const creatorEmail = session.user?.email || "";
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.rrule) {
|
||||
recurringSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = asText(entry.summary);
|
||||
const start = entry.start instanceof Date ? entry.start : null;
|
||||
if (!title || !start || Number.isNaN(start.getTime())) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const end =
|
||||
entry.end instanceof Date && !Number.isNaN(entry.end.getTime())
|
||||
? entry.end
|
||||
: new Date(start.getTime() + 3 * 60 * 60 * 1000);
|
||||
const location = asText(entry.location) || null;
|
||||
const description = asText(entry.description) || null;
|
||||
|
||||
const existing = await prisma.event.findFirst({
|
||||
where: {
|
||||
title,
|
||||
startAt: start,
|
||||
location,
|
||||
categoryId
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.event.create({
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
startAt: start,
|
||||
endAt: end,
|
||||
status: "APPROVED",
|
||||
createdBy: { connect: { email: creatorEmail } },
|
||||
category: { connect: { id: categoryId } }
|
||||
}
|
||||
});
|
||||
|
||||
created += 1;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
created,
|
||||
duplicates,
|
||||
skipped,
|
||||
recurringSkipped
|
||||
});
|
||||
}
|
||||
36
app/api/password-reset/confirm/route.ts
Normal file
36
app/api/password-reset/confirm/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const { token, newPassword } = body || {};
|
||||
|
||||
if (!token || !newPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: "Token und neues Passwort erforderlich." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const resetToken = await prisma.passwordResetToken.findUnique({
|
||||
where: { token }
|
||||
});
|
||||
|
||||
if (!resetToken || resetToken.expiresAt < new Date()) {
|
||||
return NextResponse.json({ error: "Token ungültig." }, { status: 400 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: resetToken.userId },
|
||||
data: { passwordHash }
|
||||
});
|
||||
|
||||
await prisma.passwordResetToken.deleteMany({
|
||||
where: { userId: resetToken.userId }
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
41
app/api/password-reset/request/route.ts
Normal file
41
app/api/password-reset/request/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { sendMail } from "../../../../lib/mailer";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const { email } = body || {};
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
if (user) {
|
||||
await prisma.passwordResetToken.deleteMany({ where: { userId: user.id } });
|
||||
|
||||
const token = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
await prisma.passwordResetToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token,
|
||||
expiresAt
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
|
||||
const resetUrl = `${baseUrl}/reset/confirm?token=${token}`;
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: "Passwort zurücksetzen",
|
||||
text: `Passwort zurücksetzen: ${resetUrl}`
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
83
app/api/places/autocomplete/route.ts
Normal file
83
app/api/places/autocomplete/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { requireSession } from "../../../../lib/auth-helpers";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const input = searchParams.get("input") || "";
|
||||
const countries = searchParams.get("countries") || "de,fr,ch,at";
|
||||
const countryList = countries
|
||||
.split(",")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.slice(0, 5);
|
||||
const countryParam = countryList.join(",");
|
||||
|
||||
if (input.trim().length < 3) {
|
||||
return NextResponse.json({ predictions: [] });
|
||||
}
|
||||
|
||||
const apiKeySetting = await prisma.setting.findUnique({
|
||||
where: { key: "google_places_api_key" }
|
||||
});
|
||||
const providerSetting = await prisma.setting.findUnique({
|
||||
where: { key: "geocoding_provider" }
|
||||
});
|
||||
const provider =
|
||||
providerSetting?.value || (apiKeySetting?.value ? "google" : "osm");
|
||||
|
||||
if (provider === "google") {
|
||||
if (!apiKeySetting?.value) {
|
||||
return NextResponse.json({ predictions: [] });
|
||||
}
|
||||
|
||||
const apiUrl = new URL("https://maps.googleapis.com/maps/api/place/autocomplete/json");
|
||||
apiUrl.searchParams.set("input", input);
|
||||
apiUrl.searchParams.set("key", apiKeySetting.value);
|
||||
apiUrl.searchParams.set("language", "de");
|
||||
apiUrl.searchParams.set("types", "geocode");
|
||||
if (countryParam) {
|
||||
apiUrl.searchParams.set("components", `country:${countryParam}`);
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl.toString());
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ predictions: [] });
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json({ predictions: payload.predictions || [] });
|
||||
}
|
||||
|
||||
const apiUrl = new URL("https://nominatim.openstreetmap.org/search");
|
||||
apiUrl.searchParams.set("format", "jsonv2");
|
||||
apiUrl.searchParams.set("q", input);
|
||||
apiUrl.searchParams.set("addressdetails", "1");
|
||||
apiUrl.searchParams.set("limit", "5");
|
||||
apiUrl.searchParams.set("accept-language", "de");
|
||||
if (countryParam) {
|
||||
apiUrl.searchParams.set("countrycodes", countryParam);
|
||||
}
|
||||
|
||||
const userAgent = process.env.NOMINATIM_USER_AGENT || "vereinskalender/1.0";
|
||||
const response = await fetch(apiUrl.toString(), {
|
||||
headers: { "User-Agent": userAgent }
|
||||
});
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ predictions: [] });
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const predictions = Array.isArray(payload)
|
||||
? payload.map((item: any) => ({
|
||||
description: item.display_name,
|
||||
place_id: String(item.place_id)
|
||||
}))
|
||||
: [];
|
||||
return NextResponse.json({ predictions });
|
||||
}
|
||||
71
app/api/places/details/route.ts
Normal file
71
app/api/places/details/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { requireSession } from "../../../../lib/auth-helpers";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const placeId = searchParams.get("placeId") || "";
|
||||
|
||||
if (!placeId) {
|
||||
return NextResponse.json({ error: "PlaceId erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKeySetting = await prisma.setting.findUnique({
|
||||
where: { key: "google_places_api_key" }
|
||||
});
|
||||
const providerSetting = await prisma.setting.findUnique({
|
||||
where: { key: "geocoding_provider" }
|
||||
});
|
||||
const provider =
|
||||
providerSetting?.value || (apiKeySetting?.value ? "google" : "osm");
|
||||
|
||||
if (provider === "google") {
|
||||
if (!apiKeySetting?.value) {
|
||||
return NextResponse.json({ error: "API-Key fehlt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiUrl = new URL("https://maps.googleapis.com/maps/api/place/details/json");
|
||||
apiUrl.searchParams.set("place_id", placeId);
|
||||
apiUrl.searchParams.set("fields", "geometry/location");
|
||||
apiUrl.searchParams.set("key", apiKeySetting.value);
|
||||
|
||||
const response = await fetch(apiUrl.toString());
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: "Fehler beim Laden." }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const location = payload?.result?.geometry?.location;
|
||||
return NextResponse.json({
|
||||
lat: location?.lat ?? null,
|
||||
lng: location?.lng ?? null
|
||||
});
|
||||
}
|
||||
|
||||
const apiUrl = new URL("https://nominatim.openstreetmap.org/details");
|
||||
apiUrl.searchParams.set("place_id", placeId);
|
||||
apiUrl.searchParams.set("format", "json");
|
||||
|
||||
const userAgent = process.env.NOMINATIM_USER_AGENT || "vereinskalender/1.0";
|
||||
const response = await fetch(apiUrl.toString(), {
|
||||
headers: { "User-Agent": userAgent }
|
||||
});
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: "Fehler beim Laden." }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const coords = payload?.centroid?.coordinates;
|
||||
const lat = Array.isArray(coords) ? Number(coords[1]) : Number(payload?.lat);
|
||||
const lng = Array.isArray(coords) ? Number(coords[0]) : Number(payload?.lon);
|
||||
|
||||
return NextResponse.json({
|
||||
lat: Number.isFinite(lat) ? lat : null,
|
||||
lng: Number.isFinite(lng) ? lng : null
|
||||
});
|
||||
}
|
||||
70
app/api/places/reverse/route.ts
Normal file
70
app/api/places/reverse/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { requireSession } from "../../../../lib/auth-helpers";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const lat = searchParams.get("lat");
|
||||
const lng = searchParams.get("lng");
|
||||
|
||||
if (!lat || !lng) {
|
||||
return NextResponse.json({ error: "Koordinaten erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKeySetting = await prisma.setting.findUnique({
|
||||
where: { key: "google_places_api_key" }
|
||||
});
|
||||
const providerSetting = await prisma.setting.findUnique({
|
||||
where: { key: "geocoding_provider" }
|
||||
});
|
||||
const provider =
|
||||
providerSetting?.value || (apiKeySetting?.value ? "google" : "osm");
|
||||
|
||||
if (provider === "google") {
|
||||
if (!apiKeySetting?.value) {
|
||||
return NextResponse.json({ error: "API-Key fehlt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiUrl = new URL("https://maps.googleapis.com/maps/api/geocode/json");
|
||||
apiUrl.searchParams.set("latlng", `${lat},${lng}`);
|
||||
apiUrl.searchParams.set("key", apiKeySetting.value);
|
||||
apiUrl.searchParams.set("language", "de");
|
||||
|
||||
const response = await fetch(apiUrl.toString());
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: "Fehler beim Laden." }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const result = payload?.results?.[0];
|
||||
return NextResponse.json({
|
||||
label: result?.formatted_address || null,
|
||||
placeId: result?.place_id || null
|
||||
});
|
||||
}
|
||||
|
||||
const apiUrl = new URL("https://nominatim.openstreetmap.org/reverse");
|
||||
apiUrl.searchParams.set("format", "jsonv2");
|
||||
apiUrl.searchParams.set("lat", lat);
|
||||
apiUrl.searchParams.set("lon", lng);
|
||||
apiUrl.searchParams.set("addressdetails", "1");
|
||||
|
||||
const userAgent = process.env.NOMINATIM_USER_AGENT || "vereinskalender/1.0";
|
||||
const response = await fetch(apiUrl.toString(), {
|
||||
headers: { "User-Agent": userAgent }
|
||||
});
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: "Fehler beim Laden." }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json({
|
||||
label: payload?.display_name || null,
|
||||
placeId: payload?.place_id ? String(payload.place_id) : null
|
||||
});
|
||||
}
|
||||
91
app/api/profile/route.ts
Normal file
91
app/api/profile/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { requireSession } from "../../../lib/auth-helpers";
|
||||
import { sendMail } from "../../../lib/mailer";
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { currentPassword, newPassword, newEmail } = body || {};
|
||||
const normalizedEmail = newEmail ? String(newEmail).trim().toLowerCase() : "";
|
||||
|
||||
if (!currentPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: "Aktuelles Passwort erforderlich." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user?.email || "" }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
return NextResponse.json({ error: "Passwort ungültig." }, { status: 401 });
|
||||
}
|
||||
|
||||
const data: { email?: string; passwordHash?: string; emailVerified?: boolean } = {};
|
||||
|
||||
if (normalizedEmail && normalizedEmail !== user.email) {
|
||||
const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "E-Mail bereits vergeben." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
data.email = normalizedEmail;
|
||||
data.emailVerified = false;
|
||||
}
|
||||
|
||||
if (newPassword) {
|
||||
data.passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return NextResponse.json({ error: "Keine Änderungen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data
|
||||
});
|
||||
|
||||
if (data.email) {
|
||||
const token = crypto.randomUUID();
|
||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: data.email,
|
||||
token,
|
||||
expires
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
|
||||
const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`;
|
||||
await sendMail({
|
||||
to: data.email,
|
||||
subject: "E-Mail verifizieren",
|
||||
text: `Bitte verifiziere deine E-Mail: ${verifyUrl}`
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: updated.id,
|
||||
email: updated.email,
|
||||
changedEmail: Boolean(data.email),
|
||||
changedPassword: Boolean(data.passwordHash)
|
||||
});
|
||||
}
|
||||
@@ -1,30 +1,89 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { isAdminEmail } from "../../../lib/auth";
|
||||
import { isAdminEmail, isSuperAdminEmail } from "../../../lib/auth";
|
||||
import { sendMail } from "../../../lib/mailer";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const registrationSetting = await prisma.setting.findUnique({
|
||||
where: { key: "registration_enabled" }
|
||||
});
|
||||
if (registrationSetting?.value === "false") {
|
||||
return NextResponse.json(
|
||||
{ error: "Registrierung ist derzeit deaktiviert." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { email, name, password } = body || {};
|
||||
const normalizedEmail = String(email || "").trim().toLowerCase();
|
||||
|
||||
if (!email || !password) {
|
||||
if (!normalizedEmail || !password) {
|
||||
return NextResponse.json({ error: "Email und Passwort sind erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Account existiert bereits." }, { status: 409 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const superAdmin = isSuperAdminEmail(normalizedEmail);
|
||||
const admin = isAdminEmail(normalizedEmail) || superAdmin;
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
email: normalizedEmail,
|
||||
name: name || null,
|
||||
passwordHash,
|
||||
role: isAdminEmail(email) ? "ADMIN" : "USER"
|
||||
role: superAdmin ? "SUPERADMIN" : admin ? "ADMIN" : "USER",
|
||||
status: admin ? "ACTIVE" : "PENDING",
|
||||
emailVerified: admin
|
||||
}
|
||||
});
|
||||
|
||||
const categories = await prisma.category.findMany({
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
const view = await prisma.userView.create({
|
||||
data: {
|
||||
name: "Meine Ansicht",
|
||||
token: randomUUID(),
|
||||
user: { connect: { id: user.id } }
|
||||
}
|
||||
});
|
||||
|
||||
if (categories.length > 0) {
|
||||
await prisma.userViewCategory.createMany({
|
||||
data: categories.map((category) => ({
|
||||
viewId: view.id,
|
||||
categoryId: category.id
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
if (!admin) {
|
||||
const token = randomUUID();
|
||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: normalizedEmail,
|
||||
token,
|
||||
expires
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
|
||||
const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`;
|
||||
await sendMail({
|
||||
to: normalizedEmail,
|
||||
subject: "E-Mail verifizieren",
|
||||
text: `Bitte verifiziere deine E-Mail: ${verifyUrl}`
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ id: user.id, email: user.email });
|
||||
}
|
||||
|
||||
76
app/api/settings/google-places/route.ts
Normal file
76
app/api/settings/google-places/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { isSuperAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
||||
|
||||
export async function GET() {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKeySetting = await prisma.setting.findUnique({
|
||||
where: { key: "google_places_api_key" }
|
||||
});
|
||||
const providerSetting = await prisma.setting.findUnique({
|
||||
where: { key: "geocoding_provider" }
|
||||
});
|
||||
const registrationSetting = await prisma.setting.findUnique({
|
||||
where: { key: "registration_enabled" }
|
||||
});
|
||||
|
||||
const apiKey = apiKeySetting?.value || "";
|
||||
const provider =
|
||||
providerSetting?.value || (apiKey ? "google" : "osm");
|
||||
const registrationEnabled = registrationSetting?.value !== "false";
|
||||
|
||||
return NextResponse.json({ apiKey, provider, registrationEnabled });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isSuperAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { apiKey, provider, registrationEnabled } = body || {};
|
||||
|
||||
if (!provider || !["google", "osm"].includes(provider)) {
|
||||
return NextResponse.json({ error: "Provider erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (provider === "google" && !apiKey) {
|
||||
return NextResponse.json({ error: "API-Key erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKeyValue = provider === "google" ? apiKey : "";
|
||||
|
||||
const apiKeySetting = await prisma.setting.upsert({
|
||||
where: { key: "google_places_api_key" },
|
||||
update: { value: apiKeyValue },
|
||||
create: { key: "google_places_api_key", value: apiKeyValue }
|
||||
});
|
||||
|
||||
const providerSetting = await prisma.setting.upsert({
|
||||
where: { key: "geocoding_provider" },
|
||||
update: { value: provider },
|
||||
create: { key: "geocoding_provider", value: provider }
|
||||
});
|
||||
|
||||
const registrationValue = registrationEnabled === false ? "false" : "true";
|
||||
await prisma.setting.upsert({
|
||||
where: { key: "registration_enabled" },
|
||||
update: { value: registrationValue },
|
||||
create: { key: "registration_enabled", value: registrationValue }
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
apiKey: apiKeySetting.value,
|
||||
provider: providerSetting.value,
|
||||
registrationEnabled: registrationValue !== "false"
|
||||
});
|
||||
}
|
||||
118
app/api/settings/logo/route.ts
Normal file
118
app/api/settings/logo/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { isSuperAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "prisma", "data");
|
||||
const UPLOADS_DIR = path.join(DATA_DIR, "uploads");
|
||||
|
||||
const MIME_TO_EXT: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
"image/svg+xml": "svg"
|
||||
};
|
||||
|
||||
const resolveLogoPath = (relativePath: string) => {
|
||||
const absolutePath = path.join(DATA_DIR, relativePath);
|
||||
if (!absolutePath.startsWith(DATA_DIR)) {
|
||||
throw new Error("Ungültiger Pfad.");
|
||||
}
|
||||
return absolutePath;
|
||||
};
|
||||
|
||||
const getLogoSetting = async () =>
|
||||
prisma.setting.findUnique({ where: { key: "app_logo_path" } });
|
||||
|
||||
const getLogoTypeSetting = async () =>
|
||||
prisma.setting.findUnique({ where: { key: "app_logo_type" } });
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!isSuperAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return NextResponse.json({ error: "Datei fehlt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const extension = MIME_TO_EXT[file.type];
|
||||
if (!extension) {
|
||||
return NextResponse.json({ error: "Dateityp nicht unterstützt." }, { status: 400 });
|
||||
}
|
||||
|
||||
await fs.mkdir(UPLOADS_DIR, { recursive: true });
|
||||
|
||||
const previousSetting = await getLogoSetting();
|
||||
const previousTypeSetting = await getLogoTypeSetting();
|
||||
|
||||
const filename = `app-logo.${extension}`;
|
||||
const relativePath = path.join("uploads", filename);
|
||||
const absolutePath = resolveLogoPath(relativePath);
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await fs.writeFile(absolutePath, buffer);
|
||||
|
||||
if (previousSetting?.value && previousSetting.value !== relativePath) {
|
||||
try {
|
||||
await fs.unlink(resolveLogoPath(previousSetting.value));
|
||||
} catch {
|
||||
// ignore missing old file
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.setting.upsert({
|
||||
where: { key: "app_logo_path" },
|
||||
update: { value: relativePath },
|
||||
create: { key: "app_logo_path", value: relativePath }
|
||||
});
|
||||
|
||||
await prisma.setting.upsert({
|
||||
where: { key: "app_logo_type" },
|
||||
update: { value: file.type },
|
||||
create: { key: "app_logo_type", value: file.type }
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!isSuperAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const logoSetting = await getLogoSetting();
|
||||
const typeSetting = await getLogoTypeSetting();
|
||||
|
||||
if (logoSetting?.value) {
|
||||
try {
|
||||
await fs.unlink(resolveLogoPath(logoSetting.value));
|
||||
} catch {
|
||||
// ignore missing file
|
||||
}
|
||||
}
|
||||
|
||||
if (logoSetting) {
|
||||
await prisma.setting.delete({ where: { key: "app_logo_path" } });
|
||||
}
|
||||
if (typeSetting) {
|
||||
await prisma.setting.delete({ where: { key: "app_logo_type" } });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
303
app/api/users/route.ts
Normal file
303
app/api/users/route.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import {
|
||||
isAdminSession,
|
||||
isSuperAdminSession,
|
||||
requireSession
|
||||
} from "../../../lib/auth-helpers";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get("status");
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: status ? { status } : undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
status: true,
|
||||
role: true,
|
||||
emailVerified: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!isSuperAdminSession(session)) {
|
||||
return NextResponse.json(users);
|
||||
}
|
||||
|
||||
const emails = users.map((user) => user.email).filter(Boolean);
|
||||
const attempts = emails.length
|
||||
? await prisma.loginAttempt.findMany({
|
||||
where: { email: { in: emails } }
|
||||
})
|
||||
: [];
|
||||
|
||||
const stats = attempts.reduce<Record<string, {
|
||||
attempts: number;
|
||||
lastAttempt: Date | null;
|
||||
lockedUntil: Date | null;
|
||||
}>>((acc, attempt) => {
|
||||
const current = acc[attempt.email] || {
|
||||
attempts: 0,
|
||||
lastAttempt: null,
|
||||
lockedUntil: null
|
||||
};
|
||||
current.attempts += attempt.attempts;
|
||||
if (!current.lastAttempt || attempt.lastAttempt > current.lastAttempt) {
|
||||
current.lastAttempt = attempt.lastAttempt;
|
||||
}
|
||||
if (!current.lockedUntil || (attempt.lockedUntil && attempt.lockedUntil > current.lockedUntil)) {
|
||||
current.lockedUntil = attempt.lockedUntil;
|
||||
}
|
||||
acc[attempt.email] = current;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const enriched = users.map((user) => ({
|
||||
...user,
|
||||
loginStats: stats[user.email] || {
|
||||
attempts: 0,
|
||||
lastAttempt: null,
|
||||
lockedUntil: null
|
||||
}
|
||||
}));
|
||||
|
||||
return NextResponse.json(enriched);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
email,
|
||||
name,
|
||||
password,
|
||||
role,
|
||||
status,
|
||||
emailVerified
|
||||
} = body || {};
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "E-Mail und Passwort sind erforderlich." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedEmail = String(email).trim().toLowerCase();
|
||||
const allowedRoles = ["USER", "ADMIN", "SUPERADMIN"];
|
||||
const allowedStatuses = ["ACTIVE", "PENDING", "DISABLED"];
|
||||
const isSuperAdmin = isSuperAdminSession(session);
|
||||
const nextRole = isSuperAdmin && allowedRoles.includes(role) ? role : "USER";
|
||||
const nextStatus = allowedStatuses.includes(status) ? status : "PENDING";
|
||||
|
||||
if (!normalizedEmail) {
|
||||
return NextResponse.json({ error: "Ungültige E-Mail." }, { status: 400 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(String(password), 10);
|
||||
|
||||
try {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: normalizedEmail,
|
||||
name: name ? String(name).trim() : null,
|
||||
passwordHash,
|
||||
role: nextRole,
|
||||
status: nextStatus,
|
||||
emailVerified: Boolean(emailVerified)
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
status: true,
|
||||
emailVerified: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(user, { status: 201 });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: "Benutzer konnte nicht angelegt werden." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
userId,
|
||||
status,
|
||||
role,
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
emailVerified,
|
||||
resetLoginAttempts
|
||||
} = body || {};
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true }
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (resetLoginAttempts) {
|
||||
if (!isSuperAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const userRecord = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true }
|
||||
});
|
||||
if (userRecord?.email) {
|
||||
await prisma.loginAttempt.deleteMany({ where: { email: userRecord.email } });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (target.role === "SUPERADMIN" && !isSuperAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const allowedStatuses = ["ACTIVE", "PENDING", "DISABLED"];
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
if (status) {
|
||||
if (!allowedStatuses.includes(status)) {
|
||||
return NextResponse.json({ error: "Ungültiger Status." }, { status: 400 });
|
||||
}
|
||||
data.status = status;
|
||||
}
|
||||
|
||||
if (name !== undefined) {
|
||||
data.name = name ? String(name).trim() : null;
|
||||
}
|
||||
|
||||
if (email) {
|
||||
data.email = String(email).trim().toLowerCase();
|
||||
}
|
||||
|
||||
if (emailVerified !== undefined) {
|
||||
data.emailVerified = Boolean(emailVerified);
|
||||
}
|
||||
|
||||
if (password) {
|
||||
data.passwordHash = await bcrypt.hash(String(password), 10);
|
||||
}
|
||||
|
||||
if (role) {
|
||||
if (!isSuperAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
if (!["USER", "ADMIN", "SUPERADMIN"].includes(role)) {
|
||||
return NextResponse.json({ error: "Ungültige Rolle." }, { status: 400 });
|
||||
}
|
||||
data.role = role;
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
status: true,
|
||||
emailVerified: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: user.id, status: user.status });
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const userId = searchParams.get("id");
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (session.user?.id === userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Eigenes Konto kann nicht gelöscht werden." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true }
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (target.role !== "USER" && !isSuperAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.session.deleteMany({ where: { userId } });
|
||||
await prisma.account.deleteMany({ where: { userId } });
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { status: "DISABLED", emailVerified: false }
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
38
app/api/verify-email/confirm/route.ts
Normal file
38
app/api/verify-email/confirm/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const { token } = body || {};
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "Token erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const record = await prisma.verificationToken.findUnique({
|
||||
where: { token }
|
||||
});
|
||||
|
||||
if (!record || record.expires < new Date()) {
|
||||
return NextResponse.json({ error: "Token ungültig." }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: record.identifier }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { emailVerified: true }
|
||||
});
|
||||
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: { identifier: record.identifier }
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
44
app/api/verify-email/request/route.ts
Normal file
44
app/api/verify-email/request/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { sendMail } from "../../../../lib/mailer";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const { email } = body || {};
|
||||
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
await prisma.verificationToken.deleteMany({ where: { identifier: email } });
|
||||
|
||||
const token = randomUUID();
|
||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: email,
|
||||
token,
|
||||
expires
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
|
||||
const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`;
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: "E-Mail verifizieren",
|
||||
text: `Bitte verifiziere deine E-Mail: ${verifyUrl}`
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
65
app/api/views/[id]/categories/route.ts
Normal file
65
app/api/views/[id]/categories/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import { requireSession } from "../../../../../lib/auth-helpers";
|
||||
|
||||
async function ensureOwner(viewId: string, email: string) {
|
||||
const view = await prisma.userView.findFirst({
|
||||
where: { id: viewId, user: { email } }
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: { params: { id: string } }) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const email = session.user?.email || "";
|
||||
const view = await ensureOwner(context.params.id, email);
|
||||
if (!view) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { categoryId } = body || {};
|
||||
if (!categoryId) {
|
||||
return NextResponse.json({ error: "Kategorie erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.userViewCategory.upsert({
|
||||
where: { viewId_categoryId: { viewId: view.id, categoryId } },
|
||||
update: {},
|
||||
create: { viewId: view.id, categoryId }
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true }, { status: 201 });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
context: { params: { id: string } }
|
||||
) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const email = session.user?.email || "";
|
||||
const view = await ensureOwner(context.params.id, email);
|
||||
if (!view) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { categoryId } = body || {};
|
||||
if (!categoryId) {
|
||||
return NextResponse.json({ error: "Kategorie erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.userViewCategory.deleteMany({
|
||||
where: { viewId: view.id, categoryId }
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -27,8 +27,34 @@ export async function POST(request: Request, context: { params: { id: string } }
|
||||
return NextResponse.json({ error: "Event erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.userViewItem.create({
|
||||
data: { viewId: view.id, eventId }
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: { categoryId: true }
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: "Event nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.userViewExclusion.deleteMany({
|
||||
where: { viewId: view.id, eventId }
|
||||
});
|
||||
|
||||
if (event.categoryId) {
|
||||
const subscribed = await prisma.userViewCategory.findUnique({
|
||||
where: {
|
||||
viewId_categoryId: { viewId: view.id, categoryId: event.categoryId }
|
||||
}
|
||||
});
|
||||
if (subscribed) {
|
||||
return NextResponse.json({ ok: true }, { status: 201 });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.userViewItem.upsert({
|
||||
where: { viewId_eventId: { viewId: view.id, eventId } },
|
||||
update: {},
|
||||
create: { viewId: view.id, eventId }
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true }, { status: 201 });
|
||||
@@ -52,6 +78,31 @@ export async function DELETE(request: Request, context: { params: { id: string }
|
||||
return NextResponse.json({ error: "Event erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: { categoryId: true }
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: "Event nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (event.categoryId) {
|
||||
const subscribed = await prisma.userViewCategory.findUnique({
|
||||
where: {
|
||||
viewId_categoryId: { viewId: view.id, categoryId: event.categoryId }
|
||||
}
|
||||
});
|
||||
if (subscribed) {
|
||||
await prisma.userViewExclusion.upsert({
|
||||
where: { viewId_eventId: { viewId: view.id, eventId } },
|
||||
update: {},
|
||||
create: { viewId: view.id, eventId }
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.userViewItem.deleteMany({
|
||||
where: { viewId: view.id, eventId }
|
||||
});
|
||||
|
||||
27
app/api/views/default/rotate/route.ts
Normal file
27
app/api/views/default/rotate/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import { requireSession } from "../../../../../lib/auth-helpers";
|
||||
|
||||
export async function POST() {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const email = session.user?.email || "";
|
||||
const view = await prisma.userView.findFirst({
|
||||
where: { user: { email } }
|
||||
});
|
||||
|
||||
if (!view) {
|
||||
return NextResponse.json({ error: "Keine Ansicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const updated = await prisma.userView.update({
|
||||
where: { id: view.id },
|
||||
data: { token: randomUUID() }
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: updated.id, token: updated.token });
|
||||
}
|
||||
57
app/api/views/default/route.ts
Normal file
57
app/api/views/default/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { requireSession } from "../../../../lib/auth-helpers";
|
||||
|
||||
export async function GET() {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const email = session.user?.email || "";
|
||||
const existing = await prisma.userView.findFirst({
|
||||
where: { user: { email } },
|
||||
include: {
|
||||
items: { include: { event: true } },
|
||||
categories: { include: { category: true } },
|
||||
exclusions: true
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(existing);
|
||||
}
|
||||
|
||||
const view = await prisma.userView.create({
|
||||
data: {
|
||||
name: "Meine Ansicht",
|
||||
token: randomUUID(),
|
||||
user: { connect: { email } }
|
||||
}
|
||||
});
|
||||
|
||||
const categories = await prisma.category.findMany({
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (categories.length > 0) {
|
||||
await prisma.userViewCategory.createMany({
|
||||
data: categories.map((category) => ({
|
||||
viewId: view.id,
|
||||
categoryId: category.id
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
const hydrated = await prisma.userView.findUnique({
|
||||
where: { id: view.id },
|
||||
include: {
|
||||
items: { include: { event: true } },
|
||||
categories: { include: { category: true } },
|
||||
exclusions: true
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(hydrated, { status: 201 });
|
||||
}
|
||||
@@ -11,7 +11,11 @@ export async function GET() {
|
||||
|
||||
const views = await prisma.userView.findMany({
|
||||
where: { user: { email: session.user?.email || "" } },
|
||||
include: { items: { include: { event: true } } },
|
||||
include: {
|
||||
items: { include: { event: true } },
|
||||
categories: { include: { category: true } },
|
||||
exclusions: true
|
||||
},
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
|
||||
|
||||
322
app/globals.css
322
app/globals.css
@@ -1,4 +1,5 @@
|
||||
@import "@fontsource/space-grotesk/variable.css";
|
||||
|
||||
@import "leaflet/dist/leaflet.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@@ -7,18 +8,319 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface: #ffffff;
|
||||
--ink: #1f1a17;
|
||||
--muted: #f7efe4;
|
||||
--line: #e6dccf;
|
||||
--accent: #ff6b4a;
|
||||
--accent-strong: #e24a2b;
|
||||
--ink: #0f0f10;
|
||||
--muted: #f2f2ee;
|
||||
--line: #deded6;
|
||||
--accent: #6f7a4f;
|
||||
--accent-strong: #4e5837;
|
||||
--accent-glow: #aab790;
|
||||
--cool: #2f3b2a;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(65% 80% at 0% 0%, #fff1df 0%, transparent 60%),
|
||||
radial-gradient(65% 80% at 100% 0%, #e9f0ff 0%, transparent 60%),
|
||||
#f8f2e9;
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
radial-gradient(60% 80% at 0% 0%, rgba(170, 183, 144, 0.25) 0%, transparent 60%),
|
||||
radial-gradient(60% 70% at 100% 0%, rgba(15, 15, 16, 0.08) 0%, transparent 60%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.7), transparent 25%),
|
||||
#f4f4f0;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
font-family: "Sora", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface: #0f1110;
|
||||
--ink: #f8f7f2;
|
||||
--muted: #161a18;
|
||||
--line: #2b322c;
|
||||
--accent: #8e9b6b;
|
||||
--accent-strong: #a3b37a;
|
||||
--accent-glow: #3b4a2a;
|
||||
--cool: #c9d4b4;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] body {
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(70% 90% at 0% 0%, rgba(142, 155, 107, 0.22) 0%, transparent 60%),
|
||||
radial-gradient(80% 70% at 100% 0%, rgba(255, 255, 255, 0.06) 0%, transparent 60%),
|
||||
linear-gradient(180deg, rgba(15, 17, 16, 0.95), transparent 25%),
|
||||
#0b0d0c;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 rgba(255, 155, 130, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(255, 155, 130, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply rounded-2xl border border-slate-200/70 bg-white/90 p-5 shadow-[0_20px_40px_rgba(15,15,16,0.08)];
|
||||
}
|
||||
.card-muted {
|
||||
@apply rounded-2xl border border-slate-200/70 bg-white/70 p-5;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:-translate-y-0.5 hover:bg-slate-800;
|
||||
}
|
||||
.btn-accent {
|
||||
@apply rounded-full bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:-translate-y-0.5 hover:bg-brand-700;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply rounded-full border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:-translate-y-0.5 hover:bg-slate-100;
|
||||
}
|
||||
.fade-up {
|
||||
animation: fadeUp 0.7s ease both;
|
||||
}
|
||||
.fade-up-delay {
|
||||
animation: fadeUp 0.7s ease 0.15s both;
|
||||
}
|
||||
.glow {
|
||||
animation: glow 5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .card {
|
||||
border-color: rgba(71, 85, 105, 0.35);
|
||||
background: rgba(15, 17, 16, 0.9);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .card-muted {
|
||||
border-color: rgba(71, 85, 105, 0.3);
|
||||
background: rgba(15, 17, 16, 0.7);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .btn-primary {
|
||||
background: #f8f7f2;
|
||||
color: #0f1110;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .btn-primary:hover {
|
||||
background: #e7e4da;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .btn-accent {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .btn-accent:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .btn-ghost {
|
||||
border-color: rgba(71, 85, 105, 0.6);
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 17, 16, 0.35);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .btn-ghost:hover {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] input,
|
||||
html[data-theme="dark"] select,
|
||||
html[data-theme="dark"] textarea {
|
||||
background: rgba(15, 17, 16, 0.65);
|
||||
border-color: rgba(71, 85, 105, 0.5);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] header {
|
||||
border-color: rgba(71, 85, 105, 0.35);
|
||||
background: rgba(15, 17, 16, 0.8);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .fc .fc-button {
|
||||
border-color: rgba(71, 85, 105, 0.5);
|
||||
background: rgba(15, 17, 16, 0.75);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||
html[data-theme="dark"] .fc .fc-button-primary:not(:disabled):active {
|
||||
background: #f8f7f2;
|
||||
color: #0f1110;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .fc .fc-daygrid-event,
|
||||
html[data-theme="dark"] .fc .fc-timegrid-event {
|
||||
background: #e2e8f0;
|
||||
color: #0f1110;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .fc .fc-daygrid-event .fc-event-main,
|
||||
html[data-theme="dark"] .fc .fc-timegrid-event .fc-event-main,
|
||||
html[data-theme="dark"] .fc .fc-daygrid-event .fc-event-title,
|
||||
html[data-theme="dark"] .fc .fc-timegrid-event .fc-event-title,
|
||||
html[data-theme="dark"] .fc .fc-daygrid-event .fc-event-time,
|
||||
html[data-theme="dark"] .fc .fc-timegrid-event .fc-event-time {
|
||||
color: #0f1110;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .fc .fc-day-today {
|
||||
background: rgba(248, 247, 242, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .fc .fc-daygrid-day.fc-day-past {
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .fc .fc-daygrid-day.fc-day-past .fc-daygrid-day-number {
|
||||
color: rgba(226, 232, 240, 0.7);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .drag-handle {
|
||||
border-color: rgba(71, 85, 105, 0.5);
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 17, 16, 0.8);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .drag-handle:hover {
|
||||
background: rgba(30, 41, 59, 0.9);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.fc .fc-toolbar-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fc .fc-button {
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
text-transform: none;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||
.fc .fc-button-primary:not(:disabled):active {
|
||||
background: #0f172a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-event,
|
||||
.fc .fc-timegrid-event {
|
||||
border-radius: 0.6rem;
|
||||
border: none;
|
||||
background: #1f2937;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-event .fc-event-main,
|
||||
.fc .fc-timegrid-event .fc-event-main,
|
||||
.fc .fc-daygrid-event .fc-event-title,
|
||||
.fc .fc-timegrid-event .fc-event-title,
|
||||
.fc .fc-daygrid-event .fc-event-time,
|
||||
.fc .fc-timegrid-event .fc-event-time {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-event .event-shell,
|
||||
.fc .fc-timegrid-event .event-shell {
|
||||
position: relative;
|
||||
padding-right: 1.75rem;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-event .event-toggle,
|
||||
.fc .fc-timegrid-event .event-toggle {
|
||||
position: absolute;
|
||||
right: 0.3rem;
|
||||
top: 0.3rem;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 0.35rem 0.55rem;
|
||||
color: #475569;
|
||||
background: #ffffff;
|
||||
cursor: grab;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
transform: translateY(-1px);
|
||||
background: #f8fafc;
|
||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.drag-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.drag-card.dragging {
|
||||
opacity: 0.7;
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.drag-card.drag-target {
|
||||
outline: 2px dashed rgba(99, 102, 241, 0.35);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.drag-card.shift-up {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.drag-card.shift-down {
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.fc .fc-day-today {
|
||||
background: rgba(31, 41, 55, 0.08);
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day.fc-day-past {
|
||||
background: rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day.fc-day-past .fc-daygrid-day-number {
|
||||
color: rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fc .fc-toolbar {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fc .fc-toolbar-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.fc .fc-button {
|
||||
padding: 0.35rem 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import NavBar from "../components/NavBar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Vereinskalender",
|
||||
description: "Kalenderapp fuer Vereine"
|
||||
description: "Kalenderapp für Vereine"
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -16,10 +16,19 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-core.css" />
|
||||
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-daygrid.css" />
|
||||
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-timegrid.css" />
|
||||
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-list.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar.css" />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.getItem("theme");var theme=t==="dark"?"dark":"light";document.documentElement.dataset.theme=theme;}catch(e){}})();`
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<Providers>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -17,34 +19,53 @@ export default function LoginPage() {
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: true,
|
||||
callbackUrl: "/"
|
||||
redirect: false
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
if (result.error === "PENDING") {
|
||||
setError("Dein Konto wartet auf Freischaltung durch einen Admin.");
|
||||
return;
|
||||
}
|
||||
if (result.error === "EMAIL_NOT_VERIFIED") {
|
||||
setError("Bitte bestätige zuerst deine E-Mail.");
|
||||
return;
|
||||
}
|
||||
if (result.error === "LOCKED") {
|
||||
setError("Zu viele Versuche. Bitte später erneut versuchen.");
|
||||
return;
|
||||
}
|
||||
setError("Login fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.ok) {
|
||||
router.push("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md rounded bg-white p-6 shadow-sm">
|
||||
<div className="mx-auto max-w-md card fade-up">
|
||||
<h1 className="text-2xl font-semibold">Login</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Bitte anmelden.
|
||||
</p>
|
||||
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="E-Mail"
|
||||
required
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Passwort"
|
||||
required
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<button type="submit" className="w-full rounded bg-brand-500 px-4 py-2 text-white">
|
||||
<button type="submit" className="btn-accent w-full">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
@@ -52,6 +73,18 @@ export default function LoginPage() {
|
||||
<p className="mt-4 text-sm text-slate-600">
|
||||
Kein Konto? <Link href="/register" className="text-brand-700">Registrieren</Link>
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Passwort vergessen?{" "}
|
||||
<Link href="/reset" className="text-brand-700">
|
||||
Zurücksetzen
|
||||
</Link>
|
||||
</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
29
app/page.tsx
29
app/page.tsx
@@ -1,24 +1,23 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import CalendarBoard from "../components/CalendarBoard";
|
||||
import EventForm from "../components/EventForm";
|
||||
import { authOptions } from "../lib/auth";
|
||||
|
||||
export default function HomePage() {
|
||||
export default async function HomePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-brand-500 via-brand-700 to-slate-900 p-8 text-white shadow-lg">
|
||||
<div className="absolute -right-16 -top-16 h-40 w-40 rounded-full bg-white/20 blur-2xl" />
|
||||
<div className="absolute -bottom-20 left-10 h-56 w-56 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="relative">
|
||||
<h1 className="text-3xl font-semibold">Vereinskalender</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-white/90">
|
||||
Termine einstellen, abstimmen und als persoenlichen Kalender abonnieren.
|
||||
{session?.user?.status === "ACTIVE" ? (
|
||||
<CalendarBoard />
|
||||
) : session?.user ? (
|
||||
<div className="card-muted text-center">
|
||||
<p className="text-slate-700">
|
||||
Dein Konto wartet auf Freischaltung durch einen Admin.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<CalendarBoard />
|
||||
<EventForm />
|
||||
</div>
|
||||
) : (
|
||||
redirect("/login")
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,30 +40,33 @@ export default function RegisterPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md rounded bg-white p-6 shadow-sm">
|
||||
<div className="mx-auto max-w-md card fade-up">
|
||||
<h1 className="text-2xl font-semibold">Registrieren</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Erstelle ein Konto, um Termine vorzuschlagen und eigene Ansichten anzulegen.
|
||||
</p>
|
||||
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="E-Mail"
|
||||
required
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Passwort"
|
||||
required
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<button type="submit" className="w-full rounded bg-brand-500 px-4 py-2 text-white">
|
||||
<button type="submit" className="btn-accent w-full">
|
||||
Konto anlegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
19
app/reset/confirm/page.tsx
Normal file
19
app/reset/confirm/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Suspense } from "react";
|
||||
import ResetConfirmClient from "./reset-confirm-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function ResetConfirmPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="mx-auto max-w-md card fade-up">
|
||||
<h1 className="text-2xl font-semibold">Neues Passwort</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">Link wird geladen...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ResetConfirmClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
69
app/reset/confirm/reset-confirm-client.tsx
Normal file
69
app/reset/confirm/reset-confirm-client.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ResetConfirmClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const newPassword = formData.get("newPassword");
|
||||
|
||||
const response = await fetch("/api/password-reset/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, newPassword })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.error || "Passwort konnte nicht geändert werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Passwort aktualisiert. Du kannst dich jetzt anmelden.");
|
||||
event.currentTarget.reset();
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md card fade-up">
|
||||
<h1 className="text-2xl font-semibold">Ungültiger Link</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Der Link ist unvollständig oder abgelaufen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md card fade-up">
|
||||
<h1 className="text-2xl font-semibold">Neues Passwort</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Setze ein neues Passwort für dein Konto.
|
||||
</p>
|
||||
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||
<input
|
||||
name="newPassword"
|
||||
type="password"
|
||||
placeholder="Neues Passwort"
|
||||
required
|
||||
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<button type="submit" className="btn-accent w-full">
|
||||
Passwort speichern
|
||||
</button>
|
||||
</form>
|
||||
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
app/reset/page.tsx
Normal file
54
app/reset/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ResetPage() {
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const email = formData.get("email");
|
||||
|
||||
const response = await fetch("/api/password-reset/request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.error || "Anfrage fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Falls ein Konto existiert, wurde ein Link versendet.");
|
||||
event.currentTarget.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md card fade-up">
|
||||
<h1 className="text-2xl font-semibold">Passwort zurücksetzen</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Gib deine E-Mail an und wir senden dir einen Link zum Zurücksetzen.
|
||||
</p>
|
||||
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="E-Mail"
|
||||
required
|
||||
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<button type="submit" className="btn-accent w-full">
|
||||
Link senden
|
||||
</button>
|
||||
</form>
|
||||
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
327
app/settings/page.tsx
Normal file
327
app/settings/page.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data } = useSession();
|
||||
const [viewToken, setViewToken] = useState<string | null>(null);
|
||||
const [viewId, setViewId] = useState<string | null>(null);
|
||||
const [subscribedCategories, setSubscribedCategories] = useState<Set<string>>(new Set());
|
||||
const [allCategories, setAllCategories] = useState<{ id: string; name: string }[]>([]);
|
||||
const [categoryError, setCategoryError] = useState<string | null>(null);
|
||||
const [categoryStatus, setCategoryStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
const [profileStatus, setProfileStatus] = useState<string | null>(null);
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
const [copyStatus, setCopyStatus] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const loadView = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/views/default");
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
setViewToken(payload.token);
|
||||
setViewId(payload.id);
|
||||
const ids = new Set<string>(
|
||||
(payload.categories || []).map((item: { categoryId: string }) => item.categoryId)
|
||||
);
|
||||
setSubscribedCategories(ids);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/categories");
|
||||
if (!response.ok) return;
|
||||
setAllCategories(await response.json());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.user) {
|
||||
loadView();
|
||||
loadCategories();
|
||||
}
|
||||
}, [data?.user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const saved = window.localStorage.getItem("theme");
|
||||
const next = saved === "dark" ? "dark" : "light";
|
||||
setTheme(next);
|
||||
document.documentElement.dataset.theme = next;
|
||||
}, []);
|
||||
|
||||
const rotateToken = async () => {
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
setCopyStatus(null);
|
||||
const response = await fetch("/api/views/default/rotate", { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const payload = await response.json();
|
||||
setError(payload.error || "Token konnte nicht erneuert werden.");
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
setViewToken(payload.token);
|
||||
setStatus("Neuer iCal-Link erstellt.");
|
||||
};
|
||||
|
||||
const updateProfile = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setProfileError(null);
|
||||
setProfileStatus(null);
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const payload = {
|
||||
currentPassword: formData.get("currentPassword"),
|
||||
newEmail: formData.get("newEmail"),
|
||||
newPassword: formData.get("newPassword")
|
||||
};
|
||||
|
||||
const response = await fetch("/api/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setProfileError(data.error || "Profil konnte nicht aktualisiert werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
const dataRes = await response.json();
|
||||
setProfileStatus("Profil aktualisiert.");
|
||||
if (dataRes.changedEmail || dataRes.changedPassword) {
|
||||
setProfileStatus("Profil aktualisiert. Bitte erneut anmelden.");
|
||||
await signOut({ callbackUrl: "/login" });
|
||||
}
|
||||
};
|
||||
|
||||
const baseUrl = typeof window === "undefined" ? "" : window.location.origin;
|
||||
const icalUrl = viewToken ? `${baseUrl}/api/ical/${viewToken}` : "";
|
||||
|
||||
const applyTheme = (next: "light" | "dark") => {
|
||||
setTheme(next);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("theme", next);
|
||||
document.documentElement.dataset.theme = next;
|
||||
}
|
||||
};
|
||||
|
||||
const copyIcalUrl = async () => {
|
||||
if (!icalUrl) return;
|
||||
setCopyStatus(null);
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(icalUrl);
|
||||
} else {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = icalUrl;
|
||||
textarea.setAttribute("readonly", "true");
|
||||
textarea.style.position = "absolute";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
setCopyStatus("success");
|
||||
} catch {
|
||||
setCopyStatus("error");
|
||||
}
|
||||
window.setTimeout(() => setCopyStatus(null), 2500);
|
||||
};
|
||||
|
||||
const toggleCategory = async (categoryId: string) => {
|
||||
if (!viewId) return;
|
||||
setCategoryError(null);
|
||||
setCategoryStatus(null);
|
||||
const isSubscribed = subscribedCategories.has(categoryId);
|
||||
const response = await fetch(`/api/views/${viewId}/categories`, {
|
||||
method: isSubscribed ? "DELETE" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ categoryId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json();
|
||||
setCategoryError(payload.error || "Kategorien konnten nicht aktualisiert werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Set(subscribedCategories);
|
||||
if (isSubscribed) {
|
||||
next.delete(categoryId);
|
||||
setCategoryStatus("Kategorie entfernt.");
|
||||
} else {
|
||||
next.add(categoryId);
|
||||
setCategoryStatus("Kategorie abonniert.");
|
||||
}
|
||||
setSubscribedCategories(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="card space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Profil</p>
|
||||
<h1 className="text-2xl font-semibold">Einstellungen</h1>
|
||||
</div>
|
||||
<form onSubmit={updateProfile} className="grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
name="newEmail"
|
||||
type="email"
|
||||
placeholder={`Neue E-Mail (aktuell: ${data?.user?.email || ""})`}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
|
||||
/>
|
||||
<input
|
||||
name="newPassword"
|
||||
type="password"
|
||||
placeholder="Neues Passwort"
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
placeholder="Aktuelles Passwort (Pflicht)"
|
||||
required
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<button type="submit" className="btn-accent md:col-span-2">
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
{profileStatus && (
|
||||
<p className="text-sm text-emerald-600">{profileStatus}</p>
|
||||
)}
|
||||
{profileError && <p className="text-sm text-red-600">{profileError}</p>}
|
||||
</section>
|
||||
|
||||
<section className="card space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||
Darstellung
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold">Theme</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyTheme("light")}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${
|
||||
theme === "light"
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-200 text-slate-700 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
Hell
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyTheme("dark")}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${
|
||||
theme === "dark"
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-200 text-slate-700 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
Dunkel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card space-y-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">iCal</p>
|
||||
<h2 className="text-lg font-semibold">Persönlicher Kalenderlink</h2>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
Dein Link kann in externen Kalender-Apps abonniert werden.
|
||||
</p>
|
||||
{viewToken ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-medium">iCal URL</p>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyIcalUrl}
|
||||
aria-label="iCal-Link kopieren"
|
||||
className="rounded-full border border-slate-200 p-2 text-slate-600 transition hover:bg-slate-100"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
<rect x="3" y="3" width="13" height="13" rx="2" />
|
||||
</svg>
|
||||
</button>
|
||||
{copyStatus && (
|
||||
<div
|
||||
className={`absolute right-0 top-full mt-2 rounded-full px-3 py-1 text-[11px] font-semibold shadow ${
|
||||
copyStatus === "success"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-rose-100 text-rose-700"
|
||||
}`}
|
||||
>
|
||||
{copyStatus === "success" ? "Kopiert" : "Fehler"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="break-all text-slate-700">{icalUrl}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-600">iCal-Link wird geladen...</p>
|
||||
)}
|
||||
<button type="button" className="btn-ghost" onClick={rotateToken}>
|
||||
Link erneuern
|
||||
</button>
|
||||
{status && <p className="text-sm text-emerald-600">{status}</p>}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</section>
|
||||
|
||||
<section className="card space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||
Kategorien
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold">Kategorien abonnieren</h2>
|
||||
</div>
|
||||
{allCategories.length === 0 ? (
|
||||
<p className="text-sm text-slate-600">Noch keine Kategorien vorhanden.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allCategories.map((category) => {
|
||||
const isSubscribed = subscribedCategories.has(category.id);
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
className={`rounded-full border px-3 py-1 text-xs ${
|
||||
isSubscribed
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
: "border-slate-200 text-slate-700"
|
||||
}`}
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{categoryStatus && (
|
||||
<p className="text-sm text-emerald-600">{categoryStatus}</p>
|
||||
)}
|
||||
{categoryError && <p className="text-sm text-red-600">{categoryError}</p>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
app/verify/confirm/page.tsx
Normal file
19
app/verify/confirm/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Suspense } from "react";
|
||||
import VerifyConfirmClient from "./verify-confirm-client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function VerifyConfirmPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="mx-auto max-w-md card fade-up">
|
||||
<h1 className="text-2xl font-semibold">E-Mail verifizieren</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">Link wird geladen...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<VerifyConfirmClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
55
app/verify/confirm/verify-confirm-client.tsx
Normal file
55
app/verify/confirm/verify-confirm-client.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function VerifyConfirmClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onConfirm = async () => {
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch("/api/verify-email/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.error || "Verifizierung fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("E-Mail verifiziert. Du kannst dich jetzt anmelden.");
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="mx-auto max-w-md card fade-up">
|
||||
<h1 className="text-2xl font-semibold">Ungültiger Link</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Der Link ist unvollständig oder abgelaufen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md card fade-up space-y-3">
|
||||
<h1 className="text-2xl font-semibold">E-Mail verifizieren</h1>
|
||||
<p className="text-sm text-slate-600">
|
||||
Klicke auf den Button, um deine E-Mail zu bestätigen.
|
||||
</p>
|
||||
<button type="button" className="btn-accent" onClick={onConfirm}>
|
||||
E-Mail bestätigen
|
||||
</button>
|
||||
{status && <p className="text-sm text-emerald-600">{status}</p>}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
app/verify/page.tsx
Normal file
54
app/verify/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export default function VerifyPage() {
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const email = formData.get("email");
|
||||
|
||||
const response = await fetch("/api/verify-email/request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.error || "Anfrage fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Falls ein Konto existiert, wurde ein Link versendet.");
|
||||
event.currentTarget.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md card fade-up">
|
||||
<h1 className="text-2xl font-semibold">E-Mail verifizieren</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Gib deine E-Mail an, um einen neuen Verifizierungslink zu erhalten.
|
||||
</p>
|
||||
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="E-Mail"
|
||||
required
|
||||
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<button type="submit" className="btn-accent w-full">
|
||||
Link senden
|
||||
</button>
|
||||
</form>
|
||||
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export default async function ViewsPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-slate-300 bg-white p-8 text-center">
|
||||
<div className="card-muted text-center">
|
||||
<p className="text-slate-700">Bitte anmelden, um Ansichten zu verwalten.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user