feat(admin): Gebruikers screen (list, create, role, password, deactivate)

This commit is contained in:
Bas van Rossem
2026-06-24 23:03:34 +02:00
parent 4b80898b0e
commit 0f42960f00
7 changed files with 482 additions and 12 deletions

View File

@@ -6,6 +6,7 @@ import Live from './screens/Live';
import Activities from './screens/Activities'; import Activities from './screens/Activities';
import Sessions from './screens/Sessions'; import Sessions from './screens/Sessions';
import Reports from './screens/Reports'; import Reports from './screens/Reports';
import Users from './screens/Users';
function AuthedShell() { function AuthedShell() {
return ( return (
@@ -18,6 +19,7 @@ function AuthedShell() {
<Route path="/handelingen" element={<Activities />} /> <Route path="/handelingen" element={<Activities />} />
<Route path="/sessies" element={<Sessions />} /> <Route path="/sessies" element={<Sessions />} />
<Route path="/rapporten" element={<Reports />} /> <Route path="/rapporten" element={<Reports />} />
<Route path="/gebruikers" element={<Users />} />
</Routes> </Routes>
</main> </main>
</div> </div>

View File

@@ -0,0 +1,54 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { AdminUser, CreateUserInput, Role } from '@solelog/shared';
import { apiFetch } from '../lib/api';
export function useUsers() {
return useQuery({
queryKey: ['admin', 'users'],
queryFn: () => apiFetch<AdminUser[]>('/api/admin/users'),
});
}
function useUsersMutation<T>(fn: (arg: T) => Promise<unknown>) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }),
});
}
export function useCreateUser() {
return useUsersMutation((input: CreateUserInput) =>
apiFetch<AdminUser>('/api/admin/users', { method: 'POST', body: JSON.stringify(input) }),
);
}
export function useSetUserRole() {
return useUsersMutation(({ id, role }: { id: string; role: Role }) =>
apiFetch<AdminUser>(`/api/admin/users/${id}/role`, {
method: 'POST',
body: JSON.stringify({ role }),
}),
);
}
export function useResetUserPassword() {
return useUsersMutation(({ id, password }: { id: string; password: string }) =>
apiFetch<{ success: true }>(`/api/admin/users/${id}/password`, {
method: 'POST',
body: JSON.stringify({ password }),
}),
);
}
export function useDeactivateUser() {
return useUsersMutation((id: string) =>
apiFetch<AdminUser>(`/api/admin/users/${id}/deactivate`, { method: 'POST' }),
);
}
export function useReactivateUser() {
return useUsersMutation((id: string) =>
apiFetch<AdminUser>(`/api/admin/users/${id}/reactivate`, { method: 'POST' }),
);
}

View File

@@ -7,11 +7,9 @@ const navItems = [
{ to: '/handelingen', label: 'Handelingen' }, { to: '/handelingen', label: 'Handelingen' },
{ to: '/sessies', label: 'Sessies' }, { to: '/sessies', label: 'Sessies' },
{ to: '/rapporten', label: 'Rapporten' }, { to: '/rapporten', label: 'Rapporten' },
{ to: '/gebruikers', label: 'Gebruikers' },
] as const; ] as const;
// Sections planned for the final Phase 3b cycle — shown muted/disabled.
const soonItems = ['Gebruikers'] as const;
export default function Sidebar() { export default function Sidebar() {
const { signOut } = useAuth(); const { signOut } = useAuth();
const meQuery = useMe(); const meQuery = useMe();
@@ -32,15 +30,6 @@ export default function Sidebar() {
{item.label} {item.label}
</NavLink> </NavLink>
))} ))}
<div className="nav-soon">
<span className="nav-soon-label">Binnenkort</span>
{soonItems.map((label) => (
<span key={label} className="nav-disabled" aria-disabled="true">
{label}
</span>
))}
</div>
</nav> </nav>
<div className="topbar"> <div className="topbar">

View File

