From c36ad4cb2d48ce9796011ddbf7111ffd7fae3e72 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sun, 5 Jul 2026 22:18:20 +0200 Subject: [PATCH] feat(users): gestione utenti /admin/users con guardie self/ultimo-admin --- src/lib/auth.ts | 30 ++++++ src/pages/admin/users.astro | 95 +++++++++++++++++++ src/pages/api/admin/users/[id]/index.ts | 19 ++++ .../api/admin/users/[id]/reset-password.ts | 16 ++++ src/pages/api/admin/users/[id]/role.ts | 21 ++++ src/pages/api/admin/users/index.ts | 26 +++++ tests/users.test.ts | 49 ++++++++++ 7 files changed, 256 insertions(+) create mode 100644 src/pages/admin/users.astro create mode 100644 src/pages/api/admin/users/[id]/index.ts create mode 100644 src/pages/api/admin/users/[id]/reset-password.ts create mode 100644 src/pages/api/admin/users/[id]/role.ts create mode 100644 src/pages/api/admin/users/index.ts create mode 100644 tests/users.test.ts diff --git a/src/lib/auth.ts b/src/lib/auth.ts index a5146c3..ed02c90 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -78,6 +78,36 @@ export function listUsers(db: Database.Database): { id: number; username: string { id: number; username: string; role: string }[]; } +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'; +} + export function landingFor(role: string): string { if (role === 'superuser') return '/admin/content'; return '/admin'; diff --git a/src/pages/admin/users.astro b/src/pages/admin/users.astro new file mode 100644 index 0000000..e88c746 --- /dev/null +++ b/src/pages/admin/users.astro @@ -0,0 +1,95 @@ +--- +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

+
+ + + + + +
+ +

Elenco

+ + + + {users.map((u) => ( + + + + + + ))} + +
UtenteRuolo
{u.username}{u.id === me.id && ' (tu)'} + + + + {u.id !== me.id && } +
+

+
+ + diff --git a/src/pages/api/admin/users/[id]/index.ts b/src/pages/api/admin/users/[id]/index.ts new file mode 100644 index 0000000..f382e80 --- /dev/null +++ b/src/pages/api/admin/users/[id]/index.ts @@ -0,0 +1,19 @@ +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 }); +}; diff --git a/src/pages/api/admin/users/[id]/reset-password.ts b/src/pages/api/admin/users/[id]/reset-password.ts new file mode 100644 index 0000000..7101e88 --- /dev/null +++ b/src/pages/api/admin/users/[id]/reset-password.ts @@ -0,0 +1,16 @@ +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 }); +}; diff --git a/src/pages/api/admin/users/[id]/role.ts b/src/pages/api/admin/users/[id]/role.ts new file mode 100644 index 0000000..673447f --- /dev/null +++ b/src/pages/api/admin/users/[id]/role.ts @@ -0,0 +1,21 @@ +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 }); +}; diff --git a/src/pages/api/admin/users/index.ts b/src/pages/api/admin/users/index.ts new file mode 100644 index 0000000..307bbe4 --- /dev/null +++ b/src/pages/api/admin/users/index.ts @@ -0,0 +1,26 @@ +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; + } +}; diff --git a/tests/users.test.ts b/tests/users.test.ts new file mode 100644 index 0000000..e6a8990 --- /dev/null +++ b/tests/users.test.ts @@ -0,0 +1,49 @@ +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); + }); +});