diff --git a/app/(app)/users/page.tsx b/app/(app)/users/page.tsx
new file mode 100644
index 0000000..6c4d517
--- /dev/null
+++ b/app/(app)/users/page.tsx
@@ -0,0 +1,177 @@
+"use client";
+import { useEffect, useState } from "react";
+import { Loader2, Plus, Trash2, Shield, User as UserIcon, KeyRound } from "lucide-react";
+import { PageHeader } from "@/components/PageHeader";
+import { Card, CardContent } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+
+type U = { id: number; email: string; role: "admin" | "user"; created_at: number };
+
+export default function UsersPage() {
+ const [users, setUsers] = useState
([]);
+ const [loading, setLoading] = useState(true);
+ const [forbidden, setForbidden] = useState(false);
+ const [open, setOpen] = useState(false);
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [role, setRole] = useState<"admin" | "user">("user");
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState("");
+ const [pwdOpen, setPwdOpen] = useState(null);
+ const [newPwd, setNewPwd] = useState("");
+
+ async function load() {
+ setLoading(true);
+ const r = await fetch("/api/users");
+ if (r.status === 403) { setForbidden(true); setLoading(false); return; }
+ const d = await r.json();
+ setUsers(d.users || []);
+ setLoading(false);
+ }
+ useEffect(() => { load(); }, []);
+
+ async function create(e: React.FormEvent) {
+ e.preventDefault();
+ setBusy(true);
+ setErr("");
+ try {
+ const r = await fetch("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, role }) });
+ const d = await r.json();
+ if (!r.ok) { setErr(d.error || "Fehler"); return; }
+ setEmail(""); setPassword(""); setRole("user");
+ setOpen(false);
+ load();
+ } finally { setBusy(false); }
+ }
+
+ async function changeRole(id: number, role: "admin" | "user") {
+ const r = await fetch(`/api/users/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ role }) });
+ if (!r.ok) {
+ const d = await r.json().catch(() => ({}));
+ alert(d.error || "Fehler");
+ }
+ load();
+ }
+
+ async function setPassword2(u: U, pwd: string) {
+ const r = await fetch(`/api/users/${u.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pwd }) });
+ if (!r.ok) { alert("Fehler"); return; }
+ setPwdOpen(null);
+ setNewPwd("");
+ }
+
+ async function del(u: U) {
+ if (!confirm(`User "${u.email}" wirklich löschen?`)) return;
+ const r = await fetch(`/api/users/${u.id}`, { method: "DELETE" });
+ if (!r.ok) { const d = await r.json().catch(() => ({})); alert(d.error || "Fehler"); return; }
+ load();
+ }
+
+ if (forbidden) {
+ return (
+
+
+
+ Keine Berechtigung. Nur Admin-Accounts können User verwalten.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Neuer BenutzerAccount anlegen.
+
+
+
+ }
+ />
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+
+
+ | E-Mail |
+ Rolle |
+ Erstellt |
+ |
+
+
+
+ {users.map((u) => (
+
+ | {u.email} |
+
+ {u.role === "admin"
+ ? admin
+ : user}
+ |
+ {new Date(u.created_at).toLocaleString("de-DE")} |
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/api/domains/[id]/route.ts b/app/api/domains/[id]/route.ts
index 93cb577..a1be90f 100644
--- a/app/api/domains/[id]/route.ts
+++ b/app/api/domains/[id]/route.ts
@@ -35,6 +35,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
+ if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const { id } = await params;
const body = await req.json().catch(() => null);
@@ -78,6 +79,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
+ if (session.user.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 });
const { id } = await params;
const db = getDb();
diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts
new file mode 100644
index 0000000..3a57b4a
--- /dev/null
+++ b/app/api/users/[id]/route.ts
@@ -0,0 +1,67 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import bcrypt from "bcryptjs";
+import { getDb, logAudit, type UserRow } from "@/lib/db";
+import { requireAdmin } from "@/lib/auth-helpers";
+
+const updateSchema = z.object({
+ role: z.enum(["admin", "user"]).optional(),
+ password: z.string().min(8).optional(),
+});
+
+export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
+ const u = await requireAdmin();
+ if (u instanceof NextResponse) return u;
+ const { id } = await params;
+
+ const body = await req.json().catch(() => null);
+ const parsed = updateSchema.safeParse(body);
+ if (!parsed.success) return NextResponse.json({ error: "invalid" }, { status: 400 });
+
+ const db = getDb();
+ const target = db.prepare("SELECT * FROM users WHERE id = ?").get(Number(id)) as UserRow | undefined;
+ if (!target) return NextResponse.json({ error: "not_found" }, { status: 404 });
+
+ const fields: string[] = [];
+ const values: unknown[] = [];
+ if (parsed.data.role) {
+ // Don't allow demoting the last admin
+ if (parsed.data.role === "user" && target.role === "admin") {
+ const adminCount = (db.prepare("SELECT COUNT(*) AS n FROM users WHERE role='admin'").get() as { n: number }).n;
+ if (adminCount <= 1) return NextResponse.json({ error: "last_admin", code: "cannot_demote_last_admin" }, { status: 409 });
+ }
+ fields.push("role = ?");
+ values.push(parsed.data.role);
+ }
+ if (parsed.data.password) {
+ const hash = await bcrypt.hash(parsed.data.password, 12);
+ fields.push("password_hash = ?");
+ values.push(hash);
+ }
+ if (fields.length === 0) return NextResponse.json({ ok: true, noop: true });
+
+ values.push(Number(id));
+ db.prepare(`UPDATE users SET ${fields.join(", ")} WHERE id = ?`).run(...values);
+ logAudit({ user_id: Number(u.id), user_email: u.email, action: "user.update", target_type: "user", target_id: target.id, details: { fields: Object.keys(parsed.data) } });
+ return NextResponse.json({ ok: true });
+}
+
+export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
+ const u = await requireAdmin();
+ if (u instanceof NextResponse) return u;
+ const { id } = await params;
+
+ if (Number(id) === Number(u.id)) return NextResponse.json({ error: "cannot_delete_self" }, { status: 409 });
+
+ const db = getDb();
+ const target = db.prepare("SELECT * FROM users WHERE id = ?").get(Number(id)) as UserRow | undefined;
+ if (!target) return NextResponse.json({ error: "not_found" }, { status: 404 });
+ if (target.role === "admin") {
+ const adminCount = (db.prepare("SELECT COUNT(*) AS n FROM users WHERE role='admin'").get() as { n: number }).n;
+ if (adminCount <= 1) return NextResponse.json({ error: "last_admin" }, { status: 409 });
+ }
+
+ db.prepare("DELETE FROM users WHERE id = ?").run(Number(id));
+ logAudit({ user_id: Number(u.id), user_email: u.email, action: "user.delete", target_type: "user", target_id: target.id, details: { email: target.email } });
+ return NextResponse.json({ ok: true });
+}
diff --git a/app/api/users/route.ts b/app/api/users/route.ts
new file mode 100644
index 0000000..4590475
--- /dev/null
+++ b/app/api/users/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import bcrypt from "bcryptjs";
+import { getDb, logAudit, type UserRow } from "@/lib/db";
+import { requireAdmin } from "@/lib/auth-helpers";
+
+export async function GET() {
+ const u = await requireAdmin();
+ if (u instanceof NextResponse) return u;
+ const rows = getDb().prepare("SELECT id, email, role, created_at FROM users ORDER BY created_at").all();
+ return NextResponse.json({ users: rows });
+}
+
+const createSchema = z.object({
+ email: z.string().email().transform((s) => s.toLowerCase().trim()),
+ password: z.string().min(8),
+ role: z.enum(["admin", "user"]).default("user"),
+});
+
+export async function POST(req: Request) {
+ const u = await requireAdmin();
+ if (u instanceof NextResponse) return u;
+
+ const body = await req.json().catch(() => null);
+ const parsed = createSchema.safeParse(body);
+ if (!parsed.success) return NextResponse.json({ error: "invalid", details: parsed.error.flatten() }, { status: 400 });
+
+ const db = getDb();
+ if (db.prepare("SELECT id FROM users WHERE email = ?").get(parsed.data.email)) {
+ return NextResponse.json({ error: "email_exists" }, { status: 409 });
+ }
+ const hash = await bcrypt.hash(parsed.data.password, 12);
+ const result = db.prepare("INSERT INTO users (email, password_hash, role, created_at) VALUES (?, ?, ?, ?)").run(parsed.data.email, hash, parsed.data.role, Date.now());
+ const row = db.prepare("SELECT id, email, role, created_at FROM users WHERE id = ?").get(result.lastInsertRowid) as UserRow;
+ logAudit({ user_id: Number(u.id), user_email: u.email, action: "user.create", target_type: "user", target_id: row.id, details: { email: row.email, role: row.role } });
+ return NextResponse.json({ user: row }, { status: 201 });
+}
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
index 13789a8..f2ef85b 100644
--- a/components/Sidebar.tsx
+++ b/components/Sidebar.tsx
@@ -1,22 +1,24 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { LayoutDashboard, Globe, Layers, BarChart3, Settings, LogOut, KeyRound, History } from "lucide-react";
+import { LayoutDashboard, Globe, Layers, BarChart3, Settings, LogOut, KeyRound, History, Users } from "lucide-react";
import { Logo } from "./Logo";
import { cn } from "@/lib/utils";
const NAV = [
- { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
- { href: "/domains", label: "Domains", icon: Globe },
- { href: "/groups", label: "Gruppen", icon: Layers },
- { href: "/analytics", label: "Analytics", icon: BarChart3 },
- { href: "/audit", label: "Audit-Log", icon: History },
- { href: "/settings", label: "Einstellungen", icon: Settings },
- { href: "/settings/api-tokens", label: "API-Tokens", icon: KeyRound },
+ { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, admin: false },
+ { href: "/domains", label: "Domains", icon: Globe, admin: false },
+ { href: "/groups", label: "Gruppen", icon: Layers, admin: false },
+ { href: "/analytics", label: "Analytics", icon: BarChart3, admin: false },
+ { href: "/audit", label: "Audit-Log", icon: History, admin: true },
+ { href: "/users", label: "Benutzer", icon: Users, admin: true },
+ { href: "/settings", label: "Einstellungen", icon: Settings, admin: true },
+ { href: "/settings/api-tokens", label: "API-Tokens", icon: KeyRound, admin: true },
];
-export function Sidebar({ user }: { user: { email: string } }) {
+export function Sidebar({ user }: { user: { email: string; role?: string } }) {
const pathname = usePathname();
+ const isAdmin = user.role === "admin";
return (
diff --git a/lib/auth-helpers.ts b/lib/auth-helpers.ts
new file mode 100644
index 0000000..64b9b02
--- /dev/null
+++ b/lib/auth-helpers.ts
@@ -0,0 +1,18 @@
+import { getServerSession } from "next-auth";
+import { NextResponse } from "next/server";
+import { authOptions } from "./auth";
+
+export type SessionUser = { id: string; email: string; role: string };
+
+export async function requireSession(): Promise