@@ -0,0 +1,68 @@
import { useState } from 'react';
import type { CreateUserInput, Role } from '@solelog/shared';
export default function UserForm({
onSubmit,
onCancel,
pending,
error,
}: {
onSubmit: (input: CreateUserInput) => void;
onCancel: () => void;
pending: boolean;
error: string | null;
}) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<Role>('worker');
const tooShort = password.length > 0 && password.length < 8;
return (
<form
className="user-form"
data-testid="user-form"
onSubmit={(e) => {
e.preventDefault();
if (password.length < 8) return;
onSubmit({ name: name.trim(), email: email.trim(), password, role });
}}
>
<label>
Naam
<input value={name} onChange={(e) => setName(e.target.value)} required />
</label>
<label>
E-mail
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>
Wachtwoord
<input
type="text"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</label>
{tooShort && <p className="form-error">Minstens 8 tekens.</p>}
<label>
Rol
<select value={role} onChange={(e) => setRole(e.target.value as Role)}>
<option value="worker">Werker</option>
<option value="admin">Beheerder</option>
</select>
</label>
{error && <p className="form-error">{error}</p>}
<div className="user-form-actions">
<button type="submit" className="btn-primary" disabled={pending}>
Aanmaken
</button>
<button type="button" onClick={onCancel}>
Annuleer
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,107 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { AdminUser } from '@solelog/shared';
import Users from './Users';
import { apiFetch } from '../lib/api';
vi.mock('../lib/api', () => ({ apiFetch: vi.fn() }));
const mockApiFetch = vi.mocked(apiFetch);
const USERS: AdminUser[] = [
{
id: 'me',
name: 'Beheerder',
email: 'admin@x',
role: 'admin',
status: 'active',
created_at: new Date('2026-06-01T00:00:00Z').toISOString(),
},
{
id: 'u2',
name: 'Jan',
email: 'jan@x',
role: 'worker',
status: 'active',
created_at: new Date('2026-06-02T00:00:00Z').toISOString(),
},
{
id: 'u3',
name: 'An',
email: 'an@x',
role: 'worker',
status: 'inactive',
created_at: new Date('2026-06-03T00:00:00Z').toISOString(),
},
];
function mockEndpoints() {
mockApiFetch.mockImplementation((path?: string, init?: RequestInit) => {
if (path === '/api/admin/users' && (!init || init.method === undefined))
return Promise.resolve(USERS as never);
if (path === '/api/me') return Promise.resolve({ user: USERS[0] } as never);
return Promise.resolve({} as never);
});
}
function renderUsers() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<Users />
</QueryClientProvider>,
);
}
describe('Users', () => {
beforeEach(() => mockApiFetch.mockReset());
afterEach(() => vi.clearAllMocks());
it('renders a row per user with role and status', async () => {
mockEndpoints();
renderUsers();
expect(await screen.findByText('Jan')).toBeInTheDocument();
expect(screen.getByText('An')).toBeInTheDocument();
expect(screen.getAllByText('Werker').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('Inactief')).toBeInTheDocument();
});
it('hides role/deactivate actions on the signed-in admin own row', async () => {
mockEndpoints();
renderUsers();
const adminRow = (await screen.findByText('admin@x')).closest('tr') as HTMLElement;
expect(within(adminRow).getByText('jij')).toBeInTheDocument();
expect(within(adminRow).queryByRole('button', { name: 'Deactiveer' })).not.toBeInTheDocument();
});
it('+ Nieuwe gebruiker posts CreateUserInput', async () => {
mockEndpoints();
renderUsers();
await screen.findByText('Jan');
await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe gebruiker' }));
await userEvent.type(screen.getByLabelText('Naam'), 'Nieuw');
await userEvent.type(screen.getByLabelText('E-mail'), 'nieuw@x.nl');
await userEvent.type(screen.getByLabelText('Wachtwoord'), 'wachtwoord-123');
await userEvent.click(screen.getByRole('button', { name: 'Aanmaken' }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/users',
expect.objectContaining({ method: 'POST' }),
),
);
});
it('Deactiveer on another user calls the deactivate endpoint', async () => {
mockEndpoints();
renderUsers();
const janRow = (await screen.findByText('Jan')).closest('tr') as HTMLElement;
await userEvent.click(within(janRow).getByRole('button', { name: 'Deactiveer' }));
await waitFor(() =>
expect(mockApiFetch).toHaveBeenCalledWith(
'/api/admin/users/u2/deactivate',
expect.objectContaining({ method: 'POST' }),
),
);
});
});

View File

