feat: Einstellungen, Verwaltung und öffentliches Profil
Nach einem Passwortwechsel fliegen alle anderen Geräte raus; ein gesperrtes Konto wird sofort abgemeldet. Die letzte Administration lässt sich nicht entmachten, und sich selbst kann niemand sperren. Das öffentliche Profil zeigt Zahlen und zuletzt Gesehenes — keine Notizen, keine Watchlist, keine E-Mail-Adresse. Was privat gemeint ist, bleibt privat. Es braucht die Freigabe des Nutzers und die des Betreibers.
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
"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<SettingsResult> {
|
||||
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<ImportResult> {
|
||||
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]}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user