From cf4103a46ccb564f8a579906e81aab4da2d4c57d Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 25 Jul 2026 21:30:33 +0200 Subject: [PATCH] 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) --- src/app/account/page.tsx | 65 ++ src/app/admin/page.tsx | 52 ++ src/app/api/account/passkeys/[id]/route.ts | 64 ++ src/app/api/account/password/route.ts | 71 +++ src/app/api/account/profile/route.ts | 49 ++ src/app/api/account/sessions/route.ts | 44 ++ src/app/api/admin/settings/route.ts | 64 ++ src/app/api/admin/users/[id]/route.ts | 121 ++++ src/app/api/admin/users/route.ts | 85 +++ src/components/account/AccountManager.tsx | 530 ++++++++++++++++ src/components/admin/AdminManager.tsx | 665 +++++++++++++++++++++ 11 files changed, 1810 insertions(+) create mode 100644 src/app/account/page.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/api/account/passkeys/[id]/route.ts create mode 100644 src/app/api/account/password/route.ts create mode 100644 src/app/api/account/profile/route.ts create mode 100644 src/app/api/account/sessions/route.ts create mode 100644 src/app/api/admin/settings/route.ts create mode 100644 src/app/api/admin/users/[id]/route.ts create mode 100644 src/app/api/admin/users/route.ts create mode 100644 src/components/account/AccountManager.tsx create mode 100644 src/components/admin/AdminManager.tsx diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx new file mode 100644 index 0000000..d15252b --- /dev/null +++ b/src/app/account/page.tsx @@ -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 { + 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 ( +
+
+

Mein Konto

+

+ Profil, Anmeldemittel und aktive Sitzungen. +

+
+ + +
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..f3858c2 --- /dev/null +++ b/src/app/admin/page.tsx @@ -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 { + 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 ( +
+
+

+ Administration +

+

+ Admin-Einstellungen +

+

+ Betriebsart, Registrierung und Benutzerkonten. +

+
+ + +
+ ); +} diff --git a/src/app/api/account/passkeys/[id]/route.ts b/src/app/api/account/passkeys/[id]/route.ts new file mode 100644 index 0000000..5304646 --- /dev/null +++ b/src/app/api/account/passkeys/[id]/route.ts @@ -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 }); +} diff --git a/src/app/api/account/password/route.ts b/src/app/api/account/password/route.ts new file mode 100644 index 0000000..4a93741 --- /dev/null +++ b/src/app/api/account/password/route.ts @@ -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; +} diff --git a/src/app/api/account/profile/route.ts b/src/app/api/account/profile/route.ts new file mode 100644 index 0000000..842515b --- /dev/null +++ b/src/app/api/account/profile/route.ts @@ -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 }); +} diff --git a/src/app/api/account/sessions/route.ts b/src/app/api/account/sessions/route.ts new file mode 100644 index 0000000..d6a95fa --- /dev/null +++ b/src/app/api/account/sessions/route.ts @@ -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; +} diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts new file mode 100644 index 0000000..8dbdd10 --- /dev/null +++ b/src/app/api/admin/settings/route.ts @@ -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 }); +} diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..cacf38f --- /dev/null +++ b/src/app/api/admin/users/[id]/route.ts @@ -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 { + 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); + } +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..d4902a7 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -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 }); +} diff --git a/src/components/account/AccountManager.tsx b/src/components/account/AccountManager.tsx new file mode 100644 index 0000000..6a0d0bf --- /dev/null +++ b/src/components/account/AccountManager.tsx @@ -0,0 +1,530 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { startRegistration } from "@simplewebauthn/browser"; +import { + Fingerprint, + KeyRound, + Loader2, + LogOut, + Monitor, + Pencil, + Save, + ShieldCheck, + Trash2, +} from "lucide-react"; +import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants"; +import { cn, formatDateTime, formatRelative } from "@/lib/utils"; + +export interface PasskeyEntry { + id: string; + name: string | null; + deviceType: string | null; + backedUp: boolean; + createdAt: string | Date; + lastUsedAt: string | Date | null; +} + +export interface SessionEntry { + id: string; + userAgent: string | null; + ip: string | null; + createdAt: string | Date; + lastSeenAt: string | Date; +} + +export interface AccountData { + username: string; + displayName: string | null; + email: string | null; + role: "ADMIN" | "USER"; + hasPassword: boolean; + passkeys: PasskeyEntry[]; + sessions: SessionEntry[]; + passkeysAvailable: boolean; +} + +type Notice = { ok: boolean; text: string } | null; + +export function AccountManager({ data }: { data: AccountData }) { + return ( +
+ + + + +
+ ); +} + +function SectionCard({ + title, + description, + icon: Icon, + children, +}: { + title: string; + description?: string; + icon: typeof KeyRound; + children: React.ReactNode; +}) { + return ( +
+
+ + + +
+

+ {title} +

+ {description ? ( +

{description}

+ ) : null} +
+
+ {children} +
+ ); +} + +function NoticeLine({ notice }: { notice: Notice }) { + if (!notice) return null; + return ( +

+ {notice.text} +

+ ); +} + +function ProfileSection({ data }: { data: AccountData }) { + const router = useRouter(); + const [displayName, setDisplayName] = useState(data.displayName ?? ""); + const [email, setEmail] = useState(data.email ?? ""); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + + async function save(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setNotice(null); + try { + const response = await fetch("/api/account/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName, email }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen."); + setNotice({ ok: true, text: "Profil gespeichert." }); + router.refresh(); + } catch (caught) { + setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); + } finally { + setBusy(false); + } + } + + return ( + +
+
+ Benutzername +

@{data.username}

+

+ {data.role === "ADMIN" ? "Administrator" : "Benutzer"} · nicht aenderbar +

+
+
+
+ + setDisplayName(event.target.value)} + /> +
+
+ + setEmail(event.target.value)} + placeholder="optional" + /> +
+
+ + +
+ + + ); +} + +function PasswordSection({ hasPassword }: { hasPassword: boolean }) { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [repeat, setRepeat] = useState(""); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + + async function save(event: React.FormEvent) { + event.preventDefault(); + if (newPassword !== repeat) { + setNotice({ ok: false, text: "Die Passwoerter stimmen nicht ueberein." }); + return; + } + setBusy(true); + setNotice(null); + try { + const response = await fetch("/api/account/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error ?? "Aenderung fehlgeschlagen."); + setNotice({ + ok: true, + text: "Passwort gesetzt. Alle anderen Sitzungen wurden abgemeldet.", + }); + setCurrentPassword(""); + setNewPassword(""); + setRepeat(""); + } catch (caught) { + setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); + } finally { + setBusy(false); + } + } + + return ( + +
+ {hasPassword ? ( +
+ + setCurrentPassword(event.target.value)} + autoComplete="current-password" + required + /> +
+ ) : null} + +
+ + setNewPassword(event.target.value)} + autoComplete="new-password" + minLength={PASSWORD_MIN_LENGTH} + required + /> +
+
+ + setRepeat(event.target.value)} + autoComplete="new-password" + required + /> +
+ +
+ + +
+
+
+ ); +} + +function PasskeySection({ + passkeys, + available, +}: { + passkeys: PasskeyEntry[]; + available: boolean; +}) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [busyId, setBusyId] = useState(null); + const [notice, setNotice] = useState(null); + + async function addPasskey() { + setBusy(true); + setNotice(null); + try { + const optionsResponse = await fetch("/api/auth/passkey/register/options", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const optionsPayload = await optionsResponse.json().catch(() => ({})); + if (!optionsResponse.ok) { + throw new Error(optionsPayload.error ?? "Passkey konnte nicht vorbereitet werden."); + } + + const attestation = await startRegistration({ optionsJSON: optionsPayload.options }); + + const suggestion = + typeof navigator !== "undefined" && /mac/i.test(navigator.platform) + ? "Mac" + : typeof navigator !== "undefined" && /win/i.test(navigator.platform) + ? "Windows" + : "Geraet"; + + const verifyResponse = await fetch("/api/auth/passkey/register/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ response: attestation, name: suggestion }), + }); + const verifyPayload = await verifyResponse.json().catch(() => ({})); + if (!verifyResponse.ok) { + throw new Error(verifyPayload.error ?? "Passkey wurde nicht akzeptiert."); + } + + setNotice({ ok: true, text: "Passkey hinzugefuegt." }); + router.refresh(); + } catch (caught) { + const message = caught instanceof Error ? caught.message : String(caught); + if (!/abort|NotAllowed/i.test(message)) setNotice({ ok: false, text: message }); + } finally { + setBusy(false); + } + } + + async function rename(entry: PasskeyEntry) { + const name = window.prompt("Name des Passkeys", entry.name ?? ""); + if (name === null) return; + setBusyId(entry.id); + await fetch(`/api/account/passkeys/${entry.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }).catch(() => undefined); + setBusyId(null); + router.refresh(); + } + + async function remove(entry: PasskeyEntry) { + if (!window.confirm(`Passkey „${entry.name ?? "ohne Namen"}“ entfernen?`)) return; + setBusyId(entry.id); + try { + const response = await fetch(`/api/account/passkeys/${entry.id}`, { method: "DELETE" }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error ?? "Loeschen fehlgeschlagen."); + router.refresh(); + } catch (caught) { + setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); + } finally { + setBusyId(null); + } + } + + return ( + + {!available ? ( +

+ Passkeys sind derzeit nicht verfuegbar. Sie benoetigen einen sicheren Kontext: + APP_URL muss auf eine https-Adresse + oder auf http://localhost zeigen. +

+ ) : ( + <> +
+ {passkeys.length === 0 ? ( +

Noch kein Passkey hinterlegt.

+ ) : ( + passkeys.map((entry) => ( +
+
+

+ + {entry.name ?? "Unbenannter Passkey"} + {entry.backedUp ? ( + + synchronisiert + + ) : null} +

+

+ angelegt {formatDateTime(entry.createdAt)} + {entry.lastUsedAt + ? ` · zuletzt genutzt ${formatRelative(entry.lastUsedAt)}` + : " · noch nie genutzt"} +

+
+
+ {busyId === entry.id ? ( + + ) : ( + <> + + + + )} +
+
+ )) + )} +
+ + + + )} + + +
+ ); +} + +function SessionSection({ sessions }: { sessions: SessionEntry[] }) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + + async function logoutEverywhere() { + if (!window.confirm("Auf allen anderen Geraeten abmelden?")) return; + setBusy(true); + await fetch("/api/account/sessions", { method: "DELETE" }).catch(() => undefined); + setBusy(false); + router.refresh(); + } + + return ( + +
+ {sessions.map((session) => ( +
+

+ {shortenUserAgent(session.userAgent)} +

+

+ {session.ip ?? "unbekannte Adresse"} · aktiv {formatRelative(session.lastSeenAt)} · + angemeldet {formatDateTime(session.createdAt)} +

+
+ ))} +
+ + + {sessions.length <= 1 ? ( +

+ + Nur dieses Geraet ist angemeldet. +

+ ) : null} +
+ ); +} + +/** Aus dem User-Agent etwas Lesbares machen, ohne Bibliothek. */ +function shortenUserAgent(userAgent: string | null): string { + if (!userAgent) return "Unbekanntes Geraet"; + + const browser = + /Edg\//.test(userAgent) ? "Edge" + : /OPR\//.test(userAgent) ? "Opera" + : /Firefox\//.test(userAgent) ? "Firefox" + : /Chrome\//.test(userAgent) ? "Chrome" + : /Safari\//.test(userAgent) ? "Safari" + : "Browser"; + + const platform = + /Windows/.test(userAgent) ? "Windows" + : /Macintosh|Mac OS/.test(userAgent) ? "macOS" + : /Android/.test(userAgent) ? "Android" + : /iPhone|iPad/.test(userAgent) ? "iOS" + : /Linux/.test(userAgent) ? "Linux" + : ""; + + return platform ? `${browser} auf ${platform}` : browser; +} diff --git a/src/components/admin/AdminManager.tsx b/src/components/admin/AdminManager.tsx new file mode 100644 index 0000000..748b360 --- /dev/null +++ b/src/components/admin/AdminManager.tsx @@ -0,0 +1,665 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { + Ban, + CheckCircle2, + Fingerprint, + FolderGit2, + Loader2, + Lock, + Plus, + ShieldCheck, + Trash2, + User, + UserCog, + Users, +} from "lucide-react"; +import { Modal } from "@/components/ui/Modal"; +import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants"; +import { cn, formatDateTime, formatRelative } from "@/lib/utils"; + +type Mode = "SINGLE_USER" | "MULTI_USER"; +type Role = "ADMIN" | "USER"; + +export interface AdminUser { + id: string; + username: string; + displayName: string | null; + email: string | null; + role: Role; + isActive: boolean; + lastLoginAt: string | Date | null; + lockedUntil: string | Date | null; + createdAt: string | Date; + _count: { credentials: number; projects: number; sessions: number }; +} + +export interface AdminData { + mode: Mode; + allowRegistration: boolean; + users: AdminUser[]; + currentUserId: string; + appUrl: string; + passkeysAvailable: boolean; +} + +type Notice = { ok: boolean; text: string } | null; + +export function AdminManager({ data }: { data: AdminData }) { + const router = useRouter(); + const [mode, setMode] = useState(data.mode); + const [allowRegistration, setAllowRegistration] = useState(data.allowRegistration); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + + async function patchSettings(payload: Partial<{ mode: Mode; allowRegistration: boolean }>) { + setBusy(true); + setNotice(null); + try { + const response = await fetch("/api/admin/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen."); + + setMode(body.settings.mode); + setAllowRegistration(body.settings.allowRegistration); + setNotice({ ok: true, text: "Einstellungen gespeichert." }); + router.refresh(); + } catch (caught) { + // Umschalter zuruecksetzen, wenn der Server ablehnt. + setMode(data.mode); + setAllowRegistration(data.allowRegistration); + setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); + } finally { + setBusy(false); + } + } + + return ( +
+ {/* ── Betriebsart ─────────────────────────────────── */} +
+
+ + + +
+

+ Betriebsart +

+

+ Bestimmt, ob die Instanz nur dir gehoert oder mehrere Konten verwaltet. +

+
+
+ +
+ { + setMode("SINGLE_USER"); + void patchSettings({ mode: "SINGLE_USER" }); + }} + /> + { + setMode("MULTI_USER"); + void patchSettings({ mode: "MULTI_USER" }); + }} + /> +
+ + {mode === "MULTI_USER" ? ( + + ) : null} + + {notice ? ( +

+ {notice.text} +

+ ) : null} +
+ + {/* ── Instanz ─────────────────────────────────────── */} +
+
+ + + +
+

