From 11f1b54b1117a2be78b972a0f2156e6a6e987787 Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Tue, 4 Aug 2026 20:46:13 +0200 Subject: [PATCH 1/5] feat(rbac): add user assignment and audit trail UI --- src/app/(dashboard)/access/page.tsx | 17 ++++ src/components/layout/Sidebar.tsx | 1 + src/components/rbac/AuditTrail.tsx | 57 +++++++++++++ src/components/rbac/UserRoleAssignment.tsx | 95 ++++++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 src/components/rbac/AuditTrail.tsx create mode 100644 src/components/rbac/UserRoleAssignment.tsx diff --git a/src/app/(dashboard)/access/page.tsx b/src/app/(dashboard)/access/page.tsx index 973ff0a..06535fa 100644 --- a/src/app/(dashboard)/access/page.tsx +++ b/src/app/(dashboard)/access/page.tsx @@ -3,6 +3,8 @@ import { getSession } from "@/lib/auth/session" import { prisma } from "@/lib/prisma" import { getUserPermissions, authorize } from "@/lib/rbac/authorize" import { RolesManager } from "@/components/rbac/RolesManager" +import { UserRoleAssignment } from "@/components/rbac/UserRoleAssignment" +import { AuditTrail } from "@/components/rbac/AuditTrail" export default async function AccessPage() { const session = await getSession() @@ -10,6 +12,9 @@ export default async function AccessPage() { const perms = await getUserPermissions(prisma, session.user.roleId ?? null) if (!authorize(perms, "roles:read")) redirect("/dashboard") + const canManageUsers = authorize(perms, "users:read") + const canReadAudit = authorize(perms, "audit:read") + return (
@@ -20,6 +25,18 @@ export default async function AccessPage() {

Roles

+ {canManageUsers && ( +
+

People

+ +
+ )} + {canReadAudit && ( +
+

Audit trail

+ +
+ )}
) } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 19567ec..281017e 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -27,6 +27,7 @@ const navItems = [ { href: "/data-sources", label: "Data Sources", icon: Database }, { href: "/data-api", label: "Data API", icon: KeyRound }, { href: "/notifications", label: "Notifications", icon: Send }, + { href: "/access", label: "Access", icon: ShieldCheck }, ] // Layers (within the aside stacking context): labels z-10 sit UNDER the rail diff --git a/src/components/rbac/AuditTrail.tsx b/src/components/rbac/AuditTrail.tsx new file mode 100644 index 0000000..a51a218 --- /dev/null +++ b/src/components/rbac/AuditTrail.tsx @@ -0,0 +1,57 @@ +"use client" + +import { useEffect, useState } from "react" + +type Entry = { + id: string + action: string + targetType: string + targetId: string | null + createdAt: string + actor: { email: string } | null +} + +const PAGE = 20 + +export function AuditTrail() { + const [entries, setEntries] = useState([]) + const [total, setTotal] = useState(0) + const [skip, setSkip] = useState(0) + + useEffect(() => { + void (async () => { + const res = await fetch(`/api/audit?take=${PAGE}&skip=${skip}`) + if (res.ok) { + const data = (await res.json()) as { entries: Entry[]; total: number } + setEntries(data.entries) + setTotal(data.total) + } + })() + }, [skip]) + + return ( +
+
    + {entries.map((e) => ( +
  • + {e.action} + + {e.actor?.email ?? "system"} - {new Date(e.createdAt).toLocaleString()} + +
  • + ))} +
+
+ {total} event(s) +
+ + +
+
+
+ ) +} diff --git a/src/components/rbac/UserRoleAssignment.tsx b/src/components/rbac/UserRoleAssignment.tsx new file mode 100644 index 0000000..45dec1d --- /dev/null +++ b/src/components/rbac/UserRoleAssignment.tsx @@ -0,0 +1,95 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { StepUpDialog } from "./StepUpDialog" + +type UserRow = { id: string; email: string; name: string; roleId: string | null; roleName: string | null } +type RoleRow = { id: string; name: string; isAssignable: boolean } + +export function UserRoleAssignment() { + const [users, setUsers] = useState([]) + const [roles, setRoles] = useState([]) + const [query, setQuery] = useState("") + const [error, setError] = useState(null) + const [stepUpRetry, setStepUpRetry] = useState void)>(null) + + async function load() { + const [u, r] = await Promise.all([fetch("/api/users"), fetch("/api/roles")]) + if (u.ok) setUsers((await u.json()).users) + if (r.ok) setRoles((await r.json()).roles) + } + useEffect(() => { + void load() + }, []) + + const filtered = useMemo( + () => users.filter((u) => u.email.toLowerCase().includes(query.toLowerCase())), + [users, query], + ) + + async function assign(userId: string, roleId: string | null) { + const run = () => + fetch(`/api/users/${userId}/role`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ roleId }), + }) + const res = await run() + if (res.status === 403) { + const body = (await res.json().catch(() => ({}))) as { code?: string; error?: string } + if (body.code === "STEP_UP_REQUIRED") { + setStepUpRetry(() => async () => { + await assign(userId, roleId) + }) + return + } + setError(body.error ?? "Forbidden") + return + } + if (!res.ok) { + setError(((await res.json().catch(() => ({}))) as { error?: string }).error ?? "Failed") + return + } + setError(null) + setStepUpRetry(null) + await load() + } + + return ( +
+ setQuery(e.target.value)} + className="rounded-lg border border-input bg-card px-3 py-2 text-sm" + /> + {error &&

{error}

} +
    + {filtered.map((u) => ( +
  • + {u.email} + +
  • + ))} +
