feat(users): gestione utenti /admin/users con guardie self/ultimo-admin

This commit is contained in:
2026-07-05 22:18:20 +02:00
parent 0f2b2ba014
commit c36ad4cb2d
7 changed files with 256 additions and 0 deletions
+95
View File
@@ -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>
+19
View File
@@ -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 });
};
@@ -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 });
};
+21
View File
@@ -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 });
};
+26
View File
@@ -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;
}
};