From 5fd56e3f67fece2891951f5015ac2c76cee36609 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Tue, 5 May 2026 14:38:42 +0200 Subject: [PATCH 1/2] feat(ST-111ci8t4): admin user-actions (delete, updateRoles, setMustResetPassword) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/session.ts: isAdmin: boolean toegevoegd aan SessionData - lib/auth-guard.ts: requireAdmin() toegevoegd (redirect /dashboard bij !isAdmin) - actions/admin/users.ts: deleteUserAction (zelfbescherming), updateUserRolesAction (Zod z.nativeEnum, eigen ADMIN-rol-beveiliging, transactie), setMustResetPasswordAction — alle drie 'use server', revalidatePath('/admin/users') --- actions/admin/users.ts | 43 ++++++++++++++++++++++++++++++++++++++++++ lib/auth-guard.ts | 8 ++++++++ lib/session.ts | 1 + 3 files changed, 52 insertions(+) create mode 100644 actions/admin/users.ts diff --git a/actions/admin/users.ts b/actions/admin/users.ts new file mode 100644 index 0000000..c7698fa --- /dev/null +++ b/actions/admin/users.ts @@ -0,0 +1,43 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { Role } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { requireAdmin } from '@/lib/auth-guard' + +export async function deleteUserAction(userId: string) { + const session = await requireAdmin() + if (userId === session.userId) { + throw new Error('Zelfverwijdering niet toegestaan') + } + await prisma.user.delete({ where: { id: userId } }) + revalidatePath('/admin/users') +} + +const rolesSchema = z.array(z.nativeEnum(Role)) + +export async function updateUserRolesAction(userId: string, roles: Role[]) { + const session = await requireAdmin() + + const parsed = rolesSchema.safeParse(roles) + if (!parsed.success) { + throw new Error('Ongeldige rol-waarden') + } + + if (userId === session.userId && !parsed.data.includes(Role.ADMIN)) { + throw new Error('Kan eigen ADMIN-rol niet verwijderen') + } + + await prisma.$transaction([ + prisma.userRole.deleteMany({ where: { user_id: userId } }), + ...parsed.data.map((role) => prisma.userRole.create({ data: { user_id: userId, role } })), + ]) + revalidatePath('/admin/users') +} + +export async function setMustResetPasswordAction(userId: string, value: boolean) { + await requireAdmin() + await prisma.user.update({ where: { id: userId }, data: { must_reset_password: value } }) + revalidatePath('/admin/users') +} diff --git a/lib/auth-guard.ts b/lib/auth-guard.ts index 8b6baf5..e82a568 100644 --- a/lib/auth-guard.ts +++ b/lib/auth-guard.ts @@ -22,3 +22,11 @@ export async function requireSession() { return session } + +export async function requireAdmin() { + const session = await getSession() + if (!session.userId || !session.isAdmin) { + redirect('/dashboard') + } + return session +} diff --git a/lib/session.ts b/lib/session.ts index bf1f9a9..5d7c587 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -3,6 +3,7 @@ import { SessionOptions } from 'iron-session' export interface SessionData { userId: string isDemo: boolean + isAdmin: boolean // ST-1002 (M10) — gezet door /api/auth/pair/claim na een succesvolle QR-pairing. // Beide velden zijn optioneel zodat bestaande wachtwoord-sessies onveranderd blijven. paired?: boolean 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 2/2] 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 ( + + }> + Rollen + + + + Rollen voor {user.username} + +
+ {ALL_ROLES.map(role => { + const isDisabled = isSelf && role === Role.ADMIN && selected.has(role) + 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 ( + + + } + > + Verwijder + + + + Gebruiker verwijderen + +

+ Weet je zeker dat je {user.username} wilt verwijderen? Dit kan niet ongedaan worden gemaakt. +

+ + }>Annuleer + + +
+
+ ) +} + +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')} + + +
+ + + +
+
+
+ ))} +
+
+ ) +}