v0.1.21 — Multi-User mit Rollen (admin/user), User-CRUD-UI, role-enforcement auf domain mutations

This commit is contained in:
Hendrik 2026-05-01 21:38:33 +02:00
parent 91b7b2494e
commit ad44a7b8b2
7 changed files with 315 additions and 12 deletions

View file

@ -16,7 +16,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
<div className="relative flex h-screen overflow-hidden bg-background">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(45,212,191,0.12),transparent_40%),radial-gradient(circle_at_bottom_left,rgba(245,158,11,0.07),transparent_34%)]" />
<Sidebar user={{ email: session.user.email }} />
<Sidebar user={{ email: session.user.email, role: session.user.role }} />
<div className="relative flex flex-1 flex-col overflow-hidden">
<UpdateBanner />

177
app/(app)/users/page.tsx Normal file
View file

@ -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<U[]>([]);
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<U | null>(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 (
<div>
<PageHeader title="Benutzer" description="Nur für Admins" />
<div className="p-8">
<Card><CardContent className="py-12 text-center text-sm text-muted-foreground">Keine Berechtigung. Nur Admin-Accounts können User verwalten.</CardContent></Card>
</div>
</div>
);
}
return (
<div>
<PageHeader
title="Benutzer"
description="Admin- und User-Accounts verwalten"
actions={
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-1 h-3 w-3" />Neuer User</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Neuer Benutzer</DialogTitle><DialogDescription>Account anlegen.</DialogDescription></DialogHeader>
<form onSubmit={create} className="space-y-3">
<div className="space-y-1"><Label>E-Mail</Label><Input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div className="space-y-1"><Label>Passwort (min 8)</Label><Input type="password" required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} /></div>
<div className="space-y-1">
<Label>Rolle</Label>
<select value={role} onChange={(e) => setRole(e.target.value as "admin" | "user")} className="flex h-9 w-full rounded-md border border-input bg-zinc-950 px-3 text-sm text-zinc-100">
<option value="user" className="bg-zinc-900">User (read-only)</option>
<option value="admin" className="bg-zinc-900">Admin (alles)</option>
</select>
</div>
{err && <p className="text-xs text-destructive">{err}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button type="submit" disabled={busy}>{busy ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : null}Anlegen</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
}
/>
<div className="p-8">
{loading ? (
<div className="flex justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : (
<Card>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="border-b border-zinc-800/70 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-6 py-3 text-left">E-Mail</th>
<th className="px-6 py-3 text-left">Rolle</th>
<th className="px-6 py-3 text-left">Erstellt</th>
<th className="px-6 py-3 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/70">
{users.map((u) => (
<tr key={u.id}>
<td className="px-6 py-3">{u.email}</td>
<td className="px-6 py-3">
{u.role === "admin"
? <Badge variant="green"><Shield className="mr-1 h-3 w-3" />admin</Badge>
: <Badge variant="zinc"><UserIcon className="mr-1 h-3 w-3" />user</Badge>}
</td>
<td className="px-6 py-3 text-xs text-muted-foreground">{new Date(u.created_at).toLocaleString("de-DE")}</td>
<td className="px-6 py-3">
<div className="flex justify-end gap-1">
<button onClick={() => changeRole(u.id, u.role === "admin" ? "user" : "admin")} className="rounded p-1.5 text-zinc-400 hover:text-zinc-100" title="Rolle wechseln">
<Shield className="h-3.5 w-3.5" />
</button>
<button onClick={() => setPwdOpen(u)} className="rounded p-1.5 text-zinc-400 hover:text-zinc-100" title="Passwort ändern">
<KeyRound className="h-3.5 w-3.5" />
</button>
<button onClick={() => del(u)} className="rounded p-1.5 text-zinc-400 hover:text-destructive" title="Löschen">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</div>
<Dialog open={!!pwdOpen} onOpenChange={(v) => { if (!v) { setPwdOpen(null); setNewPwd(""); } }}>
<DialogContent>
<DialogHeader><DialogTitle>Passwort ändern</DialogTitle><DialogDescription>{pwdOpen?.email}</DialogDescription></DialogHeader>
<div className="space-y-1"><Label>Neues Passwort (min 8)</Label><Input type="password" minLength={8} value={newPwd} onChange={(e) => setNewPwd(e.target.value)} /></div>
<DialogFooter>
<Button variant="outline" onClick={() => { setPwdOpen(null); setNewPwd(""); }}>Abbrechen</Button>
<Button onClick={() => pwdOpen && setPassword2(pwdOpen, newPwd)} disabled={newPwd.length < 8}>Speichern</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -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();

View file

@ -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 });
}

37
app/api/users/route.ts Normal file
View file

@ -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 });
}

View file

@ -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 (
<aside className="relative z-10 flex w-60 shrink-0 flex-col border-r border-zinc-800/70 bg-zinc-950/80 backdrop-blur">
<div className="flex h-16 shrink-0 items-center gap-3 border-b border-zinc-800/70 px-5">
@ -28,7 +30,7 @@ export function Sidebar({ user }: { user: { email: string } }) {
</div>
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
{NAV.map((item) => {
{NAV.filter((item) => !item.admin || isAdmin).map((item) => {
const Icon = item.icon;
const active = pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href));
return (
@ -54,7 +56,7 @@ export function Sidebar({ user }: { user: { email: string } }) {
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-zinc-200">{user.email}</p>
<p className="truncate text-[10px] text-zinc-500">Admin</p>
<p className="truncate text-[10px] text-zinc-500">{user.role === "admin" ? "Admin" : "User"}</p>
</div>
<Link href="/api/auth/signout" className="rounded p-1 text-zinc-600 hover:text-zinc-300" title="Abmelden">
<LogOut className="h-3.5 w-3.5" />

18
lib/auth-helpers.ts Normal file
View file

@ -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<SessionUser | NextResponse> {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
return session.user as SessionUser;
}
export async function requireAdmin(): Promise<SessionUser | NextResponse> {
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;
}