"Mein Konto": Profil, Passwort setzen oder wechseln, Passkeys anlegen, umbenennen und entfernen, aktive Sitzungen einsehen und ueberall abmelden. Der letzte Passkey laesst sich nicht entfernen, solange kein Passwort gesetzt ist - sonst waere das Konto ausgesperrt. Admin-Einstellungen: Betriebsart umschalten, Selbstregistrierung freigeben, Konten anlegen, bearbeiten, deaktivieren und loeschen. Die Instanzwerte aus der .env werden angezeigt, damit sichtbar ist, warum Passkeys gegebenenfalls nicht verfuegbar sind. Schutzregeln: - Der Wechsel in den Einzelbetrieb wird abgelehnt, solange mehr als ein Konto existiert. - Der letzte aktive Administrator kann weder herabgestuft noch deaktiviert oder geloescht werden. - Das eigene Konto laesst sich nicht loeschen oder deaktivieren. - Deaktivieren oder ein neues Passwort beenden sofort alle Sitzungen des Kontos. Administratoren verwalten Konten, keine Inhalte: fremde Projekte sehen auch sie nicht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
122 lines
3.9 KiB
TypeScript
122 lines
3.9 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { requireAdminApi } from "@/lib/auth/guard";
|
||
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
||
import { destroyAllSessions } from "@/lib/auth/session";
|
||
import { userSafeSelect } from "@/lib/auth/users";
|
||
import { prisma } from "@/lib/db";
|
||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||
import { adminUserUpdateSchema, formatZodError } from "@/lib/validation";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
type Params = { params: Promise<{ id: string }> };
|
||
|
||
/** Verhindert, dass sich die Instanz ohne Administrator wiederfindet. */
|
||
async function wouldRemoveLastAdmin(userId: string): Promise<boolean> {
|
||
const target = await prisma.user.findUnique({
|
||
where: { id: userId },
|
||
select: { role: true },
|
||
});
|
||
if (target?.role !== "ADMIN") return false;
|
||
|
||
const activeAdmins = await prisma.user.count({
|
||
where: { role: "ADMIN", isActive: true, NOT: { id: userId } },
|
||
});
|
||
return activeAdmins === 0;
|
||
}
|
||
|
||
export async function PATCH(request: Request, { params }: Params) {
|
||
const auth = await requireAdminApi();
|
||
if (!auth.ok) return auth.response;
|
||
|
||
const { id } = await params;
|
||
|
||
const body = await readJsonBody(request);
|
||
if (!body.ok) return body.response;
|
||
|
||
const parsed = adminUserUpdateSchema.safeParse(body.data);
|
||
if (!parsed.success) {
|
||
return NextResponse.json(
|
||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||
{ status: 422 },
|
||
);
|
||
}
|
||
|
||
const target = await prisma.user.findUnique({ where: { id } });
|
||
if (!target) return jsonError("Konto nicht gefunden.", 404);
|
||
|
||
const { displayName, email, role, isActive, password } = parsed.data;
|
||
|
||
const losesAdmin = (role && role !== "ADMIN") || isActive === false;
|
||
if (losesAdmin && (await wouldRemoveLastAdmin(id))) {
|
||
return jsonError(
|
||
"Das ist der letzte aktive Administrator – Rolle und Status koennen nicht geaendert werden.",
|
||
409,
|
||
);
|
||
}
|
||
|
||
if (email) {
|
||
const taken = await prisma.user.findFirst({
|
||
where: { email, NOT: { id } },
|
||
select: { id: true },
|
||
});
|
||
if (taken) {
|
||
return NextResponse.json(
|
||
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
||
{ status: 409 },
|
||
);
|
||
}
|
||
}
|
||
|
||
if (password) {
|
||
const weak = validatePasswordStrength(password);
|
||
if (weak) {
|
||
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
||
}
|
||
}
|
||
|
||
const user = await prisma.user.update({
|
||
where: { id },
|
||
data: {
|
||
...(displayName !== undefined ? { displayName } : {}),
|
||
...(email !== undefined ? { email } : {}),
|
||
...(role ? { role } : {}),
|
||
...(isActive !== undefined ? { isActive } : {}),
|
||
...(password ? { passwordHash: await hashPassword(password) } : {}),
|
||
// Ein Eingriff des Administrators hebt eine Sperre auf.
|
||
...(isActive === true ? { lockedUntil: null, failedLoginCount: 0 } : {}),
|
||
},
|
||
select: userSafeSelect,
|
||
});
|
||
|
||
// Deaktivieren oder ein neues Passwort beenden alle Sitzungen des Kontos.
|
||
if (isActive === false || password) {
|
||
await destroyAllSessions(id);
|
||
}
|
||
|
||
return NextResponse.json({ user });
|
||
}
|
||
|
||
export async function DELETE(_request: Request, { params }: Params) {
|
||
const auth = await requireAdminApi();
|
||
if (!auth.ok) return auth.response;
|
||
|
||
const { id } = await params;
|
||
|
||
if (id === auth.user.id) {
|
||
return jsonError("Das eigene Konto kann nicht geloescht werden.", 409);
|
||
}
|
||
if (await wouldRemoveLastAdmin(id)) {
|
||
return jsonError("Der letzte aktive Administrator kann nicht geloescht werden.", 409);
|
||
}
|
||
|
||
try {
|
||
// Projekte, Verbindungen, Passkeys und Sitzungen haengen per
|
||
// onDelete: Cascade daran und verschwinden mit.
|
||
await prisma.user.delete({ where: { id } });
|
||
return NextResponse.json({ ok: true });
|
||
} catch {
|
||
return jsonError("Konto nicht gefunden.", 404);
|
||
}
|
||
}
|