b913a2bf90
Refs gitea:Adriano/AI-OS#310 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
328 lines
13 KiB
Diff
328 lines
13 KiB
Diff
# Review package: 0f2b2ba..c36ad4c
|
||
|
||
## Commits
|
||
c36ad4c feat(users): gestione utenti /admin/users con guardie self/ultimo-admin
|
||
|
||
## Files changed
|
||
src/lib/auth.ts | 30 ++++++++
|
||
src/pages/admin/users.astro | 95 ++++++++++++++++++++++++
|
||
src/pages/api/admin/users/[id]/index.ts | 19 +++++
|
||
src/pages/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(+)
|
||
|
||
## Diff
|
||
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
|
||
@@ -71,14 +71,44 @@ export function canAccessAdminPath(role: string, pathname: string): boolean {
|
||
export function getUsernameById(db: Database.Database, id: number): string | null {
|
||
const row = db.prepare('SELECT username FROM users WHERE id = ?').get(id) as { username: string } | undefined;
|
||
return row?.username ?? null;
|
||
}
|
||
|
||
export function listUsers(db: Database.Database): { id: number; username: string; role: string }[] {
|
||
return db.prepare('SELECT id, username, role FROM users ORDER BY username').all() as
|
||
{ 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!;
|
||
+---
|
||
+<Admin title="Utenti">
|
||
+ <h1>Utenti</h1>
|
||
+
|
||
+ <h2>Nuovo utente</h2>
|
||
+ <form id="new-user" style="max-width:420px">
|
||
+ <input class="afield" name="username" placeholder="Nome utente" required />
|
||
+ <input class="afield" name="password" type="text" placeholder="Password (min 8)" required />
|
||
+ <select class="afield" name="role">
|
||
+ <option value="user">user</option>
|
||
+ <option value="superuser">superuser</option>
|
||
+ <option value="admin">admin</option>
|
||
+ </select>
|
||
+ <button class="abtn" type="submit">Crea</button>
|
||
+ <span class="form-msg" id="new-msg"></span>
|
||
+ </form>
|
||
+
|
||
+ <h2 style="margin-top:30px">Elenco</h2>
|
||
+ <table>
|
||
+ <thead><tr><th>Utente</th><th>Ruolo</th><th></th></tr></thead>
|
||
+ <tbody>
|
||
+ {users.map((u) => (
|
||
+ <tr data-id={u.id}>
|
||
+ <td>{u.username}{u.id === me.id && ' (tu)'}</td>
|
||
+ <td>
|
||
+ <select class="role-sel" data-id={u.id}>
|
||
+ {['user', 'superuser', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
|
||
+ </select>
|
||
+ </td>
|
||
+ <td>
|
||
+ <button class="abtn" data-reset={u.id}>Reset password</button>
|
||
+ {u.id !== me.id && <button class="abtn abtn--danger" data-del={u.id}>Elimina</button>}
|
||
+ </td>
|
||
+ </tr>
|
||
+ ))}
|
||
+ </tbody>
|
||
+ </table>
|
||
+ <p class="form-msg" id="row-msg"></p>
|
||
+</Admin>
|
||
+
|
||
+<script>
|
||
+ const rowMsg = document.getElementById('row-msg')!;
|
||
+ const show = (el: HTMLElement, text: string, ok = false) => {
|
||
+ el.textContent = text;
|
||
+ el.className = 'form-msg ' + (ok ? 'form-msg--ok' : 'form-msg--err');
|
||
+ };
|
||
+
|
||
+ document.getElementById('new-user')!.addEventListener('submit', async (e) => {
|
||
+ e.preventDefault();
|
||
+ const f = e.target as HTMLFormElement;
|
||
+ const body = {
|
||
+ username: (f.elements.namedItem('username') as HTMLInputElement).value,
|
||
+ password: (f.elements.namedItem('password') as HTMLInputElement).value,
|
||
+ role: (f.elements.namedItem('role') as HTMLSelectElement).value,
|
||
+ };
|
||
+ const res = await fetch('/api/admin/users', {
|
||
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||
+ });
|
||
+ const msg = document.getElementById('new-msg') as HTMLElement;
|
||
+ if (res.ok) { show(msg, 'Creato ✓', true); setTimeout(() => location.reload(), 600); }
|
||
+ else show(msg, (await res.json()).error ?? 'Errore');
|
||
+ });
|
||
+
|
||
+ document.querySelectorAll<HTMLSelectElement>('.role-sel').forEach((sel) =>
|
||
+ sel.addEventListener('change', async () => {
|
||
+ const res = await fetch(`/api/admin/users/${sel.dataset.id}/role`, {
|
||
+ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: sel.value }),
|
||
+ });
|
||
+ if (res.ok) show(rowMsg, 'Ruolo aggiornato ✓', true);
|
||
+ else { show(rowMsg, (await res.json()).error ?? 'Errore'); location.reload(); }
|
||
+ }));
|
||
+
|
||
+ document.querySelectorAll<HTMLButtonElement>('[data-reset]').forEach((b) =>
|
||
+ b.addEventListener('click', async () => {
|
||
+ if (!confirm('Generare una nuova password per questo utente?')) return;
|
||
+ const res = await fetch(`/api/admin/users/${b.dataset.reset}/reset-password`, { method: 'POST' });
|
||
+ if (res.ok) { const { password } = await res.json(); prompt('Nuova password (copiala e consegnala ora):', password); }
|
||
+ else show(rowMsg, (await res.json()).error ?? 'Errore');
|
||
+ }));
|
||
+
|
||
+ document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
||
+ b.addEventListener('click', async () => {
|
||
+ if (!confirm('Eliminare definitivamente questo utente?')) return;
|
||
+ const res = await fetch(`/api/admin/users/${b.dataset.del}`, { method: 'DELETE' });
|
||
+ if (res.ok) location.reload();
|
||
+ else show(rowMsg, (await res.json()).error ?? 'Errore');
|
||
+ }));
|
||
+</script>
|
||
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);
|
||
+ });
|
||
+});
|