longevity: revisione finale - codice cliente non riusabile, ruoli allineati, middleware verificato
- creaCliente: il codice si deduce dal massimo fra identity E longevity (non piu' solo identity), cosi' un client_code cancellato dall'anagrafica non torna mai disponibile e non si attribuiscono le misure di un vecchio cliente a uno nuovo. Compensazione se la scrittura su longevity fallisce dopo quella su identity. - ROLES unica fonte in auth.ts (Role e isRole derivati); users.astro e users/new.astro usano quella lista invece di array scritti a mano che dimenticavano cliente/trainer. - Commento falso su cosa protegge il middleware, riscritto: rimanda a isProtectedPath. - vitest.config.ts: alias per astro:middleware (stesso bersaglio della pipeline vite di Astro), cosi' src/middleware.ts e' finalmente importabile e testabile in isolamento - prima nessun test lo caricava davvero. - export.test.ts: asserzione posizionale per colonna, non piu' solo intestazioni scritte a mano; intercetta un riordino della SELECT che disallinea etichette e valori. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+11
-4
@@ -5,7 +5,13 @@ import { randomBytes } from 'node:crypto';
|
||||
export const SESSION_COOKIE = 'session';
|
||||
const SESSION_DAYS = 7;
|
||||
|
||||
export type Role = 'admin' | 'superuser' | 'user' | 'piattaforme' | 'cliente' | 'trainer';
|
||||
// Unica fonte dei ruoli validi: da qui si derivano il tipo Role e la validazione isRole,
|
||||
// così aggiungere un ruolo in futuro è una modifica sola. Prima erano quattro punti
|
||||
// indipendenti - questo tipo, isRole, la tendina di admin/users.astro e create-user.mjs -
|
||||
// ed è già andato fuori sincrono due volte in questo stesso ramo (create-user.mjs resta
|
||||
// duplicato: è JavaScript, non può importare da qui, vedi il commento lì).
|
||||
export const ROLES = ['user', 'superuser', 'piattaforme', 'admin', 'cliente', 'trainer'] as const;
|
||||
export type Role = typeof ROLES[number];
|
||||
|
||||
export function hashPassword(plain: string): string {
|
||||
return bcrypt.hashSync(plain, 12);
|
||||
@@ -51,8 +57,9 @@ export function logout(db: Database.Database, token: string): void {
|
||||
|
||||
// Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola
|
||||
// che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a
|
||||
// qualsiasi loggato (il middleware protegge solo /admin e /api/admin, quindi qui arrivano solo
|
||||
// utenti già autenticati).
|
||||
// qualsiasi loggato. L'elenco vero di cosa il middleware protegge (non solo /admin e
|
||||
// /api/admin: anche /campus, /piattaforme e /longevity) vive in isProtectedPath, più sotto:
|
||||
// qui arrivano solo utenti già autenticati su una di quelle rotte.
|
||||
const RULES: [RegExp, Role[]][] = [
|
||||
[/^\/admin\/users(\/|$)/, ['admin']],
|
||||
[/^\/api\/admin\/users(\/|$)/, ['admin']],
|
||||
@@ -118,7 +125,7 @@ export function randomPassword(): string {
|
||||
}
|
||||
|
||||
export function isRole(v: unknown): v is Role {
|
||||
return v === 'admin' || v === 'superuser' || v === 'user' || v === 'piattaforme' || v === 'cliente' || v === 'trainer';
|
||||
return (ROLES as readonly unknown[]).includes(v);
|
||||
}
|
||||
|
||||
export function landingFor(role: string): string {
|
||||
|
||||
@@ -5,6 +5,28 @@
|
||||
// la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Il prossimo codice libero: il massimo fra quelli già usati in identity.clienti E in
|
||||
* longevity.soggetti. Dedurlo da una sola metà è il bug che permette di riassegnare il
|
||||
* codice di un cliente cancellato dall'anagrafica (le sue misure restano in longevity)
|
||||
* a una persona nuova: qui non torna mai disponibile.
|
||||
*/
|
||||
function prossimoCodice(identity: Database.Database, longevity: Database.Database): string {
|
||||
const numero = (code: string) => Number(code.slice(4));
|
||||
const ultimoIdentity = identity.prepare(
|
||||
`SELECT client_code FROM clienti ORDER BY client_code DESC LIMIT 1`
|
||||
).get() as { client_code: string } | undefined;
|
||||
const ultimoLongevity = longevity.prepare(
|
||||
`SELECT client_code FROM soggetti ORDER BY client_code DESC LIMIT 1`
|
||||
).get() as { client_code: string } | undefined;
|
||||
|
||||
const max = Math.max(
|
||||
ultimoIdentity ? numero(ultimoIdentity.client_code) : 0,
|
||||
ultimoLongevity ? numero(ultimoLongevity.client_code) : 0,
|
||||
);
|
||||
return `ISL-${String(max + 1).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
export function creaCliente(
|
||||
identity: Database.Database,
|
||||
longevity: Database.Database,
|
||||
@@ -13,11 +35,7 @@ export function creaCliente(
|
||||
data_nascita?: string; email?: string; telefono?: string; user_id?: number;
|
||||
}
|
||||
): string {
|
||||
const ultimo = identity.prepare(
|
||||
`SELECT client_code FROM clienti ORDER BY client_code DESC LIMIT 1`
|
||||
).get() as { client_code: string } | undefined;
|
||||
const n = ultimo ? Number(ultimo.client_code.slice(4)) + 1 : 1;
|
||||
const code = `ISL-${String(n).padStart(4, '0')}`;
|
||||
const code = prossimoCodice(identity, longevity);
|
||||
|
||||
identity.prepare(
|
||||
`INSERT INTO clienti (client_code, user_id, nome, cognome, data_nascita, email, telefono)
|
||||
@@ -25,7 +43,15 @@ export function creaCliente(
|
||||
).run(code, dati.user_id ?? null, dati.nome, dati.cognome,
|
||||
dati.data_nascita ?? null, dati.email ?? null, dati.telefono ?? null);
|
||||
|
||||
longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso);
|
||||
try {
|
||||
longevity.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(code, dati.sesso);
|
||||
} catch (err) {
|
||||
// Compensazione: due database distinti, quindi nessuna transazione unica possibile.
|
||||
// Se longevity rifiuta il cliente non deve restarne uno orfano in identity, senza
|
||||
// corrispettivo nell'altro database.
|
||||
identity.prepare(`DELETE FROM clienti WHERE client_code = ?`).run(code);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { listUsers } from '../../lib/auth';
|
||||
import { listUsers, ROLES } from '../../lib/auth';
|
||||
export const prerender = false;
|
||||
const users = listUsers(getDb());
|
||||
const me = Astro.locals.user!;
|
||||
@@ -45,7 +45,7 @@ const me = Astro.locals.user!;
|
||||
<td>{u.username}{u.id === me.id && ' (tu)'}</td>
|
||||
<td>
|
||||
<select class="role-sel arole" data-id={u.id} data-role={u.role} aria-label={`Ruolo di ${u.username}`}>
|
||||
{['user', 'superuser', 'piattaforme', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
|
||||
{ROLES.map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td class="uactions">
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
// Creazione di un utente, su pagina propria come per gli articoli: l'elenco resta pulito e
|
||||
// il modulo ha spazio per le sue spiegazioni.
|
||||
import Admin from '../../../layouts/Admin.astro';
|
||||
import { ROLES } from '../../../lib/auth';
|
||||
export const prerender = false;
|
||||
|
||||
// Descrizioni solo per la UI: se un ruolo nuovo non è qui, compare comunque nella tendina
|
||||
// (fonte di validità unica: ROLES), semplicemente senza spiegazione accanto.
|
||||
const DESCRIZIONI: Partial<Record<(typeof ROLES)[number], string>> = {
|
||||
user: 'scrive i propri articoli',
|
||||
superuser: 'articoli e contenuti del sito',
|
||||
piattaforme: 'solo Campus e Stress Index',
|
||||
admin: 'accesso completo',
|
||||
cliente: 'solo il proprio fascicolo Longevity',
|
||||
trainer: 'gestionale Longevity',
|
||||
};
|
||||
---
|
||||
<Admin title="Nuovo utente">
|
||||
<h1>Nuovo utente</h1>
|
||||
@@ -17,10 +29,7 @@ export const prerender = false;
|
||||
|
||||
<label class="alabel" for="role">Ruolo</label>
|
||||
<select class="afield" id="role" name="role">
|
||||
<option value="user">user — scrive i propri articoli</option>
|
||||
<option value="superuser">superuser — articoli e contenuti del sito</option>
|
||||
<option value="piattaforme">piattaforme — solo Campus e Stress Index</option>
|
||||
<option value="admin">admin — accesso completo</option>
|
||||
{ROLES.map((r) => <option value={r}>{r}{DESCRIZIONI[r] ? ` — ${DESCRIZIONI[r]}` : ''}</option>)}
|
||||
</select>
|
||||
|
||||
<div class="aactions">
|
||||
|
||||
Reference in New Issue
Block a user