Compare commits

...

10 Commits

Author SHA1 Message Date
Bas van Rossem
8d303dabcb docs: phase 3b.3 user-management session log + roadmap (Phase 3 complete)
All checks were successful
Build and Push Docker Image / build (push) Successful in 57s
2026-06-24 23:14:59 +02:00
Bas van Rossem
b9aa24c5ae harden(api): adminGuard rejects banned admins; fix mixed-case create + real last-admin guard coverage 2026-06-24 23:12:40 +02:00
Bas van Rossem
0f42960f00 feat(admin): Gebruikers screen (list, create, role, password, deactivate) 2026-06-24 23:03:34 +02:00
Bas van Rossem
4b80898b0e feat(api): admin reset-password endpoint 2026-06-24 22:57:35 +02:00
Bas van Rossem
40977f8abd feat(api): deactivate/reactivate users (ban + revoke sessions) with guards 2026-06-24 22:54:02 +02:00
Bas van Rossem
88b7773317 feat(api): set user role with self-demote + last-admin guards 2026-06-24 22:50:52 +02:00
Bas van Rossem
2b4b633961 feat(api): admin-users router with enriched roster + user creation 2026-06-24 22:46:59 +02:00
Bas van Rossem
a32406de69 refactor(api): extract adminGuard + add user-management contracts 2026-06-24 22:42:35 +02:00
Bas van Rossem
4463a2e41a docs(plan): phase 3b.3 user management implementation plan 2026-06-24 22:39:15 +02:00
Bas van Rossem
6e4ce6b472 docs(spec): phase 3b.3 user management design 2026-06-24 22:33:59 +02:00
17 changed files with 2745 additions and 33 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;
}

View File

@@ -5,6 +5,7 @@ import { me } from './routes/me';
import { activitiesRoutes } from './routes/activities'; import { activitiesRoutes } from './routes/activities';
import { sessionsRoutes } from './routes/sessions'; import { sessionsRoutes } from './routes/sessions';
import { adminRoutes } from './routes/admin'; import { adminRoutes } from './routes/admin';
import { adminUsersRoutes } from './routes/admin-users';
import { auth } from './auth'; import { auth } from './auth';
import { env } from './env'; import { env } from './env';
@@ -26,5 +27,6 @@ export function createApp(): Hono {
app.route('/', activitiesRoutes); app.route('/', activitiesRoutes);
app.route('/', sessionsRoutes); app.route('/', sessionsRoutes);
app.route('/', adminRoutes); app.route('/', adminRoutes);
app.route('/', adminUsersRoutes);
return app; return app;
} }

View File

@@ -1,18 +1,29 @@
import type { Context } from 'hono'; import type { Context, MiddlewareHandler } from 'hono';
import { auth } from '../auth'; import { auth } from '../auth';
export interface SessionUser { export interface SessionUser {
id: string; id: string;
role: string; role: string;
banned: boolean;
} }
export async function getSessionUser(c: Context): Promise<SessionUser | null> { export async function getSessionUser(c: Context): Promise<SessionUser | null> {
const session = await auth.api.getSession({ headers: c.req.raw.headers }); const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) return null; if (!session) return null;
const role = (session.user as { role?: string | null }).role ?? 'worker'; const u = session.user as { role?: string | null; banned?: boolean | null };
return { id: session.user.id, role }; return { id: session.user.id, role: u.role ?? 'worker', banned: Boolean(u.banned) };
} }
export function isAdmin(u: SessionUser | null): boolean { export function isAdmin(u: SessionUser | null): boolean {
return u?.role === 'admin'; return u?.role === 'admin';
} }
// Reusable gate for the whole /api/admin/* surface: 401 if unauthenticated, 403 if not an
// active admin. A banned admin is rejected even with a still-valid session (defense-in-depth
// beyond deactivate revoking their sessions).
export const adminGuard: MiddlewareHandler = async (c, next) => {
const u = await getSessionUser(c);
if (!u) return c.json({ error: 'Unauthorized' }, 401);
if (u.banned || !isAdmin(u)) return c.json({ error: 'Forbidden' }, 403);
await next();
};

View File

