"use server"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { checkPasswordStrength, createApiKey, destroyAllSessions, getSessionUser, hashPassword, verifyPassword, } from "@/lib/auth"; import { prisma } from "@/lib/db"; import { setSetting } from "@/lib/settings"; import { sendTestNotification } from "@/lib/notify"; import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer"; import { runSync } from "@/lib/sync"; /** Server-Aktionen für Einstellungen, Kanäle, API-Schlüssel und Verwaltung. */ export type SettingsResult = { ok: boolean; message?: string; secret?: string }; async function run( fn: (userId: string) => Promise<{ message?: string; secret?: string } | void>, ): Promise { const user = await getSessionUser(); if (!user) return { ok: false, message: "Bitte melde dich an." }; try { const result = (await fn(user.id)) ?? {}; revalidatePath("/einstellungen", "layout"); revalidatePath("/", "layout"); return { ok: true, ...result }; } catch (error) { console.error("Einstellungs-Aktion fehlgeschlagen:", error); return { ok: false, message: error instanceof Error ? error.message : "Das hat nicht geklappt.", }; } } async function requireAdminUser() { const user = await getSessionUser(); if (!user || user.role !== "ADMIN") { throw new Error("Dafür fehlen dir die Rechte."); } return user; } // --------------------------------------------------------------------------- // Profil // --------------------------------------------------------------------------- const profileSchema = z.object({ name: z.string().trim().min(1).max(80), region: z.string().trim().length(2).toUpperCase(), publicProfile: z.boolean(), profileSlug: z .string() .trim() .toLowerCase() .regex(/^[a-z0-9-]{3,40}$/, "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 40 Zeichen.") .nullable(), notifyNewEpisode: z.boolean(), notifyRelease: z.boolean(), notifyAvailability: z.boolean(), }); export async function updateProfileAction(data: unknown) { return run(async (userId) => { const parsed = profileSchema.parse(data); if (parsed.publicProfile && !parsed.profileSlug) { throw new Error("Für ein öffentliches Profil braucht es eine Adresse."); } if (parsed.profileSlug) { const taken = await prisma.user.findFirst({ where: { profileSlug: parsed.profileSlug, id: { not: userId } }, select: { id: true }, }); if (taken) throw new Error("Diese Profiladresse ist schon vergeben."); } await prisma.user.update({ where: { id: userId }, data: parsed }); return { message: "Gespeichert" }; }); } const passwordSchema = z.object({ current: z.string().min(1), next: z.string().min(1), repeat: z.string().min(1), }); export async function changePasswordAction(data: unknown) { return run(async (userId) => { const parsed = passwordSchema.parse(data); if (parsed.next !== parsed.repeat) throw new Error("Die neuen Passwörter stimmen nicht überein."); const weak = checkPasswordStrength(parsed.next); if (weak) throw new Error(weak); const user = await prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { passwordHash: true }, }); if (!user.passwordHash) { throw new Error("Dieses Konto meldet sich über SSO an und hat kein Passwort."); } if (!(await verifyPassword(parsed.current, user.passwordHash))) { throw new Error("Das bisherige Passwort stimmt nicht."); } await prisma.user.update({ where: { id: userId }, data: { passwordHash: await hashPassword(parsed.next) }, }); // Nach einem Passwortwechsel fliegen alle anderen Geräte raus. await destroyAllSessions(userId); return { message: "Passwort geändert — bitte neu anmelden." }; }); } // --------------------------------------------------------------------------- // API-Schlüssel // --------------------------------------------------------------------------- export async function createApiKeyAction(name: string) { return run(async (userId) => { const clean = z.string().trim().min(1).max(60).parse(name); const { plain } = await createApiKey(userId, clean); // Der Klartext existiert nur hier — danach steht in der Datenbank nur der Hash. return { message: "Schlüssel angelegt", secret: plain }; }); } export async function revokeApiKeyAction(keyId: string) { return run(async (userId) => { await prisma.apiKey.updateMany({ where: { id: keyId, userId }, data: { revokedAt: new Date() }, }); return { message: "Schlüssel zurückgezogen" }; }); } // --------------------------------------------------------------------------- // Benachrichtigungskanäle // --------------------------------------------------------------------------- const channelSchema = z.object({ kind: z.enum(["EMAIL", "NTFY", "GOTIFY", "WEBHOOK"]), name: z.string().trim().min(1).max(60), config: z.record(z.string(), z.string().max(500)).default({}), }); export async function saveChannelAction(data: unknown, channelId?: string) { return run(async (userId) => { const parsed = channelSchema.parse(data); if (channelId) { await prisma.notificationChannel.updateMany({ where: { id: channelId, userId }, data: { kind: parsed.kind, name: parsed.name, config: parsed.config }, }); return { message: "Kanal gespeichert" }; } await prisma.notificationChannel.create({ data: { userId, kind: parsed.kind, name: parsed.name, config: parsed.config }, }); return { message: "Kanal angelegt" }; }); } export async function toggleChannelAction(channelId: string, enabled: boolean) { return run(async (userId) => { await prisma.notificationChannel.updateMany({ where: { id: channelId, userId }, data: { enabled }, }); return { message: enabled ? "Kanal aktiv" : "Kanal ruht" }; }); } export async function deleteChannelAction(channelId: string) { return run(async (userId) => { await prisma.notificationChannel.deleteMany({ where: { id: channelId, userId } }); return { message: "Kanal entfernt" }; }); } export async function testChannelAction(channelId: string) { return run(async (userId) => { await sendTestNotification(userId, channelId); return { message: "Testnachricht verschickt" }; }); } // --------------------------------------------------------------------------- // Import // --------------------------------------------------------------------------- export type ImportResult = { ok: boolean; message?: string; summary?: ImportSummary }; export async function importDataAction( format: "csv" | "trakt", content: string, ): Promise { const user = await getSessionUser(); if (!user) return { ok: false, message: "Bitte melde dich an." }; if (content.length > 8_000_000) { return { ok: false, message: "Die Datei ist zu groß (mehr als 8 MB)." }; } try { const summary = format === "csv" ? await importCsv(user.id, content) : await importTrakt(user.id, content); revalidatePath("/bibliothek", "layout"); revalidatePath("/", "layout"); return { ok: true, message: `${summary.added} aufgenommen, ${summary.skipped} übersprungen`, summary, }; } catch (error) { console.error("Import fehlgeschlagen:", error); return { ok: false, message: error instanceof Error ? error.message : "Import fehlgeschlagen." }; } } // --------------------------------------------------------------------------- // Verwaltung // --------------------------------------------------------------------------- export async function setRegistrationAction(enabled: boolean) { return run(async () => { await requireAdminUser(); await setSetting("registrationEnabled", enabled); return { message: enabled ? "Registrierung offen" : "Registrierung geschlossen" }; }); } export async function setInstanceNameAction(name: string) { return run(async () => { await requireAdminUser(); await setSetting("instanceName", z.string().trim().min(1).max(60).parse(name)); return { message: "Name gespeichert" }; }); } export async function setPublicProfilesAction(enabled: boolean) { return run(async () => { await requireAdminUser(); await setSetting("publicProfilesEnabled", enabled); return { message: enabled ? "Öffentliche Profile erlaubt" : "Öffentliche Profile abgeschaltet" }; }); } export async function setUserActiveAction(targetId: string, active: boolean) { return run(async (userId) => { await requireAdminUser(); if (targetId === userId) throw new Error("Dich selbst kannst du nicht sperren."); await prisma.user.update({ where: { id: targetId }, data: { isActive: active } }); if (!active) await destroyAllSessions(targetId); revalidatePath("/verwaltung", "layout"); return { message: active ? "Konto freigegeben" : "Konto gesperrt" }; }); } export async function setUserRoleAction(targetId: string, role: "ADMIN" | "USER") { return run(async (userId) => { await requireAdminUser(); if (targetId === userId) throw new Error("Deine eigene Rolle kannst du nicht ändern."); if (role === "USER") { const admins = await prisma.user.count({ where: { role: "ADMIN", isActive: true } }); if (admins <= 1) throw new Error("Es muss mindestens eine Administration geben."); } await prisma.user.update({ where: { id: targetId }, data: { role } }); revalidatePath("/verwaltung", "layout"); return { message: role === "ADMIN" ? "Zur Administration ernannt" : "Rechte entzogen" }; }); } export async function runSyncAction() { return run(async () => { await requireAdminUser(); const stats = await runSync({ limit: 40 }); revalidatePath("/verwaltung", "layout"); return { message: stats.errors.length === 0 ? `${stats.titlesUpdated} Titel aufgefrischt, ${stats.newEpisodes} neue Episoden, ${stats.notificationsSent} Benachrichtigungen` : `Mit ${stats.errors.length} Fehlern beendet: ${stats.errors[0]}`, }; }); }