From ad44a7b8b2818a9325c9724f5d68069939818590 Mon Sep 17 00:00:00 2001 From: Hendrik Date: Fri, 1 May 2026 21:38:33 +0200 Subject: [PATCH] =?UTF-8?q?v0.1.21=20=E2=80=94=20Multi-User=20mit=20Rollen?= =?UTF-8?q?=20(admin/user),=20User-CRUD-UI,=20role-enforcement=20auf=20dom?= =?UTF-8?q?ain=20mutations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(app)/layout.tsx | 2 +- app/(app)/users/page.tsx | 177 ++++++++++++++++++++++++++++++++++ app/api/domains/[id]/route.ts | 2 + app/api/users/[id]/route.ts | 67 +++++++++++++ app/api/users/route.ts | 37 +++++++ components/Sidebar.tsx | 24 ++--- lib/auth-helpers.ts | 18 ++++ 7 files changed, 315 insertions(+), 12 deletions(-) create mode 100644 app/(app)/users/page.tsx create mode 100644 app/api/users/[id]/route.ts create mode 100644 app/api/users/route.ts create mode 100644 lib/auth-helpers.ts diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index f6985f3..1c52028 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -16,7 +16,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
- +
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. +
+
setEmail(e.target.value)} />
+
setPassword(e.target.value)} />
+
+ + +
+ {err &&

{err}

} + + + + +
+
+ + } + /> + +
+ {loading ? ( +
+ ) : ( + + + + + + + + + + + + + {users.map((u) => ( + + + + + + + ))} + +
E-MailRolleErstellt
{u.email} + {u.role === "admin" + ? admin + : user} + {new Date(u.created_at).toLocaleString("de-DE")} +
+ + + +
+
+
+
+ )} +
+ + { if (!v) { setPwdOpen(null); setNewPwd(""); } }}> + + Passwort ändern{pwdOpen?.email} +
setNewPwd(e.target.value)} />
+ + + + +
+
+
+ ); +} 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 (

{user.email}

-

Admin

+

{user.role === "admin" ? "Admin" : "User"}

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 { + const session = await getServerSession(authOptions); + if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + return session.user as SessionUser; +} + +export async function requireAdmin(): Promise { + const u = await requireSession(); + if (u instanceof NextResponse) return u; + if (u.role !== "admin") return NextResponse.json({ error: "forbidden", code: "admin_required" }, { status: 403 }); + return u; +}