Files
InsanityLab-web/.superpowers/sdd/task-13-brief.md
T
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

349 lines
13 KiB
Markdown
Raw 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.
### 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!;
---
<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>
```
- [ ] **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"
```
---