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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ProfileSection data={data} />
|
||||||
|
<PasswordSection hasPassword={data.hasPassword} />
|
||||||
|
<PasskeySection passkeys={data.passkeys} available={data.passkeysAvailable} />
|
||||||
|
<SessionSection sessions={data.sessions} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon: typeof KeyRound;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="glass p-5">
|
||||||
|
<div className="mb-4 flex items-start gap-3">
|
||||||
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{description ? (
|
||||||
|
<p className="mt-1 text-sm text-slate-400">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoticeLine({ notice }: { notice: Notice }) {
|
||||||
|
if (!notice) return null;
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
||||||
|
notice.ok
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||||
|
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notice.text}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Notice>(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 (
|
||||||
|
<SectionCard title="Profil" icon={Pencil}>
|
||||||
|
<form onSubmit={save} className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<span className="field-label">Benutzername</span>
|
||||||
|
<p className="font-mono text-sm text-slate-400">@{data.username}</p>
|
||||||
|
<p className="mt-1 text-[11px] text-slate-600">
|
||||||
|
{data.role === "ADMIN" ? "Administrator" : "Benutzer"} · nicht aenderbar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="account-display">
|
||||||
|
Anzeigename
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="account-display"
|
||||||
|
className="field"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(event) => setDisplayName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="account-email">
|
||||||
|
E-Mail
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="account-email"
|
||||||
|
className="field"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
placeholder="optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<NoticeLine notice={notice} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Notice>(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 (
|
||||||
|
<SectionCard
|
||||||
|
title={hasPassword ? "Passwort aendern" : "Passwort vergeben"}
|
||||||
|
icon={KeyRound}
|
||||||
|
description={
|
||||||
|
hasPassword
|
||||||
|
? "Nach der Aenderung werden alle anderen Sitzungen beendet."
|
||||||
|
: "Dieses Konto hat bisher kein Passwort und nutzt nur Passkeys."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={save} className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{hasPassword ? (
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="field-label" htmlFor="account-current">
|
||||||
|
Aktuelles Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="account-current"
|
||||||
|
className="field"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="account-new">
|
||||||
|
Neues Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="account-new"
|
||||||
|
className="field"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(event) => setNewPassword(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={PASSWORD_MIN_LENGTH}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="account-repeat">
|
||||||
|
Wiederholen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="account-repeat"
|
||||||
|
className="field"
|
||||||
|
type="password"
|
||||||
|
value={repeat}
|
||||||
|
onChange={(event) => setRepeat(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
|
||||||
|
{hasPassword ? "Passwort aendern" : "Passwort setzen"}
|
||||||
|
</button>
|
||||||
|
<NoticeLine notice={notice} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasskeySection({
|
||||||
|
passkeys,
|
||||||
|
available,
|
||||||
|
}: {
|
||||||
|
passkeys: PasskeyEntry[];
|
||||||
|
available: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<Notice>(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 (
|
||||||
|
<SectionCard
|
||||||
|
title="Passkeys"
|
||||||
|
icon={Fingerprint}
|
||||||
|
description="Anmeldung per Fingerabdruck, Gesichtserkennung oder Sicherheitsschluessel – ohne Passwort."
|
||||||
|
>
|
||||||
|
{!available ? (
|
||||||
|
<p className="rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-sm text-slate-400">
|
||||||
|
Passkeys sind derzeit nicht verfuegbar. Sie benoetigen einen sicheren Kontext:
|
||||||
|
<code className="mx-1 text-violet-300">APP_URL</code> muss auf eine https-Adresse
|
||||||
|
oder auf http://localhost zeigen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{passkeys.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Noch kein Passkey hinterlegt.</p>
|
||||||
|
) : (
|
||||||
|
passkeys.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="glass-soft flex items-center justify-between gap-3 px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="flex items-center gap-2 truncate text-sm text-slate-200">
|
||||||
|
<Fingerprint className="h-3.5 w-3.5 shrink-0 text-violet-300" />
|
||||||
|
{entry.name ?? "Unbenannter Passkey"}
|
||||||
|
{entry.backedUp ? (
|
||||||
|
<span className="rounded bg-emerald-500/12 px-1.5 py-0.5 text-[10px] text-emerald-200">
|
||||||
|
synchronisiert
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-500" suppressHydrationWarning>
|
||||||
|
angelegt {formatDateTime(entry.createdAt)}
|
||||||
|
{entry.lastUsedAt
|
||||||
|
? ` · zuletzt genutzt ${formatRelative(entry.lastUsedAt)}`
|
||||||
|
: " · noch nie genutzt"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{busyId === entry.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-slate-500" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => rename(entry)}
|
||||||
|
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/8 hover:text-violet-300"
|
||||||
|
aria-label="Umbenennen"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(entry)}
|
||||||
|
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-rose-500/12 hover:text-rose-300"
|
||||||
|
aria-label="Entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="btn btn-ghost mt-4" onClick={addPasskey} disabled={busy}>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Fingerprint className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Passkey hinzufuegen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NoticeLine notice={notice} />
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<SectionCard
|
||||||
|
title="Aktive Sitzungen"
|
||||||
|
icon={Monitor}
|
||||||
|
description="Jede Anmeldung erzeugt eine Sitzung. Sie laeuft nach 30 Tagen ohne Nutzung ab."
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<div key={session.id} className="glass-soft px-3 py-2.5">
|
||||||
|
<p className="truncate text-sm text-slate-300">
|
||||||
|
{shortenUserAgent(session.userAgent)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-500" suppressHydrationWarning>
|
||||||
|
{session.ip ?? "unbekannte Adresse"} · aktiv {formatRelative(session.lastSeenAt)} ·
|
||||||
|
angemeldet {formatDateTime(session.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger mt-4"
|
||||||
|
onClick={logoutEverywhere}
|
||||||
|
disabled={busy || sessions.length <= 1}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4" /> : <LogOut className="h-4 w-4" />}
|
||||||
|
Ueberall abmelden
|
||||||
|
</button>
|
||||||
|
{sessions.length <= 1 ? (
|
||||||
|
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-slate-600">
|
||||||
|
<ShieldCheck className="h-3 w-3" />
|
||||||
|
Nur dieses Geraet ist angemeldet.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
@@ -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<Mode>(data.mode);
|
||||||
|
const [allowRegistration, setAllowRegistration] = useState(data.allowRegistration);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [notice, setNotice] = useState<Notice>(null);
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<AdminUser | null>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* ── Betriebsart ─────────────────────────────────── */}
|
||||||
|
<section className="glass p-5">
|
||||||
|
<div className="mb-4 flex items-start gap-3">
|
||||||
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||||
|
Betriebsart
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Bestimmt, ob die Instanz nur dir gehoert oder mehrere Konten verwaltet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<ModeCard
|
||||||
|
active={mode === "SINGLE_USER"}
|
||||||
|
disabled={busy}
|
||||||
|
icon={User}
|
||||||
|
title="Einzelbetrieb"
|
||||||
|
hint="Genau ein Konto. Keine Registrierung, keine Benutzerverwaltung."
|
||||||
|
onClick={() => {
|
||||||
|
setMode("SINGLE_USER");
|
||||||
|
void patchSettings({ mode: "SINGLE_USER" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ModeCard
|
||||||
|
active={mode === "MULTI_USER"}
|
||||||
|
disabled={busy}
|
||||||
|
icon={Users}
|
||||||
|
title="Mehrbenutzerbetrieb"
|
||||||
|
hint="Mehrere Konten. Projekte, Notizen und Git-Verbindungen bleiben streng getrennt."
|
||||||
|
onClick={() => {
|
||||||
|
setMode("MULTI_USER");
|
||||||
|
void patchSettings({ mode: "MULTI_USER" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "MULTI_USER" ? (
|
||||||
|
<label className="mt-4 flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allowRegistration}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => {
|
||||||
|
setAllowRegistration(event.target.checked);
|
||||||
|
void patchSettings({ allowRegistration: event.target.checked });
|
||||||
|
}}
|
||||||
|
className="mt-0.5 h-4 w-4 accent-violet-500"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm text-slate-200">Selbstregistrierung erlauben</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||||
|
Ist das aus, legt nur ein Administrator neue Konten an. Bei einer aus dem
|
||||||
|
Internet erreichbaren Instanz besser aus lassen.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{notice ? (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
||||||
|
notice.ok
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||||
|
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notice.text}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Instanz ─────────────────────────────────────── */}
|
||||||
|
<section className="glass p-5">
|
||||||
|
<div className="mb-4 flex items-start gap-3">
|
||||||
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-cyan-500/12 text-cyan-300">
|
||||||
|
<ShieldCheck className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||||
|
Instanz
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Diese Werte kommen aus der <code className="text-violet-300">.env</code> und
|
||||||
|
lassen sich nur dort aendern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="glass-soft px-3 py-2.5">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider text-slate-500">APP_URL</dt>
|
||||||
|
<dd className="mt-1 truncate font-mono text-sm text-slate-200">{data.appUrl}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="glass-soft px-3 py-2.5">
|
||||||
|
<dt className="text-[11px] uppercase tracking-wider text-slate-500">Passkeys</dt>
|
||||||
|
<dd className="mt-1 flex items-center gap-1.5 text-sm text-slate-200">
|
||||||
|
{data.passkeysAvailable ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-300" />
|
||||||
|
verfuegbar
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ban className="h-3.5 w-3.5 text-amber-300" />
|
||||||
|
kein sicherer Kontext
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Benutzer ────────────────────────────────────── */}
|
||||||
|
<section className="glass p-5">
|
||||||
|
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-emerald-500/12 text-emerald-300">
|
||||||
|
<UserCog className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||||
|
Benutzer ({data.users.length})
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Administratoren verwalten Konten – Projekte anderer sehen sie nicht.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary px-3 py-1.5 text-xs"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
disabled={mode === "SINGLE_USER"}
|
||||||
|
title={
|
||||||
|
mode === "SINGLE_USER"
|
||||||
|
? "Erst in den Mehrbenutzerbetrieb wechseln"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Konto anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.users.map((user) => (
|
||||||
|
<UserRow
|
||||||
|
key={user.id}
|
||||||
|
user={user}
|
||||||
|
isSelf={user.id === data.currentUserId}
|
||||||
|
onEdit={() => setEditing(user)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<UserDialog
|
||||||
|
open={createOpen}
|
||||||
|
mode="create"
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
/>
|
||||||
|
<UserDialog
|
||||||
|
open={editing !== null}
|
||||||
|
mode="edit"
|
||||||
|
user={editing}
|
||||||
|
isSelf={editing?.id === data.currentUserId}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeCard({
|
||||||
|
active,
|
||||||
|
disabled,
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
icon: typeof User;
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border px-4 py-3.5 text-left transition-all disabled:opacity-60",
|
||||||
|
active
|
||||||
|
? "border-violet-400/60 bg-violet-500/12 shadow-[0_0_28px_-12px_rgba(139,92,246,0.9)]"
|
||||||
|
: "border-white/9 hover:border-white/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium text-slate-100">
|
||||||
|
<Icon className={cn("h-4 w-4", active ? "text-violet-300" : "text-slate-500")} />
|
||||||
|
{title}
|
||||||
|
{active ? (
|
||||||
|
<CheckCircle2 className="ml-auto h-4 w-4 text-violet-300" />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1.5 block text-[11px] leading-snug text-slate-500">{hint}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserRow({
|
||||||
|
user,
|
||||||
|
isSelf,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
user: AdminUser;
|
||||||
|
isSelf: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="glass-soft px-3 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-9 w-9 shrink-0 place-items-center rounded-lg text-[11px] font-semibold text-white",
|
||||||
|
user.isActive
|
||||||
|
? "bg-gradient-to-br from-violet-500/80 to-cyan-400/70"
|
||||||
|
: "bg-slate-700/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(user.displayName ?? user.username).slice(0, 2).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="flex flex-wrap items-center gap-2 text-sm text-slate-100">
|
||||||
|
<span className="truncate">{user.displayName ?? user.username}</span>
|
||||||
|
<span className="font-mono text-[11px] text-slate-500">@{user.username}</span>
|
||||||
|
{user.role === "ADMIN" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-violet-500/12 px-1.5 py-0.5 text-[10px] text-violet-200">
|
||||||
|
<ShieldCheck className="h-2.5 w-2.5" />
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{isSelf ? (
|
||||||
|
<span className="rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400">
|
||||||
|
du
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{!user.isActive ? (
|
||||||
|
<span className="rounded bg-rose-500/12 px-1.5 py-0.5 text-[10px] text-rose-200">
|
||||||
|
deaktiviert
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{locked ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-amber-500/12 px-1.5 py-0.5 text-[10px] text-amber-200">
|
||||||
|
<Lock className="h-2.5 w-2.5" />
|
||||||
|
gesperrt
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-500">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<FolderGit2 className="h-3 w-3" />
|
||||||
|
{user._count.projects} Projekte
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Fingerprint className="h-3 w-3" />
|
||||||
|
{user._count.credentials} Passkeys
|
||||||
|
</span>
|
||||||
|
<span suppressHydrationWarning>
|
||||||
|
{user.lastLoginAt
|
||||||
|
? `zuletzt ${formatRelative(user.lastLoginAt)}`
|
||||||
|
: "noch nie angemeldet"}
|
||||||
|
</span>
|
||||||
|
<span suppressHydrationWarning>angelegt {formatDateTime(user.createdAt)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="btn btn-ghost px-2.5 py-1.5 text-xs"
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<UserCog className="h-3.5 w-3.5" />
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={remove}
|
||||||
|
className="btn btn-danger px-2.5 py-1.5 text-xs"
|
||||||
|
disabled={busy || isSelf}
|
||||||
|
title={isSelf ? "Das eigene Konto kann nicht geloescht werden" : "Loeschen"}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="mt-2 text-xs text-rose-300">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Role>("USER");
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const [loadedFor, setLoadedFor] = useState<string | null>(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 (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={() => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
{mode === "create" ? (
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="admin-username">
|
||||||
|
Benutzername *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="admin-username"
|
||||||
|
className="field"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{fieldErrors.username ? (
|
||||||
|
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="font-mono text-sm text-slate-400">@{user?.username}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="admin-display">
|
||||||
|
Anzeigename
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="admin-display"
|
||||||
|
className="field"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(event) => setDisplayName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="admin-email">
|
||||||
|
E-Mail
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="admin-email"
|
||||||
|
className="field"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.email ? (
|
||||||
|
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="field-label" htmlFor="admin-password">
|
||||||
|
{mode === "create" ? "Passwort *" : "Neues Passwort (leer = unveraendert)"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="admin-password"
|
||||||
|
className="field"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={mode === "create" ? PASSWORD_MIN_LENGTH : undefined}
|
||||||
|
required={mode === "create"}
|
||||||
|
/>
|
||||||
|
{fieldErrors.password ? (
|
||||||
|
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="field-label">Rolle</span>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{(["USER", "ADMIN"] as Role[]).map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRole(value)}
|
||||||
|
disabled={isSelf && value === "USER"}
|
||||||
|
title={
|
||||||
|
isSelf && value === "USER"
|
||||||
|
? "Die eigene Administratorrolle kann hier nicht abgegeben werden"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border px-3 py-2.5 text-sm transition-all disabled:opacity-40",
|
||||||
|
role === value
|
||||||
|
? "border-violet-400/60 bg-violet-500/12 text-white"
|
||||||
|
: "border-white/9 text-slate-400 hover:border-white/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value === "ADMIN" ? "Administrator" : "Benutzer"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "edit" ? (
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(event) => setIsActive(event.target.checked)}
|
||||||
|
disabled={isSelf}
|
||||||
|
className="h-4 w-4 accent-violet-500"
|
||||||
|
/>
|
||||||
|
Konto aktiv
|
||||||
|
{isSelf ? (
|
||||||
|
<span className="text-[11px] text-slate-600">
|
||||||
|
(das eigene Konto laesst sich nicht deaktivieren)
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setLoadedFor(null);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user