@@ -0,0 +1,149 @@
import { Hono } from 'hono';
import { asc, eq } from 'drizzle-orm';
import {
CreateUserInput,
SetPasswordInput,
SetRoleInput,
type AdminUser,
type Role,
} from '@solelog/shared';
import { db } from '../db/client';
import { session, user } from '../db/schema';
import { auth } from '../auth';
import { adminGuard, getSessionUser } from '../lib/require-user';
export const adminUsersRoutes = new Hono();
adminUsersRoutes.use('/api/admin/users', adminGuard);
adminUsersRoutes.use('/api/admin/users/*', adminGuard);
type UserRow = typeof user.$inferSelect;
// Map a user row to the public list shape.
export function toListItem(row: UserRow): AdminUser {
return {
id: row.id,
email: row.email,
name: row.name,
role: (row.role ?? 'worker') as Role,
status: row.banned ? 'inactive' : 'active',
created_at: new Date(row.createdAt).toISOString(),
};
}
// Enriched roster (replaces the old id/name/email version from admin.ts).
adminUsersRoutes.get('/api/admin/users', async (c) => {
const rows = await db.select().from(user).orderBy(asc(user.name));
return c.json(rows.map(toListItem));
});
// Create a user. role/password validated by zod; password hashing via better-auth.
adminUsersRoutes.post('/api/admin/users', async (c) => {
const parsed = CreateUserInput.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
const d = parsed.data;
// better-auth types role as its built-in set; our 'worker' role is valid at runtime (same cast as seed.ts).
const createUser = auth.api.createUser as (args: {
body: { email: string; password: string; name: string; role: 'worker' | 'admin' };
}) => Promise<unknown>;
try {
await createUser({
body: { email: d.email, password: d.password, name: d.name, role: d.role },
});
} catch {
// Inputs are pre-validated, so the realistic failure is a duplicate email.
return c.json({ error: 'E-mailadres bestaat al.' }, 409);
}
// better-auth lowercases the email before storing, so re-select on the normalized form
// (zod's .email() does not lowercase) — otherwise a mixed-case email would miss and 500.
const [row] = await db.select().from(user).where(eq(user.email, d.email.toLowerCase()));
return c.json(toListItem(row));
});
// Count admins that are active (not banned), excluding one user id.
export async function activeAdminsExcluding(excludeId: string): Promise<number> {
const admins = await db
.select({ id: user.id, banned: user.banned })
.from(user)
.where(eq(user.role, 'admin'));
return admins.filter((a) => !a.banned && a.id !== excludeId).length;
}
adminUsersRoutes.post('/api/admin/users/:id/role', async (c) => {
const id = c.req.param('id');
const parsed = SetRoleInput.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
const newRole = parsed.data.role;
const caller = await getSessionUser(c);
const [target] = await db.select().from(user).where(eq(user.id, id));
if (!target) return c.json({ error: 'Gebruiker niet gevonden' }, 404);
if (id === caller?.id && newRole !== 'admin') {
return c.json({ error: 'Je kunt jezelf niet degraderen.' }, 400);
}
if (target.role === 'admin' && newRole !== 'admin') {
if ((await activeAdminsExcluding(id)) === 0) {
return c.json({ error: 'Er moet minstens één actieve beheerder blijven.' }, 400);
}
}
await db.update(user).set({ role: newRole }).where(eq(user.id, id));
const [updated] = await db.select().from(user).where(eq(user.id, id));
return c.json(toListItem(updated));
});
adminUsersRoutes.post('/api/admin/users/:id/deactivate', async (c) => {
const id = c.req.param('id');
const caller = await getSessionUser(c);
const [target] = await db.select().from(user).where(eq(user.id, id));
if (!target) return c.json({ error: 'Gebruiker niet gevonden' }, 404);
if (id === caller?.id) return c.json({ error: 'Je kunt jezelf niet deactiveren.' }, 400);
if (target.role === 'admin' && (await activeAdminsExcluding(id)) === 0) {
return c.json({ error: 'Er moet minstens één actieve beheerder blijven.' }, 400);
}
await db
.update(user)
.set({ banned: true, banReason: null, banExpires: null })
.where(eq(user.id, id));
await db.delete(session).where(eq(session.userId, id)); // kill any live token
const [updated] = await db.select().from(user).where(eq(user.id, id));
return c.json(toListItem(updated));
});
adminUsersRoutes.post('/api/admin/users/:id/reactivate', async (c) => {
const id = c.req.param('id');
const [target] = await db.select().from(user).where(eq(user.id, id));
if (!target) return c.json({ error: 'Gebruiker niet gevonden' }, 404);
await db
.update(user)
.set({ banned: false, banReason: null, banExpires: null })
.where(eq(user.id, id));
const [updated] = await db.select().from(user).where(eq(user.id, id));
return c.json(toListItem(updated));
});
adminUsersRoutes.post('/api/admin/users/:id/password', async (c) => {
const id = c.req.param('id');
const parsed = SetPasswordInput.safeParse(await c.req.json().catch(() => null));
if (!parsed.success) return c.json({ error: 'Invalid input' }, 400);
const [target] = await db.select().from(user).where(eq(user.id, id));
if (!target) return c.json({ error: 'Gebruiker niet gevonden' }, 404);
try {
await auth.api.setUserPassword({
body: { userId: id, newPassword: parsed.data.password },
headers: c.req.raw.headers,
});
} catch {
return c.json({ error: 'Wachtwoord wijzigen mislukt.' }, 400);
}
return c.json({ success: true });
});

View File

