feat(admin): Gebruikers screen (list, create, role, password, deactivate)
This commit is contained in:
@@ -6,6 +6,7 @@ import Live from './screens/Live';
|
||||
import Activities from './screens/Activities';
|
||||
import Sessions from './screens/Sessions';
|
||||
import Reports from './screens/Reports';
|
||||
import Users from './screens/Users';
|
||||
|
||||
function AuthedShell() {
|
||||
return (
|
||||
@@ -18,6 +19,7 @@ function AuthedShell() {
|
||||
<Route path="/handelingen" element={<Activities />} />
|
||||
<Route path="/sessies" element={<Sessions />} />
|
||||
<Route path="/rapporten" element={<Reports />} />
|
||||
<Route path="/gebruikers" element={<Users />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
54
apps/admin/src/api/users.ts
Normal file
54
apps/admin/src/api/users.ts
Normal 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' }),
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,9 @@ const navItems = [
|
||||
{ to: '/handelingen', label: 'Handelingen' },
|
||||
{ to: '/sessies', label: 'Sessies' },
|
||||
{ to: '/rapporten', label: 'Rapporten' },
|
||||
{ to: '/gebruikers', label: 'Gebruikers' },
|
||||
] as const;
|
||||
|
||||
// Sections planned for the final Phase 3b cycle — shown muted/disabled.
|
||||
const soonItems = ['Gebruikers'] as const;
|
||||
|
||||
export default function Sidebar() {
|
||||
const { signOut } = useAuth();
|
||||
const meQuery = useMe();
|
||||
@@ -32,15 +30,6 @@ export default function Sidebar() {
|
||||
{item.label}
|
||||
</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>
|
||||
|
||||
<div className="topbar">
|
||||
|
||||
68
apps/admin/src/components/UserForm.tsx
Normal file
68
apps/admin/src/components/UserForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
apps/admin/src/screens/Users.test.tsx
Normal file
107
apps/admin/src/screens/Users.test.tsx
Normal 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' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
179
apps/admin/src/screens/Users.tsx
Normal file
179
apps/admin/src/screens/Users.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user