+ stepUpRetry?.()} + onCancel={() => setStepUpRetry(null)} + /> +
+ ) +} From 9392cda0ff90828038cd5c2c9fb5e52c0f00a812 Mon Sep 17 00:00:00 2001 From: WhiteMuush Date: Tue, 4 Aug 2026 20:56:19 +0200 Subject: [PATCH 2/5] fix(rbac): make the New role button open the create form The form rendered behind `editing || name || perms.size > 0`, but creating a role starts with no name and no permissions and startEdit(null) sets editing to null, so every term was falsy and the button did nothing. Only Edit on an existing role could open the form. Track the open state explicitly instead, and give the escape hatch its real name: the button cleared the fields but left the form open, so Cancel is what it does now that closing is possible. Caught by the e2e added in the next commit. --- src/components/rbac/RolesManager.tsx | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/components/rbac/RolesManager.tsx b/src/components/rbac/RolesManager.tsx index 9a3f923..4110d48 100644 --- a/src/components/rbac/RolesManager.tsx +++ b/src/components/rbac/RolesManager.tsx @@ -20,6 +20,9 @@ export function RolesManager() { const [query, setQuery] = useState("") const [page, setPage] = useState(0) const [editing, setEditing] = useState(null) + // Explicit, because a new role starts with no name and no permissions, so + // the form's own fields cannot tell "closed" from "creating". + const [formOpen, setFormOpen] = useState(false) const [perms, setPerms] = useState>(new Set()) const [name, setName] = useState("") const [error, setError] = useState(null) @@ -44,6 +47,15 @@ export function RolesManager() { setName(role?.name ?? "") setPerms(new Set(role?.permissions ?? [])) setError(null) + setFormOpen(true) + } + + function closeForm() { + setEditing(null) + setName("") + setPerms(new Set()) + setError(null) + setFormOpen(false) } // Runs a mutation; on STEP_UP_REQUIRED it stashes the retry and opens the @@ -63,7 +75,7 @@ export function RolesManager() { setError(((await res.json().catch(() => ({}))) as { error?: string }).error ?? "Failed") return } - setEditing(null) + closeForm() setStepUpRetry(null) await load() } @@ -156,7 +168,7 @@ export function RolesManager() { - {(editing || name || perms.size > 0) && ( + {formOpen && (
{error &&

{error}

}
-