@@ -3,7 +3,7 @@ import { and, asc, desc, eq, gte, lte, type SQL } from 'drizzle-orm';
import { AdminUpdateSessionInput, CreateManualSessionInput, InsoleType } from '@solelog/shared'; import { AdminUpdateSessionInput, CreateManualSessionInput, InsoleType } from '@solelog/shared';
import { db } from '../db/client'; import { db } from '../db/client';
import { activities, user, workSessions } from '../db/schema'; import { activities, user, workSessions } from '../db/schema';
import { getSessionUser, isAdmin } from '../lib/require-user'; import { adminGuard } from '../lib/require-user';
import { toWorkSession } from '../lib/work-session'; import { toWorkSession } from '../lib/work-session';
import { buildSessionsCsv } from '../lib/csv'; import { buildSessionsCsv } from '../lib/csv';
@@ -59,12 +59,7 @@ function buildSessionFilters(q: ReportQuery): SQL[] {
export const adminRoutes = new Hono(); export const adminRoutes = new Hono();
// Gate the whole /api/admin/* surface to admins. // Gate the whole /api/admin/* surface to admins.
adminRoutes.use('/api/admin/*', async (c, next) => { adminRoutes.use('/api/admin/*', adminGuard);
const sessionUser = await getSessionUser(c);
if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401);
if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403);
await next();
});
const baseSelect = { const baseSelect = {
session: workSessions, session: workSessions,
@@ -110,15 +105,6 @@ adminRoutes.get('/api/admin/sessions/active', async (c) => {
); );
}); });
// Roster for the create-form worker picker. Direct DB read — no better-auth client dependency.
adminRoutes.get('/api/admin/users', async (c) => {
const rows = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.orderBy(asc(user.name));
return c.json(rows);
});
adminRoutes.get('/api/admin/report', async (c) => { adminRoutes.get('/api/admin/report', async (c) => {
const q = parseReportQuery(c); const q = parseReportQuery(c);
if (!q) return c.json({ error: 'Invalid query' }, 400); if (!q) return c.json({ error: 'Invalid query' }, 400);

View File

@@ -0,0 +1,394 @@
import { describe, it, expect } from 'vitest';
import type { Hono } from 'hono';
import { createApp } from '../src/app';
import { authToken, bearer } from './helpers';
import { db } from '../src/db/client';
import { user } from '../src/db/schema';
import { eq } from 'drizzle-orm';
import { activeAdminsExcluding } from '../src/routes/admin-users';
const PASSWORD = 'sterk-wachtwoord-123';
async function signInStatus(app: Hono, email: string, password: string): Promise<number> {
const res = await app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
});
return res.status;
}
describe('GET /api/admin/users', () => {
it('401s without a token, 403s for a worker', async () => {
const app = createApp();
expect((await app.request('/api/admin/users')).status).toBe(401);
const workerTok = await authToken(app, 'users-list-worker@example.com');
expect((await app.request('/api/admin/users', { headers: bearer(workerTok) })).status).toBe(
403,
);
});
it('returns each user with role and status', async () => {
const app = createApp();
const adminTok = await authToken(app, 'users-list-admin@example.com', 'admin');
await authToken(app, 'users-list-w@example.com'); // a worker
const res = await app.request('/api/admin/users', { headers: bearer(adminTok) });
expect(res.status).toBe(200);
const users = await res.json();
const admin = users.find((u: { email: string }) => u.email === 'users-list-admin@example.com');
const worker = users.find((u: { email: string }) => u.email === 'users-list-w@example.com');
expect(admin.role).toBe('admin');
expect(admin.status).toBe('active');
expect(worker.role).toBe('worker');
expect(worker.status).toBe('active');
});
});
describe('POST /api/admin/users', () => {
it('creates a user who can then sign in', async () => {
const app = createApp();
const adminTok = await authToken(app, 'users-create-admin@example.com', 'admin');
const res = await app.request('/api/admin/users', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
email: 'created-worker@example.com',
name: 'Nieuwe Werker',
password: PASSWORD,
role: 'worker',
}),
});
expect(res.status).toBe(200);
const created = await res.json();
expect(created.email).toBe('created-worker@example.com');
expect(created.role).toBe('worker');
expect(created.status).toBe('active');
expect(await signInStatus(app, 'created-worker@example.com', PASSWORD)).toBe(200);
});
it('rejects a duplicate email with 409', async () => {
const app = createApp();
const adminTok = await authToken(app, 'users-dup-admin@example.com', 'admin');
const body = JSON.stringify({
email: 'dup@example.com',
name: 'Dup',
password: PASSWORD,
role: 'worker',
});
expect(
(await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body }))
.status,
).toBe(200);
expect(
(await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body }))
.status,
).toBe(409);
});
it('rejects a too-short password with 400', async () => {
const app = createApp();
const adminTok = await authToken(app, 'users-shortpw-admin@example.com', 'admin');
const res = await app.request('/api/admin/users', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
email: 'short@example.com',
name: 'S',
password: 'short',
role: 'worker',
}),
});
expect(res.status).toBe(400);
});
it('403s for a worker', async () => {
const app = createApp();
const workerTok = await authToken(app, 'users-create-worker@example.com');
const res = await app.request('/api/admin/users', {
method: 'POST',
headers: bearer(workerTok),
body: JSON.stringify({
email: 'x@example.com',
name: 'X',
password: PASSWORD,
role: 'worker',
}),
});
expect(res.status).toBe(403);
});
it('stores a mixed-case email lowercased and returns the row (no 500)', async () => {
const app = createApp();
const adminTok = await authToken(app, 'mixed-admin@example.com', 'admin');
const res = await app.request('/api/admin/users', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
email: 'Mixed.Case@Example.com',
name: 'Mixed',
password: PASSWORD,
role: 'worker',
}),
});
expect(res.status).toBe(200);
expect((await res.json()).email).toBe('mixed.case@example.com');
});
});
async function userIdByEmail(email: string): Promise<string> {
const [row] = await db.select().from(user).where(eq(user.email, email));
return row.id;
}
describe('POST /api/admin/users/:id/role', () => {
it('promotes a worker to admin and back', async () => {
const app = createApp();
const adminTok = await authToken(app, 'role-admin@example.com', 'admin');
await authToken(app, 'role-target@example.com'); // worker
const id = await userIdByEmail('role-target@example.com');
const up = await app.request(`/api/admin/users/${id}/role`, {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ role: 'admin' }),
});
expect(up.status).toBe(200);
expect((await up.json()).role).toBe('admin');
const down = await app.request(`/api/admin/users/${id}/role`, {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ role: 'worker' }),
});
expect((await down.json()).role).toBe('worker');
});
it('refuses self-demotion', async () => {
const app = createApp();
const adminTok = await authToken(app, 'role-self@example.com', 'admin');
// a second admin so the last-admin guard is not what trips first
await authToken(app, 'role-self-other-admin@example.com', 'admin');
const id = await userIdByEmail('role-self@example.com');
const res = await app.request(`/api/admin/users/${id}/role`, {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ role: 'worker' }),
});
expect(res.status).toBe(400);
expect((await res.json()).error).toContain('degraderen');
});
// Demoting *another* admin is allowed while the acting admin remains active — this is the
// expected 200 path. The genuine last-admin guard logic is covered as a unit test of
// activeAdminsExcluding below (the route branch is unreachable over HTTP because the caller
// is always an active admin and never the target).
it('allows demoting another admin while the caller stays admin', async () => {
const app = createApp();
const adminTok = await authToken(app, 'role-peer-admin@example.com', 'admin');
await authToken(app, 'role-peer@example.com', 'admin');
const peerId = await userIdByEmail('role-peer@example.com');
const res = await app.request(`/api/admin/users/${peerId}/role`, {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ role: 'worker' }),
});
expect(res.status).toBe(200);
expect((await res.json()).role).toBe('worker');
});
});
// Direct unit coverage of the last-admin invariant helper (the route branch that uses it is
// unreachable over HTTP, so this is where its !banned filter + exclusion logic is actually tested).
describe('activeAdminsExcluding', () => {
it('counts only active (non-banned) admins and honours the exclusion', async () => {
const app = createApp();
const before = await activeAdminsExcluding('___none___');
await authToken(app, 'aae-fresh-admin@example.com', 'admin');
expect(await activeAdminsExcluding('___none___')).toBe(before + 1); // a new active admin counts
const freshId = await userIdByEmail('aae-fresh-admin@example.com');
expect(await activeAdminsExcluding(freshId)).toBe(before); // excluding it drops the count back
await db.update(user).set({ banned: true }).where(eq(user.id, freshId));
expect(await activeAdminsExcluding('___none___')).toBe(before); // a banned admin is not counted
});
});
describe('adminGuard rejects banned admins', () => {
it('403s a banned admin that still holds a valid token', async () => {
const app = createApp();
const adminTok = await authToken(app, 'banned-admin@example.com', 'admin');
expect((await app.request('/api/admin/users', { headers: bearer(adminTok) })).status).toBe(200);
// Ban directly (not via /deactivate, which would also delete the session) so the token stays valid.
const id = await userIdByEmail('banned-admin@example.com');
await db.update(user).set({ banned: true }).where(eq(user.id, id));
expect((await app.request('/api/admin/users', { headers: bearer(adminTok) })).status).toBe(403);
});
});
describe('deactivate / reactivate', () => {
async function signIn(app: Hono, email: string, password: string): Promise<number> {
const res = await app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
});
return res.status;
}
it('blocks sign-in after deactivate and restores it after reactivate', async () => {
const app = createApp();
const adminTok = await authToken(app, 'deact-admin@example.com', 'admin');
// create a worker we control the password of
await app.request('/api/admin/users', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
email: 'deact-target@example.com',
name: 'Deact',
password: 'sterk-wachtwoord-123',
role: 'worker',
}),
});
const id = await userIdByEmail('deact-target@example.com');
expect(await signIn(app, 'deact-target@example.com', 'sterk-wachtwoord-123')).toBe(200);
const off = await app.request(`/api/admin/users/${id}/deactivate`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(off.status).toBe(200);
expect((await off.json()).status).toBe('inactive');
expect(await signIn(app, 'deact-target@example.com', 'sterk-wachtwoord-123')).not.toBe(200);
const on = await app.request(`/api/admin/users/${id}/reactivate`, {
method: 'POST',
headers: bearer(adminTok),
});
expect((await on.json()).status).toBe('active');
expect(await signIn(app, 'deact-target@example.com', 'sterk-wachtwoord-123')).toBe(200);
});
it('refuses self-deactivation', async () => {
const app = createApp();
const adminTok = await authToken(app, 'deact-self@example.com', 'admin');
await authToken(app, 'deact-self-other@example.com', 'admin'); // a second admin
const id = await userIdByEmail('deact-self@example.com');
const res = await app.request(`/api/admin/users/${id}/deactivate`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(res.status).toBe(400);
expect((await res.json()).error).toContain('deactiveren');
});
// Deactivating another admin is allowed (the caller remains); the caller can never deactivate
// themselves even as the sole remaining active admin — together these preserve the invariant.
it('allows deactivating another admin but always blocks self-deactivate', async () => {
const app = createApp();
const adminTok = await authToken(app, 'deact-last-admin@example.com', 'admin');
await authToken(app, 'deact-peer@example.com', 'admin');
const peerId = await userIdByEmail('deact-peer@example.com');
const res = await app.request(`/api/admin/users/${peerId}/deactivate`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(res.status).toBe(200); // caller still admin → allowed
// The caller is now the only active admin; deactivating self is refused.
const callerId = await userIdByEmail('deact-last-admin@example.com');
const selfRes = await app.request(`/api/admin/users/${callerId}/deactivate`, {
method: 'POST',
headers: bearer(adminTok),
});
expect(selfRes.status).toBe(400);
});
it('404s on an unknown id and 403s for a worker', async () => {
const app = createApp();
const adminTok = await authToken(app, 'deact-404-admin@example.com', 'admin');
expect(
(
await app.request('/api/admin/users/nope/deactivate', {
method: 'POST',
headers: bearer(adminTok),
})
).status,
).toBe(404);
const workerTok = await authToken(app, 'deact-worker@example.com');
const id = await userIdByEmail('deact-worker@example.com');
expect(
(
await app.request(`/api/admin/users/${id}/deactivate`, {
method: 'POST',
headers: bearer(workerTok),
})
).status,
).toBe(403);
});
});
describe('POST /api/admin/users/:id/password', () => {
async function signIn(app: Hono, email: string, password: string): Promise<number> {
const res = await app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
});
return res.status;
}
it('sets a new password: new works, old fails', async () => {
const app = createApp();
const adminTok = await authToken(app, 'pw-admin@example.com', 'admin');
await app.request('/api/admin/users', {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({
email: 'pw-target@example.com',
name: 'Pw',
password: 'old-wachtwoord-123',
role: 'worker',
}),
});
const id = await userIdByEmail('pw-target@example.com');
const res = await app.request(`/api/admin/users/${id}/password`, {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ password: 'new-wachtwoord-456' }),
});
expect(res.status).toBe(200);
expect(await signIn(app, 'pw-target@example.com', 'new-wachtwoord-456')).toBe(200);
expect(await signIn(app, 'pw-target@example.com', 'old-wachtwoord-123')).not.toBe(200);
});
it('rejects a too-short password with 400 and 403s for a worker', async () => {
const app = createApp();
const adminTok = await authToken(app, 'pw-short-admin@example.com', 'admin');
await authToken(app, 'pw-worker@example.com');
const id = await userIdByEmail('pw-worker@example.com');
expect(
(
await app.request(`/api/admin/users/${id}/password`, {
method: 'POST',
headers: bearer(adminTok),
body: JSON.stringify({ password: 'short' }),
})
).status,
).toBe(400);
const workerTok = await authToken(app, 'pw-worker2@example.com');
expect(
(
await app.request(`/api/admin/users/${id}/password`, {
method: 'POST',
headers: bearer(workerTok),
body: JSON.stringify({ password: 'long-enough-123' }),
})
).status,
).toBe(403);
});
});