@@ -0,0 +1,179 @@
import { useState } from 'react';
import type { AdminUser, CreateUserInput } from '@solelog/shared';
import {
useCreateUser,
useDeactivateUser,
useReactivateUser,
useResetUserPassword,
useSetUserRole,
useUsers,
} from '../api/users';
import { useMe } from '../api/me';
import UserForm from '../components/UserForm';
export default function Users() {
const usersQuery = useUsers();
const meQuery = useMe();
const myId = meQuery.data?.user.id;
const createUser = useCreateUser();
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
function onCreate(input: CreateUserInput) {
setCreateError(null);
createUser.mutate(input, {
onSuccess: () => setCreating(false),
onError: () => setCreateError('E-mailadres bestaat al of ongeldig.'),
});
}
if (usersQuery.isLoading) {
return (
<div className="screen">
<p className="muted">Laden</p>
</div>
);
}
if (usersQuery.isError) {
return (
<div className="screen">
<p className="muted">Kon gebruikers niet laden.</p>
</div>
);
}
const users = usersQuery.data ?? [];
return (
<div className="screen">
<div className="reports-head">
<h1 className="screen-title">Gebruikers</h1>
<button type="button" className="btn-primary" onClick={() => setCreating((v) => !v)}>
+ Nieuwe gebruiker
</button>
</div>
{creating && (
<UserForm
onSubmit={onCreate}
onCancel={() => setCreating(false)}
pending={createUser.isPending}
error={createError}
/>
)}
<table className="users-table">
<thead>
<tr>
<th>Naam</th>
<th>E-mail</th>
<th>Rol</th>
<th>Status</th>
<th>Aangemaakt</th>
<th>Acties</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<UserRow key={u.id} user={u} isSelf={u.id === myId} />
))}
</tbody>
</table>
</div>
);
}
function UserRow({ user, isSelf }: { user: AdminUser; isSelf: boolean }) {
const setRole = useSetUserRole();
const deactivate = useDeactivateUser();
const reactivate = useReactivateUser();
const resetPassword = useResetUserPassword();
const [resetting, setResetting] = useState(false);
const [pw, setPw] = useState('');
const busy =
setRole.isPending || deactivate.isPending || reactivate.isPending || resetPassword.isPending;
function onReset() {
if (pw.length < 8) return;
resetPassword.mutate(
{ id: user.id, password: pw },
{
onSuccess: () => {
setResetting(false);
setPw('');
},
},
);
}
return (
<tr>
<td>
{user.name} {isSelf && <span className="user-self-badge">jij</span>}
</td>
<td>{user.email}</td>
<td>
<span className={user.role === 'admin' ? 'pill pill-admin' : 'pill pill-worker'}>
{user.role === 'admin' ? 'Beheerder' : 'Werker'}
</span>
</td>
<td>
<span className={user.status === 'active' ? 'pill pill-active' : 'pill pill-inactive'}>
{user.status === 'active' ? 'Actief' : 'Inactief'}
</span>
</td>
<td>{new Date(user.created_at).toLocaleDateString('nl-BE')}</td>
<td className="users-actions">
{!isSelf && (
<button
type="button"
disabled={busy}
onClick={() =>
setRole.mutate({ id: user.id, role: user.role === 'admin' ? 'worker' : 'admin' })
}
>
{user.role === 'admin' ? 'Maak werker' : 'Maak admin'}
</button>
)}
{resetting ? (
<span className="users-reset">
<input
type="text"
aria-label={`Nieuw wachtwoord voor ${user.name}`}
value={pw}
onChange={(e) => setPw(e.target.value)}
minLength={8}
/>
<button type="button" disabled={busy || pw.length < 8} onClick={onReset}>
Opslaan
</button>
<button type="button" onClick={() => setResetting(false)}>
Annuleer
</button>
</span>
) : (
<button type="button" disabled={busy} onClick={() => setResetting(true)}>
Reset wachtwoord
</button>
)}
{!isSelf &&
(user.status === 'active' ? (
<button
type="button"
className="btn-row-cancel"
disabled={busy}
onClick={() => deactivate.mutate(user.id)}
>
Deactiveer
</button>
) : (
<button type="button" disabled={busy} onClick={() => reactivate.mutate(user.id)}>
Heractiveer
</button>
))}
</td>
</tr>
);
}

View File

@@ -671,3 +671,74 @@ body {
.reports-table th:not(:first-child) { .reports-table th:not(:first-child) {
text-align: right; text-align: right;
} }
.users-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.users-table th,
.users-table td {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--border, #e4e4e7);
vertical-align: middle;
}
.users-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.users-reset {
display: inline-flex;
gap: 0.3rem;
align-items: center;
}
.pill {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.pill-admin {
background: #ede9fe;
color: #6d28d9;
}
.pill-worker {
background: #e0f2fe;
color: #0369a1;
}
.pill-active {
background: #dcfce7;
color: #15803d;
}
.pill-inactive {
background: #fee2e2;
color: #b91c1c;
}
.user-self-badge {
font-size: 0.7rem;
color: var(--muted, #71717a);
font-style: italic;
}
.user-form {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
padding: 1rem;
background: var(--surface, #f4f4f5);
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.user-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.8rem;
}
.user-form-actions {
display: flex;
gap: 0.5rem;
}