diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 359d4d0..60c69a2 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/apps/admin/src/api/users.ts b/apps/admin/src/api/users.ts new file mode 100644 index 0000000..3fb3102 --- /dev/null +++ b/apps/admin/src/api/users.ts @@ -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('/api/admin/users'), + }); +} + +function useUsersMutation(fn: (arg: T) => Promise) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: fn, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }), + }); +} + +export function useCreateUser() { + return useUsersMutation((input: CreateUserInput) => + apiFetch('/api/admin/users', { method: 'POST', body: JSON.stringify(input) }), + ); +} + +export function useSetUserRole() { + return useUsersMutation(({ id, role }: { id: string; role: Role }) => + apiFetch(`/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(`/api/admin/users/${id}/deactivate`, { method: 'POST' }), + ); +} + +export function useReactivateUser() { + return useUsersMutation((id: string) => + apiFetch(`/api/admin/users/${id}/reactivate`, { method: 'POST' }), + ); +} diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index 4f1bd45..48a900d 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -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} ))} - -
- Binnenkort - {soonItems.map((label) => ( - - {label} - - ))} -
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 ( +
{ + e.preventDefault(); + if (password.length < 8) return; + onSubmit({ name: name.trim(), email: email.trim(), password, role }); + }} + > + + + + {tooShort &&

Minstens 8 tekens.

} + + {error &&

{error}

} +
+ + +
+
+ ); +} 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 ( +
+

Laden…

+
+ ); + } + if (usersQuery.isError) { + return ( +
+

Kon gebruikers niet laden.

+
+ ); + } + + const users = usersQuery.data ?? []; + + return ( +
+
+

Gebruikers

+ +
+ + {creating && ( + setCreating(false)} + pending={createUser.isPending} + error={createError} + /> + )} + + + + + + + + + + + + + + {users.map((u) => ( + + ))} + +
NaamE-mailRolStatusAangemaaktActies
+
+ ); +} + +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; +}