feat(admin): Kontoverwaltung und Admin-Einstellungen

"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>
This commit is contained in:
2026-07-25 21:30:33 +02:00
co-authored by Claude Opus 5
parent 4f85e00016
commit cf4103a46c
11 changed files with 1810 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
import type { Metadata } from "next";
import { AccountManager, type AccountData } from "@/components/account/AccountManager";
import { requireUserPage } from "@/lib/auth/guard";
import { WEBAUTHN_AVAILABLE } from "@/lib/config";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: "Mein Konto" };
}
export default async function AccountPage() {
const user = await requireUserPage();
const [passkeys, sessions] = await Promise.all([
prisma.credential.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
deviceType: true,
backedUp: true,
createdAt: true,
lastUsedAt: true,
},
orderBy: { createdAt: "asc" },
}),
prisma.session.findMany({
where: { userId: user.id, expiresAt: { gt: new Date() } },
select: {
id: true,
userAgent: true,
ip: true,
createdAt: true,
lastSeenAt: true,
},
orderBy: { lastSeenAt: "desc" },
}),
]);
const data: AccountData = {
username: user.username,
displayName: user.displayName,
email: user.email,
role: user.role,
hasPassword: user.hasPassword,
passkeys,
sessions,
passkeysAvailable: WEBAUTHN_AVAILABLE,
};
return (
<div className="mx-auto max-w-3xl space-y-6 pt-4">
<header className="fade-up">
<h1 className="text-2xl font-semibold tracking-tight text-white">Mein Konto</h1>
<p className="mt-1 text-sm text-slate-400">
Profil, Anmeldemittel und aktive Sitzungen.
</p>
</header>
<AccountManager data={data} />
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { Metadata } from "next";
import { AdminManager, type AdminData } from "@/components/admin/AdminManager";
import { requireAdminPage } from "@/lib/auth/guard";
import { getAppSettings } from "@/lib/auth/settings";
import { userSafeSelect } from "@/lib/auth/users";
import { APP_URL, WEBAUTHN_AVAILABLE } from "@/lib/config";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: "Admin-Einstellungen" };
}
export default async function AdminPage() {
const admin = await requireAdminPage();
const [settings, users] = await Promise.all([
getAppSettings(),
prisma.user.findMany({
select: userSafeSelect,
orderBy: [{ role: "asc" }, { username: "asc" }],
}),
]);
const data: AdminData = {
mode: settings.mode,
allowRegistration: settings.allowRegistration,
users,
currentUserId: admin.id,
appUrl: APP_URL,
passkeysAvailable: WEBAUTHN_AVAILABLE,
};
return (
<div className="mx-auto max-w-4xl space-y-6 pt-4">
<header className="fade-up">
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Administration
</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-white">
Admin-Einstellungen
</h1>
<p className="mt-1 text-sm text-slate-400">
Betriebsart, Registrierung und Benutzerkonten.
</p>
</header>
<AdminManager data={data} />
</div>
);
}
@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, passkeyNameSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Umbenennen – nur der eigene Passkey. */
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = passkeyNameSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
// Der Besitzvergleich steht in der where-Klausel, damit fremde Passkeys
// gar nicht erst getroffen werden.
const result = await prisma.credential.updateMany({
where: { id, userId: auth.user.id },
data: { name: parsed.data.name ?? null },
});
if (result.count === 0) return jsonError("Passkey nicht gefunden.", 404);
return NextResponse.json({ ok: true });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const user = await prisma.user.findUnique({
where: { id: auth.user.id },
select: { passwordHash: true, _count: { select: { credentials: true } } },
});
if (!user) return jsonError("Konto nicht gefunden.", 404);
// Nicht das letzte Anmeldemittel entfernen.
if (!user.passwordHash && user._count.credentials <= 1) {
return jsonError(
"Das ist der letzte Passkey und es ist kein Passwort gesetzt. " +
"Bitte zuerst ein Passwort vergeben.",
409,
);
}
const result = await prisma.credential.deleteMany({ where: { id, userId: auth.user.id } });
if (result.count === 0) return jsonError("Passkey nicht gefunden.", 404);
return NextResponse.json({ ok: true });
}
+71
View File
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { hashPassword, validatePasswordStrength, verifyPassword } from "@/lib/auth/password";
import { createSession, destroyAllSessions, setSessionCookie } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { clientIp, jsonError, readJsonBody } from "@/lib/security/request";
import { changePasswordSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Eigenes Passwort setzen oder aendern.
*
* Wer bisher nur einen Passkey hat, darf ohne altes Passwort eines
* vergeben – die Sitzung selbst ist der Nachweis. Nach der Aenderung werden
* alle Sitzungen verworfen und die aktuelle neu ausgestellt.
*/
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = changePasswordSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const user = await prisma.user.findUnique({ where: { id: auth.user.id } });
if (!user) return jsonError("Konto nicht gefunden.", 404);
if (user.passwordHash) {
const current = parsed.data.currentPassword ?? "";
const valid = await verifyPassword(current, user.passwordHash);
if (!valid) {
return NextResponse.json(
{
error: "Das aktuelle Passwort stimmt nicht.",
fields: { currentPassword: "Falsch" },
},
{ status: 401 },
);
}
}
const weak = validatePasswordStrength(parsed.data.newPassword);
if (weak) {
return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 });
}
await prisma.user.update({
where: { id: user.id },
data: { passwordHash: await hashPassword(parsed.data.newPassword) },
});
// Alle bestehenden Sitzungen entwerten und fuer dieses Geraet eine neue
// ausstellen, damit der Wechsel nicht abmeldet.
await destroyAllSessions(user.id);
const session = await createSession(user.id, {
userAgent: request.headers.get("user-agent"),
ip: clientIp(request),
});
const response = NextResponse.json({ ok: true });
setSessionCookie(response, session.token, session.expiresAt);
return response;
}
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { readJsonBody } from "@/lib/security/request";
import { formatZodError, profileUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function PATCH(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = profileUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { displayName, email } = parsed.data;
if (email) {
const taken = await prisma.user.findFirst({
where: { email, NOT: { id: auth.user.id } },
select: { id: true },
});
if (taken) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
{ status: 409 },
);
}
}
const user = await prisma.user.update({
where: { id: auth.user.id },
data: {
...(displayName !== undefined ? { displayName } : {}),
...(email !== undefined ? { email } : {}),
},
select: { id: true, username: true, displayName: true, email: true, role: true },
});
return NextResponse.json({ user });
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { createSession, destroyAllSessions, setSessionCookie } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { clientIp } from "@/lib/security/request";
export const dynamic = "force-dynamic";
/** Uebersicht der eigenen aktiven Sitzungen. */
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const sessions = await prisma.session.findMany({
where: { userId: auth.user.id, expiresAt: { gt: new Date() } },
select: {
id: true,
userAgent: true,
ip: true,
createdAt: true,
lastSeenAt: true,
expiresAt: true,
},
orderBy: { lastSeenAt: "desc" },
});
return NextResponse.json({ sessions });
}
/** Ueberall abmelden – das aktuelle Geraet bleibt angemeldet. */
export async function DELETE(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
await destroyAllSessions(auth.user.id);
const session = await createSession(auth.user.id, {
userAgent: request.headers.get("user-agent"),
ip: clientIp(request),
});
const response = NextResponse.json({ ok: true });
setSessionCookie(response, session.token, session.expiresAt);
return response;
}
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { requireAdminApi } from "@/lib/auth/guard";
import { getAppSettings } from "@/lib/auth/settings";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { adminSettingsSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const [settings, userCount] = await Promise.all([getAppSettings(), prisma.user.count()]);
return NextResponse.json({ settings, userCount });
}
export async function PATCH(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = adminSettingsSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { mode, allowRegistration } = parsed.data;
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
// deshalb nur erlauben, wenn es wirklich nur ein Konto gibt.
if (mode === "SINGLE_USER") {
const userCount = await prisma.user.count();
if (userCount > 1) {
return jsonError(
`Einzelbetrieb ist nicht moeglich, solange ${userCount} Konten existieren. ` +
"Bitte zuerst die uebrigen Konten entfernen.",
409,
);
}
}
const settings = await prisma.appSettings.upsert({
where: { id: "singleton" },
create: {
id: "singleton",
...(mode ? { mode } : {}),
...(allowRegistration !== undefined ? { allowRegistration } : {}),
},
update: {
...(mode ? { mode } : {}),
...(allowRegistration !== undefined ? { allowRegistration } : {}),
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}),
},
});
return NextResponse.json({ settings });
}
+121
View File
@@ -0,0 +1,121 @@
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);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import { requireAdminApi } from "@/lib/auth/guard";
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
import { getInstanceState } from "@/lib/auth/settings";
import { userSafeSelect } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { adminUserCreateSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const users = await prisma.user.findMany({
select: userSafeSelect,
orderBy: [{ role: "asc" }, { username: "asc" }],
});
return NextResponse.json({ users });
}
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const state = await getInstanceState();
if (!state.multiUser) {
return jsonError(
"Im Einzelbetrieb kann kein weiteres Konto angelegt werden. " +
"Bitte zuerst in den Mehrbenutzerbetrieb wechseln.",
409,
);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = adminUserCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { username, displayName, email, password, role } = parsed.data;
const weak = validatePasswordStrength(password);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
const taken = await prisma.user.findUnique({ where: { username }, select: { id: true } });
if (taken) {
return NextResponse.json(
{ error: "Dieser Benutzername ist bereits vergeben.", fields: { username: "Bereits vergeben" } },
{ status: 409 },
);
}
if (email) {
const emailTaken = await prisma.user.findUnique({ where: { email }, select: { id: true } });
if (emailTaken) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
{ status: 409 },
);
}
}
const user = await prisma.user.create({
data: {
username,
displayName: displayName ?? null,
email: email ?? null,
passwordHash: await hashPassword(password),
role,
},
select: userSafeSelect,
});
return NextResponse.json({ user }, { status: 201 });
}