diff --git a/apps/admin/src/components/UserForm.tsx b/apps/admin/src/components/UserForm.tsx
new file mode 100644
index 0000000..9a6425d
--- /dev/null
+++ b/apps/admin/src/components/UserForm.tsx
@@ -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
('worker');
+ const tooShort = password.length > 0 && password.length < 8;
+
+ return (
+
+ );
+}
diff --git a/apps/admin/src/screens/Users.test.tsx b/apps/admin/src/screens/Users.test.tsx
new file mode 100644
index 0000000..e3b600c
--- /dev/null
+++ b/apps/admin/src/screens/Users.test.tsx
@@ -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(
+
+
+ ,
+ );
+}
+
+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' }),
+ ),
+ );
+ });
+});
diff --git a/apps/admin/src/screens/Users.tsx b/apps/admin/src/screens/Users.tsx
new file mode 100644
index 0000000..e03efc5
--- /dev/null
+++ b/apps/admin/src/screens/Users.tsx
@@ -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(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 (
+
+ );
+ }
+ if (usersQuery.isError) {
+ return (
+
+
Kon gebruikers niet laden.
+
+ );
+ }
+
+ const users = usersQuery.data ?? [];
+
+ return (
+
+
+
Gebruikers
+
+
+
+ {creating && (
+
setCreating(false)}
+ pending={createUser.isPending}
+ error={createError}
+ />
+ )}
+
+
+
+
+ | Naam |
+ E-mail |
+ Rol |
+ Status |
+ Aangemaakt |
+ Acties |
+
+
+
+ {users.map((u) => (
+
+ ))}
+
+
+
+ );
+}
+
+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 (
+
+ |
+ {user.name} {isSelf && jij}
+ |
+ {user.email} |
+
+
+ {user.role === 'admin' ? 'Beheerder' : 'Werker'}
+
+ |
+
+
+ {user.status === 'active' ? 'Actief' : 'Inactief'}
+
+ |
+ {new Date(user.created_at).toLocaleDateString('nl-BE')} |
+
+ {!isSelf && (
+
+ )}
+ {resetting ? (
+
+ setPw(e.target.value)}
+ minLength={8}
+ />
+
+
+
+ ) : (
+
+ )}
+ {!isSelf &&
+ (user.status === 'active' ? (
+
+ ) : (
+
+ ))}
+ |
+
+ );
+}
diff --git a/apps/admin/src/styles.css b/apps/admin/src/styles.css
index d525dac..3a34415 100644
--- a/apps/admin/src/styles.css
+++ b/apps/admin/src/styles.css
@@ -671,3 +671,74 @@ body {
.reports-table th:not(:first-child) {
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;
+}