View File

@@ -193,8 +193,14 @@ Each phase keeps the system working and is its own spec → plan → build cycle
worker/type/activity filters, headline totals and breakdowns per worker/activity/type) backed by worker/type/activity filters, headline totals and breakdowns per worker/activity/type) backed by
`GET /api/admin/report` (completed-only, JS aggregation), plus an all-users filtered CSV via `GET /api/admin/report` (completed-only, JS aggregation), plus an all-users filtered CSV via
`GET /api/admin/export` — a shared `buildSessionsCsv(rows, {includeWorker})` now serves both the `GET /api/admin/export` — a shared `buildSessionsCsv(rows, {includeWorker})` now serves both the
worker self-export and the admin export. **3b remaining:** user management worker self-export and the admin export.
(better-auth `/api/auth/admin/*`). **3b·3 implemented** (plan `docs/superpowers/plans/2026-06-24-phase-3b3-user-management.md`):
the admin **Gebruikers** screen — list users (role + active/inactive status), create, set role,
reset password, deactivate/reactivate — backed by a new `/api/admin/users*` router (a shared
`adminGuard` middleware; `role`/`banned` as direct column updates, create + reset-password via
better-auth). Server-side lockout guards (no self-deactivate, no self-demote, last-active-admin
invariant), `adminGuard` also rejects banned admins, and deactivate revokes the user's sessions.
**Phase 3b and the Phase 3 admin panel are complete.**
Activity management (add/edit/delete handelingen + their `insole_types`) was removed from the Activity management (add/edit/delete handelingen + their `insole_types`) was removed from the
worker client in the Phase 2 follow-up because it is admin-only; it must be **ported here**. The worker client in the Phase 2 follow-up because it is admin-only; it must be **ported here**. The
backend already exists (`/api/activities` writes are admin-gated; `useActivities`/the legacy backend already exists (`/api/activities` writes are admin-gated; `useActivities`/the legacy

View File

@@ -0,0 +1,94 @@
# Session: 2026-06-24 — Phase 3b·3 (User management — Gebruikers)
## Goal
Let an admin manage workplace logins: list users with role + status, create a user, change a
user's role, reset a forgotten password, and deactivate/reactivate an account — without ever losing
production history, and with server-side guards that make admin lockout impossible. Third and final
Phase 3b cycle; completes the Phase 3 admin panel.
Spec: `docs/superpowers/specs/2026-06-24-phase-3b3-user-management-design.md`;
plan: `docs/superpowers/plans/2026-06-24-phase-3b3-user-management.md`.
Tracked as Plane epic **SL-54** with tasks **SL-55…SL-60**.
## Scope decisions (brainstorming)
- **Operations:** create, set role (worker ↔ admin), reset password, deactivate/reactivate.
**No hard delete** — the `work_sessions` FK cascades, so deletion would wipe logged history;
deactivation (better-auth `banned`) keeps the data and blocks sign-in.
- **Lockout guards (server-side):** no self-deactivate, no self-demote, last-active-admin invariant.
- **Password floor:** 8 chars (better-auth default) on create + reset. No email reset flow (no mailer).
- **No DB migration** — `role`, `banned`, `banReason`, `banExpires` already exist on `user`.
## Work done
Built via a 6-task TDD **Workflow** (`wptwrno4a`, ultracode) — one commit per task, sequential
(dependent, shared tree) — followed by a two-lens adversarial security review, then a hardening pass:
- **Task 1 — adminGuard + contracts** (`a32406d`). Extracted the inline `/api/admin/*` gate into a
reusable `adminGuard` middleware in `lib/require-user.ts` (used by both admin routers). Added
`UserStatus`, `status` on `AdminUser`, `CreateUserInput`, `SetRoleInput`, `SetPasswordInput` to
`@solelog/shared`.
- **Task 2 — admin-users router: list + create** (`2b4b633`). New `routes/admin-users.ts`. Enriched
`GET /api/admin/users` (id/email/name/role/status/created_at), **moved out of `admin.ts`** so the
path is defined once; the report/session pickers keep working (they read id/name). `POST
/api/admin/users` via `auth.api.createUser` (hashing), duplicate email → 409. Mounted in `app.ts`.
- **Task 3 — set role + guards** (`88b7773`). `POST /:id/role` as a direct column update, with the
`activeAdminsExcluding` helper, self-demote guard, and last-admin guard.
- **Task 4 — deactivate/reactivate** (`40977f8`). `POST /:id/deactivate` sets `banned=true` **and
deletes the user's `session` rows** (kills live bearer tokens); `POST /:id/reactivate` clears it.
Self-deactivate + last-admin guards.
- **Task 5 — reset password** (`4b80898`). `POST /:id/password` via `auth.api.setUserPassword`
(shape verified against the installed better-auth types: `{ userId, newPassword }`).
- **Task 6 — Gebruikers UI** (`0f42960`). `api/users.ts` (`useUsers` + 5 mutations), `UserForm.tsx`
(create), `Users.tsx` (list with role/status pills, own-row "jij" badge hiding role/deactivate,
row actions for role toggle / inline password reset / deactivate-reactivate). Sidebar moves
Gebruikers into nav and drops the now-empty "Binnenkort" block; `/gebruikers` route added.
## Adversarial security review (two lenses, read-only)
Both lenses returned **no exploitable holes**: `lastAdminProtected`, `gatingConfirmed`,
`deactivateBlocksSignin` all true. Key confirmations: no reachable sequence reaches zero active
admins (the caller is always an active admin ≠ target, so `activeAdminsExcluding(target) ≥ 1`, and
the self-guards stop the caller removing their own admin/active status); deactivate genuinely blocks
sign-in (better-auth throws `BANNED_USER` in `session.create.before`, `banExpires=null` prevents
auto-unban) **and** the session-row deletion is the load-bearing half that revokes live tokens
(core session validation does not re-check `banned`); every `/api/admin/users*` route is gated.
It surfaced three real, fixable issues, addressed in a hardening pass (`b9aa24c`):
1. **Test quality** — the two "last active admin" tests asserted `200`/the self-guard, never the
last-admin branch (which is unreachable over HTTP). Renamed them honestly and added a **direct
unit test of `activeAdminsExcluding`** (covers the `!banned` filter + exclusion).
2. **500 on mixed-case create** — create re-selected by original-case email, but better-auth
lowercases on store → `toListItem(undefined)`. Now re-selects on `email.toLowerCase()`; added a
mixed-case create test.
3. **Defense-in-depth**`adminGuard` checked role only. It now also **rejects banned admins**
(so a banned admin holding a live token is refused even if their session somehow survives);
added a banned-admin-guard test.
## Verification (independent of the workflow's self-reports)
- `git log` — six task commits `a32406d → 0f42960` + hardening `b9aa24c`; **clean tree**.
- `yarn workspace @solelog/api test`**103 passed** (15 files), incl. `admin-users` and the
regression `admin`/`report`/`export`/`csv` suites; `typecheck` clean.
- `yarn workspace @solelog/admin test`**50 passed** (11 files), incl. `Users`; `typecheck`
clean; `build` succeeds (vite, 94 modules).
- `npx oxlint` — clean (exit 0).
## Outcome
Phase 3b·3 is implemented, hardened, and green across both touched workspaces. An admin can list
users with role + status, create workers/admins (who can then sign in), flip roles, reset passwords
(new works / old fails), and deactivate/reactivate accounts — with sign-in genuinely blocked on
deactivation and live tokens revoked, and with lockout made impossible by the self/last-admin guards
plus the banned-admin gate. No DB migration; no hard delete. Plane SL-54 + SL-55…SL-60 all Done.
**Phase 3b and the Phase 3 admin panel are complete.**
`origin/main` was advanced by the maintainer between cycles; after this cycle `main` is **9 ahead**
(the whole 3b·3 batch). A single `git push origin main` from the maintainer's terminal ships it
(and triggers Gitea CI).
## Next
Phase 3 is done. Per the roadmap, **Phase 4 — Workbench scanning** (QR at the bench → pre-fill the
session, with manual fallback) and **Phase 5 — Polish & deploy** remain.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,165 @@
# Phase 3b·3 — User Management (Gebruikers) — Design
- **Created:** 2026-06-24
- **Status:** Approved (brainstorming) — ready for implementation plan
- **Tracker:** Plane (workspace `solelog`, project SoleLog)
- **Cycle:** Third and final Phase 3b cycle (completes the Phase 3 admin panel)
- **Touches:** `packages/shared`, `apps/api`, `apps/admin`
## Goal
An admin can manage the workplace logins: see every user with role + status, create a user,
change a user's role, reset a forgotten password, and deactivate/reactivate an account — without
ever losing production history. No self-service email reset exists (no mailer), so the admin
setting a password is the only recovery path.
_Done when:_ an admin can list users (with role + active/inactive status), create a worker or admin
who can then sign in, flip a user's role, reset a user's password (new works, old fails),
deactivate an account (sign-in blocked) and reactivate it — while the API refuses any action that
would lock out the admins (self-deactivate, self-demote, or removing the last active admin).
## Scope decisions (confirmed during brainstorming, 2026-06-24)
1. **Operations:** create, set role (worker ↔ admin), reset password, deactivate/reactivate.
**No hard delete** — the `work_sessions` FK cascades, so deleting a user would wipe their logged
history. Deactivation (better-auth `banned`) keeps the data and blocks sign-in.
2. **Lockout guards (server-side):** no self-deactivate, no self-demote, and a last-active-admin
invariant — any demote/deactivate that would leave zero active admins is refused (400 + Dutch
message).
3. **Password floor:** better-auth's default minimum of **8** characters for create + reset.
## A. Backend
### Small enabling refactor
Extract the inline admin gate (currently `adminRoutes.use('/api/admin/*', …)` in `routes/admin.ts`)
into a reusable **`adminGuard`** middleware in `lib/require-user.ts`:
```
export const adminGuard: MiddlewareHandler — 401 if no session, 403 if not admin, else next().
```
`admin.ts` swaps its inline guard for `adminRoutes.use('/api/admin/*', adminGuard)` (existing admin
tests cover it). The new user router uses the same middleware.
### New `routes/admin-users.ts` (own Hono router, mounted in `app.ts`, gated by `adminGuard`)
User management is a distinct responsibility and `admin.ts` already carries sessions + report +
export, so it gets its own file.
- **`GET /api/admin/users`** — **moved here from `admin.ts`** and enriched. Direct DB read of
`user`, ordered by name, returning `{ id, email, name, role, status, created_at }` where
`status` is `'inactive'` when `banned` is truthy else `'active'`, and `role` defaults to
`'worker'` when null. The report/session pickers read only `id`/`name`, so they keep working.
- **`POST /api/admin/users`** — create. Body `CreateUserInput { email, name, password, role }`.
Calls `auth.api.createUser({ body: { email, password, name, role } })` (hashes the password,
generates the id + account row; works despite `disableSignUp`, as `seed.ts` already relies on).
A duplicate email throws → mapped to **409** (`{ error: 'E-mailadres bestaat al.' }`). Returns the
created user in the list shape.
- **`POST /api/admin/users/:id/role`** — body `SetRoleInput { role }`. Plain column update
`db.update(user).set({ role }).where(eq(user.id, id))` (role is a `user` column; no better-auth
call needed). **Guards** (below) run first. Returns the updated list item.
- **`POST /api/admin/users/:id/password`** — body `SetPasswordInput { password }`. Calls
`auth.api.setUserPassword({ body: { userId: id, newPassword: password }, headers: <forwarded> })`
(needs better-auth hashing). 404 if the user does not exist.
- **`POST /api/admin/users/:id/deactivate`** — plain update
`db.update(user).set({ banned: true, banReason: null, banExpires: null })` **and**
`db.delete(session).where(eq(session.userId, id))` so any live bearer token dies at the next
`getSession`. The admin plugin already blocks banned users at sign-in. **Guards** run first.
- **`POST /api/admin/users/:id/reactivate`** — plain update
`db.update(user).set({ banned: false, banReason: null, banExpires: null })`.
All write routes 404 on an unknown `:id`. The whole surface is behind `adminGuard` → 401 (no
session) / 403 (non-admin).
### Guards (helper in `admin-users.ts`, run before role/deactivate mutations)
Given the acting admin (`caller = await getSessionUser(c)`) and the target `id`:
- **Self-deactivate:** deactivate where `id === caller.id` → 400 `"Je kunt jezelf niet deactiveren."`
- **Self-demote:** role change to `'worker'` where `id === caller.id` → 400
`"Je kunt jezelf niet degraderen."`
- **Last active admin:** for a demote (target currently admin → worker) or a deactivate of an admin,
count active admins (`role = 'admin' AND (banned IS NULL OR banned = 0)`) **excluding the target**;
if that count is 0 → 400 `"Er moet minstens één actieve beheerder blijven."`
Because the caller is always an active admin, the self-guards already guarantee ≥1 admin remains;
the last-admin check is explicit defense-in-depth and documents the invariant.
### Why direct-DB for role/status (not `auth.api`)
`role` and `banned` are ordinary `user` columns; updating them directly is version-proof and
trivially testable in-process. Only **create** and **reset-password** need better-auth (password
hashing), so only those go through `auth.api.*`. The exact `auth.api` method names + param keys are
verified against the installed better-auth version during implementation.
## B. Shared contracts (`@solelog/shared`)
- `UserStatus = z.enum(['active', 'inactive'])`.
- The user list shape gains `status: UserStatus` (extend the existing `AdminUser` schema, which is
`id/email/name/role/created_at`, with `status`; if `AdminUser` is unused elsewhere, repurpose it as
the list item — verified during planning).
- `CreateUserInput = { email: string().email(), name: string().trim().min(1),
password: string().min(8), role: Role }`.
- `SetRoleInput = { role: Role }`.
- `SetPasswordInput = { password: string().min(8) }`.
No DB migration — `role`, `banned`, `banReason`, `banExpires` already exist on `user`.
## C. Admin UI (`apps/admin`)
- **`components/Sidebar.tsx`** — move `'Gebruikers'` into `navItems`
(`{ to: '/gebruikers', label: 'Gebruikers' }`); `soonItems` is now empty, so **remove the
"Binnenkort" block** entirely.
- **`App.tsx`** — add `<Route path="/gebruikers" element={<Users />} />`.
- **`screens/Users.tsx`** (Gebruikers): a row per user from `useUsers` — name, email, **role pill**
(Beheerder/Werker), **status pill** (Actief/Inactief), created date. `+ Nieuwe gebruiker` opens the
create form. Per-row actions: **Maak admin / Maak werker** (role toggle), **Reset wachtwoord**
(reveals an inline password input + Opslaan), **Deactiveer / Heractiveer**. The signed-in admin's
**own row** (matched via `useMe`) shows a "jij" badge with the role + deactivate actions hidden
(password reset on self is allowed).
- **`components/UserForm.tsx`** — the create form: name, email, password, role `<select>`, with
inline validation mirroring the API (email format, password ≥ 8). Submits `CreateUserInput`.
- **`api/users.ts`** — `useUsers` (`['admin','users']`, list shape) + mutations `useCreateUser`,
`useSetUserRole`, `useResetUserPassword`, `useDeactivateUser`, `useReactivateUser` (all invalidate
`['admin','users']`). The existing minimal `useAdminUsers` (in `admin-sessions.ts`) stays for the
pickers.
## Error handling
- Duplicate email on create → 409 → form shows "E-mailadres bestaat al."
- Guard violations → 400 with the Dutch messages above → surfaced inline near the row/action.
- Short password → 400 → inline on the password field.
- Any better-auth error is caught and returned as a JSON `{ error }` (never a 500 stack).
## Testing
- **API** (`admin-users.test.ts`, in-process via `createApp()` + `app.request`):
- `GET` returns role + status; worker token → 403.
- create → the new user can sign in with the given password; duplicate email → 409; password < 8 →
400; created user appears with the right role/status.
- set role worker→admin and admin→worker (plain rows, verified in DB/readback).
- reset password → sign-in with the new password succeeds and the old one fails.
- deactivate → sign-in is blocked; the user's sessions are gone; reactivate → sign-in works again.
- guards: self-deactivate, self-demote, and last-active-admin demote/deactivate each → 400 with the
expected message; the action did not take effect.
- **Admin** (vitest + Testing Library):
- Users renders a row per user with role + status pills.
- `+ Nieuwe gebruiker` → create form posts `CreateUserInput`.
- row actions (Maak admin, Reset wachtwoord, Deactiveer) call the right mutations with the right id.
- the signed-in admin's own row hides the role/deactivate actions.
## Adversarial verification (ultracode)
After the build, a dedicated review agent independently scrutinizes the **lockout guards** and the
**create/ban auth wiring**: it tries to construct a sequence that reaches zero active admins, checks
that deactivate truly blocks sign-in (not just hides the UI), confirms the worker-token 403 gating on
every route, and verifies the tests actually exercise these paths rather than asserting trivially.
Findings are reported (not auto-applied); real holes get a follow-up fix task.
## Out of scope
- Hard delete; email-based password reset (no mailer); session/impersonation management beyond the
deactivate-revokes-sessions behavior; bulk operations; editing a user's email/name after creation.
## Build approach
spec → `writing-plans` → one **Workflow** (~6 TDD tasks, sequential — dependent, shared tree —
commit per task) + a final adversarial security-review stage. Tracked as a Plane epic. Completes
Phase 3b and the Phase 3 admin panel.

View File

@@ -78,15 +78,33 @@ export const StartSessionInput = z.object({
}); });
export type StartSessionInput = z.infer<typeof StartSessionInput>; export type StartSessionInput = z.infer<typeof StartSessionInput>;
export const UserStatus = z.enum(['active', 'inactive']);
export type UserStatus = z.infer<typeof UserStatus>;
export const AdminUser = z.object({ export const AdminUser = z.object({
id: z.string(), id: z.string(),
email: z.string().email(), email: z.string().email(),
name: z.string(), name: z.string(),
role: Role, role: Role,
status: UserStatus,
created_at: z.string(), created_at: z.string(),
}); });
export type AdminUser = z.infer<typeof AdminUser>; export type AdminUser = z.infer<typeof AdminUser>;
export const CreateUserInput = z.object({
email: z.string().email(),
name: z.string().trim().min(1),
password: z.string().min(8),
role: Role,
});
export type CreateUserInput = z.infer<typeof CreateUserInput>;
export const SetRoleInput = z.object({ role: Role });
export type SetRoleInput = z.infer<typeof SetRoleInput>;
export const SetPasswordInput = z.object({ password: z.string().min(8) });
export type SetPasswordInput = z.infer<typeof SetPasswordInput>;
export const CreateManualSessionInput = z.object({ export const CreateManualSessionInput = z.object({
user_id: z.string(), user_id: z.string(),
activity_id: z.number().int(), activity_id: z.number().int(),