+ Instanz +

+

+ Diese Werte kommen aus der .env und + lassen sich nur dort aendern. +

+
+
+ +
+
+
APP_URL
+
{data.appUrl}
+
+
+
Passkeys
+
+ {data.passkeysAvailable ? ( + <> + + verfuegbar + + ) : ( + <> + + kein sicherer Kontext + + )} +
+
+
+
+ + {/* ── Benutzer ────────────────────────────────────── */} +
+
+
+ + + +
+

+ Benutzer ({data.users.length}) +

+

+ Administratoren verwalten Konten – Projekte anderer sehen sie nicht. +

+
+
+ + +
+ +
+ {data.users.map((user) => ( + setEditing(user)} + /> + ))} +
+
+ + setCreateOpen(false)} + /> + setEditing(null)} + /> +
+ ); +} + +function ModeCard({ + active, + disabled, + icon: Icon, + title, + hint, + onClick, +}: { + active: boolean; + disabled: boolean; + icon: typeof User; + title: string; + hint: string; + onClick: () => void; +}) { + return ( + + ); +} + +function UserRow({ + user, + isSelf, + onEdit, +}: { + user: AdminUser; + isSelf: boolean; + onEdit: () => void; +}) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const locked = + user.lockedUntil !== null && new Date(user.lockedUntil).getTime() > Date.now(); + + async function remove() { + if ( + !window.confirm( + `Konto „${user.username}“ mit ${user._count.projects} Projekt(en) unwiderruflich loeschen?`, + ) + ) { + return; + } + setBusy(true); + setError(null); + try { + const response = await fetch(`/api/admin/users/${user.id}`, { method: "DELETE" }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error ?? "Loeschen fehlgeschlagen."); + router.refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + {(user.displayName ?? user.username).slice(0, 2).toUpperCase()} + + +
+

+ {user.displayName ?? user.username} + @{user.username} + {user.role === "ADMIN" ? ( + + + Admin + + ) : null} + {isSelf ? ( + + du + + ) : null} + {!user.isActive ? ( + + deaktiviert + + ) : null} + {locked ? ( + + + gesperrt + + ) : null} +

+

+ + + {user._count.projects} Projekte + + + + {user._count.credentials} Passkeys + + + {user.lastLoginAt + ? `zuletzt ${formatRelative(user.lastLoginAt)}` + : "noch nie angemeldet"} + + angelegt {formatDateTime(user.createdAt)} +

+
+ +
+ + +
+
+ + {error ?

{error}

: null} +
+ ); +} + +function UserDialog({ + open, + mode, + user, + isSelf, + onClose, +}: { + open: boolean; + mode: "create" | "edit"; + user?: AdminUser | null; + isSelf?: boolean; + onClose: () => void; +}) { + const router = useRouter(); + const [username, setUsername] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState("USER"); + const [isActive, setIsActive] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState>({}); + const [loadedFor, setLoadedFor] = useState(null); + + // Formular beim Oeffnen einmalig aus dem Datensatz fuellen. + const key = open ? (user?.id ?? "new") : null; + if (key !== null && key !== loadedFor) { + setLoadedFor(key); + setUsername(user?.username ?? ""); + setDisplayName(user?.displayName ?? ""); + setEmail(user?.email ?? ""); + setPassword(""); + setRole(user?.role ?? "USER"); + setIsActive(user?.isActive ?? true); + setError(null); + setFieldErrors({}); + } + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + setFieldErrors({}); + + const body = + mode === "create" + ? { username, displayName, email, password, role } + : { displayName, email, role, isActive, password: password || null }; + + try { + const response = await fetch( + mode === "create" ? "/api/admin/users" : `/api/admin/users/${user?.id}`, + { + method: mode === "create" ? "POST" : "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + setFieldErrors(payload.fields ?? {}); + throw new Error(payload.error ?? "Speichern fehlgeschlagen."); + } + setLoadedFor(null); + onClose(); + router.refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setBusy(false); + } + } + + return ( + { + setLoadedFor(null); + onClose(); + }} + title={mode === "create" ? "Neues Konto" : `Konto bearbeiten`} + description={ + mode === "create" + ? "Das Konto kann sich sofort anmelden und einen eigenen Passkey hinterlegen." + : "Ein neues Passwort beendet alle Sitzungen dieses Kontos." + } + width="md" + > +
+ {mode === "create" ? ( +
+ + setUsername(event.target.value)} + autoCapitalize="none" + spellCheck={false} + required + /> + {fieldErrors.username ? ( +

{fieldErrors.username}

+ ) : null} +
+ ) : ( +

@{user?.username}

+ )} + +
+
+ + setDisplayName(event.target.value)} + /> +
+
+ + setEmail(event.target.value)} + /> + {fieldErrors.email ? ( +

{fieldErrors.email}

+ ) : null} +
+
+ +
+ + setPassword(event.target.value)} + autoComplete="new-password" + minLength={mode === "create" ? PASSWORD_MIN_LENGTH : undefined} + required={mode === "create"} + /> + {fieldErrors.password ? ( +

{fieldErrors.password}

+ ) : null} +
+ +
+ Rolle +
+ {(["USER", "ADMIN"] as Role[]).map((value) => ( + + ))} +
+
+ + {mode === "edit" ? ( + + ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+
+ ); +}