From 31edfa8194c6690bcc83f640f6070db91ed845e2 Mon Sep 17 00:00:00 2001
From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com>
Date: Tue, 5 May 2026 14:44:04 +0200
Subject: [PATCH] feat(ST-111ci8t4): /admin/users pagina met tabel, role-editor
en delete-dialog
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- app/(app)/admin/layout.tsx: admin-sidebar met links (Gebruikers/Claude Jobs/Producten)
- app/(app)/admin/page.tsx: redirect naar /admin/users
- app/(app)/admin/users/page.tsx: server component, query users+roles, geeft userId door
- components/admin/users-table.tsx: client component met UsersTable, RoleBadge,
RolesDialog (checkboxes, eigen ADMIN-rol geblokkeerd), DeleteDialog (confirm),
ResetToggle — alles via useTransition + server actions
---
app/(app)/admin/layout.tsx | 16 +++
app/(app)/admin/page.tsx | 5 +
app/(app)/admin/users/page.tsx | 19 +++
components/admin/users-table.tsx | 239 +++++++++++++++++++++++++++++++
4 files changed, 279 insertions(+)
create mode 100644 app/(app)/admin/layout.tsx
create mode 100644 app/(app)/admin/page.tsx
create mode 100644 app/(app)/admin/users/page.tsx
create mode 100644 components/admin/users-table.tsx
diff --git a/app/(app)/admin/layout.tsx b/app/(app)/admin/layout.tsx
new file mode 100644
index 0000000..6c2c912
--- /dev/null
+++ b/app/(app)/admin/layout.tsx
@@ -0,0 +1,16 @@
+import { requireAdmin } from '@/lib/auth-guard'
+import Link from 'next/link'
+
+export default async function AdminLayout({ children }: { children: React.ReactNode }) {
+ await requireAdmin()
+ return (
+
+
+ {children}
+
+ )
+}
diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx
new file mode 100644
index 0000000..f07ba33
--- /dev/null
+++ b/app/(app)/admin/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from 'next/navigation'
+
+export default function AdminPage() {
+ redirect('/admin/users')
+}
diff --git a/app/(app)/admin/users/page.tsx b/app/(app)/admin/users/page.tsx
new file mode 100644
index 0000000..6d3543d
--- /dev/null
+++ b/app/(app)/admin/users/page.tsx
@@ -0,0 +1,19 @@
+import { requireAdmin } from '@/lib/auth-guard'
+import { prisma } from '@/lib/prisma'
+import { UsersTable } from '@/components/admin/users-table'
+
+export default async function AdminUsersPage() {
+ const session = await requireAdmin()
+
+ const users = await prisma.user.findMany({
+ include: { roles: { select: { role: true } } },
+ orderBy: { created_at: 'desc' },
+ })
+
+ return (
+
+
Gebruikers
+
+
+ )
+}
diff --git a/components/admin/users-table.tsx b/components/admin/users-table.tsx
new file mode 100644
index 0000000..172cd41
--- /dev/null
+++ b/components/admin/users-table.tsx
@@ -0,0 +1,239 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { Role } from '@prisma/client'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+ DialogClose,
+} from '@/components/ui/dialog'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ deleteUserAction,
+ updateUserRolesAction,
+ setMustResetPasswordAction,
+} from '@/actions/admin/users'
+
+type UserWithRoles = {
+ id: string
+ username: string
+ email: string | null
+ must_reset_password: boolean
+ created_at: Date
+ roles: { role: Role }[]
+}
+
+const ALL_ROLES: Role[] = [Role.PRODUCT_OWNER, Role.SCRUM_MASTER, Role.DEVELOPER, Role.ADMIN]
+
+const ROLE_LABEL: Record = {
+ PRODUCT_OWNER: 'Product Owner',
+ SCRUM_MASTER: 'Scrum Master',
+ DEVELOPER: 'Developer',
+ ADMIN: 'Admin',
+}
+
+function RoleBadge({ role }: { role: Role }) {
+ const cls =
+ role === Role.ADMIN
+ ? 'bg-status-done text-white border-transparent'
+ : role === Role.PRODUCT_OWNER
+ ? 'bg-status-in-progress text-white border-transparent'
+ : role === Role.SCRUM_MASTER
+ ? 'bg-priority-medium text-white border-transparent'
+ : 'bg-secondary text-secondary-foreground'
+ return {ROLE_LABEL[role]}
+}
+
+function RolesDialog({ user, currentUserId }: { user: UserWithRoles; currentUserId: string }) {
+ const [open, setOpen] = useState(false)
+ const [selected, setSelected] = useState>(new Set(user.roles.map(r => r.role)))
+ const [pending, startTransition] = useTransition()
+ const isSelf = user.id === currentUserId
+
+ function toggle(role: Role) {
+ setSelected(prev => {
+ const next = new Set(prev)
+ if (next.has(role)) next.delete(role)
+ else next.add(role)
+ return next
+ })
+ }
+
+ function handleSave() {
+ startTransition(async () => {
+ await updateUserRolesAction(user.id, Array.from(selected))
+ setOpen(false)
+ })
+ }
+
+ return (
+
+ )
+}
+
+function DeleteDialog({ user, currentUserId }: { user: UserWithRoles; currentUserId: string }) {
+ const [open, setOpen] = useState(false)
+ const [pending, startTransition] = useTransition()
+ const isSelf = user.id === currentUserId
+
+ function handleDelete() {
+ startTransition(async () => {
+ await deleteUserAction(user.id)
+ setOpen(false)
+ })
+ }
+
+ return (
+
+ )
+}
+
+function ResetToggle({ user }: { user: UserWithRoles }) {
+ const [pending, startTransition] = useTransition()
+
+ function handleToggle() {
+ startTransition(async () => {
+ await setMustResetPasswordAction(user.id, !user.must_reset_password)
+ })
+ }
+
+ return (
+
+ )
+}
+
+export function UsersTable({
+ users,
+ currentUserId,
+}: {
+ users: UserWithRoles[]
+ currentUserId: string
+}) {
+ return (
+
+
+
+ Gebruiker
+ Email
+ Rollen
+ Reset pw
+ Aangemaakt
+ Acties
+
+
+
+ {users.map(user => (
+
+ {user.username}
+ {user.email ?? '—'}
+
+
+ {user.roles.map(r => (
+
+ ))}
+ {user.roles.length === 0 && Geen}
+
+
+
+ {user.must_reset_password ? (
+ Ja
+ ) : (
+ —
+ )}
+
+
+ {new Date(user.created_at).toLocaleDateString('nl-NL')}
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )
+}