### Task 13: Gestione utenti — pagina e API **Files:** - Modify: `src/lib/auth.ts` (helper gestione) - Create: `src/pages/api/admin/users/index.ts` - Create: `src/pages/api/admin/users/[id]/role.ts` - Create: `src/pages/api/admin/users/[id]/reset-password.ts` - Create: `src/pages/api/admin/users/[id]/index.ts` - Create: `src/pages/admin/users.astro` - Test: `tests/users.test.ts` **Interfaces:** - Consumes: `createUser`, `hashPassword`, `listUsers` (auth.ts), `Astro.locals.user`. - Produces in `auth.ts`: - `countAdmins(db): number` - `getUserById(db, id): { id: number; username: string; role: string } | null` - `updateUserRole(db, id, role: Role): void` - `updateUserPassword(db, id, hash: string): void` - `deleteUser(db, id): void` - `randomPassword(): string` - `isRole(v: unknown): v is Role` - [ ] **Step 1: Test degli helper gestione** Crea `tests/users.test.ts`: ```typescript import { describe, it, expect, beforeEach } from 'vitest'; import type Database from 'better-sqlite3'; import { createDb } from '../src/lib/db'; import { createUser, countAdmins, getUserById, updateUserRole, updateUserPassword, deleteUser, randomPassword, isRole, hashPassword, verifyPassword, } from '../src/lib/auth'; let db: Database.Database; beforeEach(() => { db = createDb(':memory:'); }); describe('gestione utenti', () => { it('countAdmins conta i soli admin', () => { createUser(db, 'a1', 'segreta123', 'admin'); createUser(db, 's1', 'segreta123', 'superuser'); expect(countAdmins(db)).toBe(1); }); it('updateUserRole e getUserById', () => { const id = createUser(db, 'u1', 'segreta123', 'user'); updateUserRole(db, id, 'superuser'); expect(getUserById(db, id)!.role).toBe('superuser'); }); it('updateUserPassword cambia l’hash', () => { const id = createUser(db, 'u2', 'vecchia123', 'user'); updateUserPassword(db, id, hashPassword('nuova12345')); const hash = (db.prepare('SELECT password_hash AS h FROM users WHERE id = ?').get(id) as { h: string }).h; expect(verifyPassword('nuova12345', hash)).toBe(true); }); it('deleteUser rimuove', () => { const id = createUser(db, 'u3', 'segreta123', 'user'); deleteUser(db, id); expect(getUserById(db, id)).toBeNull(); }); it('randomPassword genera almeno 12 caratteri', () => { expect(randomPassword().length).toBeGreaterThanOrEqual(12); }); it('isRole valida i ruoli', () => { expect(isRole('admin')).toBe(true); expect(isRole('superuser')).toBe(true); expect(isRole('user')).toBe(true); expect(isRole('editor')).toBe(false); expect(isRole(3)).toBe(false); }); }); ``` - [ ] **Step 2: Esegui e verifica il fallimento** Run: `npm test -- tests/users.test.ts` Expected: FAIL — helper non esistono. - [ ] **Step 3: Aggiungi gli helper in `auth.ts`** ```typescript export function countAdmins(db: Database.Database): number { return (db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get() as { n: number }).n; } export function getUserById(db: Database.Database, id: number): { id: number; username: string; role: string } | null { const row = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(id) as { id: number; username: string; role: string } | undefined; return row ?? null; } export function updateUserRole(db: Database.Database, id: number, role: Role): void { db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id); } export function updateUserPassword(db: Database.Database, id: number, hash: string): void { db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, id); } export function deleteUser(db: Database.Database, id: number): void { db.prepare('DELETE FROM users WHERE id = ?').run(id); } export function randomPassword(): string { return randomBytes(9).toString('base64url'); // ~12 caratteri } export function isRole(v: unknown): v is Role { return v === 'admin' || v === 'superuser' || v === 'user'; } ``` - [ ] **Step 4: Esegui e verifica il successo** Run: `npm test -- tests/users.test.ts` Expected: PASS. - [ ] **Step 5: Crea l'API di creazione/lista** Crea `src/pages/api/admin/users/index.ts`: ```typescript import type { APIRoute } from 'astro'; import { getDb } from '../../../../lib/db'; import { createUser, listUsers, isRole } from '../../../../lib/auth'; export const prerender = false; const json = (status: number, body: object) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); export const GET: APIRoute = () => json(200, { users: listUsers(getDb()) }); export const POST: APIRoute = async ({ request }) => { let data: { username?: unknown; password?: unknown; role?: unknown }; try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } const username = String(data.username ?? '').trim(); const password = String(data.password ?? ''); if (username.length < 2) return json(400, { error: 'Nome utente troppo corto.' }); if (password.length < 8) return json(400, { error: 'Password troppo corta (min 8).' }); if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' }); try { const id = createUser(getDb(), username, password, data.role); return json(201, { id }); } catch (err: any) { if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Nome utente già esistente.' }); throw err; } }; ``` - [ ] **Step 6: Crea l'API cambio ruolo (con guardia ultimo admin)** Crea `src/pages/api/admin/users/[id]/role.ts`: ```typescript import type { APIRoute } from 'astro'; import { getDb } from '../../../../../lib/db'; import { getUserById, updateUserRole, countAdmins, isRole } from '../../../../../lib/auth'; export const prerender = false; const json = (status: number, body: object) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); export const PUT: APIRoute = async ({ params, request }) => { const id = Number(params.id); const target = getUserById(getDb(), id); if (!target) return json(404, { error: 'Utente inesistente.' }); let data: { role?: unknown }; try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' }); if (target.role === 'admin' && data.role !== 'admin' && countAdmins(getDb()) <= 1) { return json(400, { error: 'Deve restare almeno un admin.' }); } updateUserRole(getDb(), id, data.role); return json(200, { ok: true }); }; ``` - [ ] **Step 7: Crea l'API reset password** Crea `src/pages/api/admin/users/[id]/reset-password.ts`: ```typescript import type { APIRoute } from 'astro'; import { getDb } from '../../../../../lib/db'; import { getUserById, updateUserPassword, hashPassword, randomPassword } from '../../../../../lib/auth'; export const prerender = false; const json = (status: number, body: object) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); export const POST: APIRoute = ({ params }) => { const id = Number(params.id); if (!getUserById(getDb(), id)) return json(404, { error: 'Utente inesistente.' }); const password = randomPassword(); updateUserPassword(getDb(), id, hashPassword(password)); // La password in chiaro è restituita UNA sola volta: l'admin la copia e la consegna. return json(200, { password }); }; ``` - [ ] **Step 8: Crea l'API elimina (guardie self + ultimo admin)** Crea `src/pages/api/admin/users/[id]/index.ts`: ```typescript import type { APIRoute } from 'astro'; import { getDb } from '../../../../../lib/db'; import { getUserById, deleteUser, countAdmins } from '../../../../../lib/auth'; export const prerender = false; const json = (status: number, body: object) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); export const DELETE: APIRoute = ({ params, locals }) => { const id = Number(params.id); const target = getUserById(getDb(), id); if (!target) return json(404, { error: 'Utente inesistente.' }); if (id === locals.user!.id) return json(400, { error: 'Non puoi eliminare te stesso.' }); if (target.role === 'admin' && countAdmins(getDb()) <= 1) { return json(400, { error: 'Deve restare almeno un admin.' }); } deleteUser(getDb(), id); return json(200, { ok: true }); }; ``` - [ ] **Step 9: Crea la pagina `/admin/users`** Crea `src/pages/admin/users.astro`: ```astro --- import Admin from '../../layouts/Admin.astro'; import { getDb } from '../../lib/db'; import { listUsers } from '../../lib/auth'; export const prerender = false; const users = listUsers(getDb()); const me = Astro.locals.user!; --- Utenti Nuovo utente user superuser admin Crea Elenco UtenteRuolo {users.map((u) => ( {u.username}{u.id === me.id && ' (tu)'} {['user', 'superuser', 'admin'].map((r) => {r})} Reset password {u.id !== me.id && Elimina} ))} ``` - [ ] **Step 10: Verifica build + test** Run: `npm run build && npm test` Expected: build OK, tutti i test verdi. - [ ] **Step 11: Commit** ```bash git add src/lib/auth.ts src/pages/api/admin/users src/pages/admin/users.astro tests/users.test.ts git commit -m "feat(users): gestione utenti /admin/users con guardie self/ultimo-admin" ``` ---