Files
InsanityLab-web/.superpowers/sdd/review-0f2b2ba..c36ad4c.diff
Adriano b913a2bf90 registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:36:26 +02:00

328 lines
13 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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);
+ });
+});