12 KiB
Utenti, ruoli, login pubblico, GUID contenuti e modifica inline — Design
Data: 2026-07-05 Progetto: sito InsanityLab (Astro 7 SSR, SQLite/better-sqlite3, live su https://insanitylab.tielogic.xyz)
Obiettivo
Portare il sito da due ruoli (admin | editor) a un sistema a tre ruoli con login pubblico,
identità utente visibile nell'header, blog con ownership per autore, un identificatore GUID stabile
per ogni contenuto taggato, e la modifica dei contenuti direttamente in pagina tramite un overlay
attivabile con toggle. Nessuna dipendenza frontend nuova: tutto vanilla, in continuità con l'esistente.
Ruoli e matrice permessi
Tre ruoli su users.role: admin, superuser, user. Il vecchio ruolo editor viene migrato a
superuser.
| Capacità | user | superuser | admin |
|---|---|---|---|
| Login + nome nell'header + logout | ✓ | ✓ | ✓ |
| Blog: CRUD solo dei propri articoli | ✓ | ✓ | ✓ |
| Blog: gestione articoli di tutti gli autori | ✓ | ✓ | |
Pannello /admin/content |
✓ | ✓ | |
| Toggle "Mostra tag" + modifica inline in pagina | ✓ | ✓ | |
Gestione utenti /admin/users (crea, reset psw, ruolo, elimina) |
✓ |
La regola: ogni ruolo può fare tutto ciò che può il ruolo inferiore, più le proprie capacità.
Modello dati (migrazioni idempotenti in src/lib/db.ts, prima di syncSeed)
Tutte additive, nello stile delle migrazioni esistenti (PRAGMA table_info + ALTER, transazione).
-
Ruolo
editor→superuserUPDATE users SET role = 'superuser' WHERE role = 'editor'. Idempotente: al secondo giro nessuna rigaeditorresta. -
GUID contenuti — colonna
content_blocks.guid TEXT.ALTER TABLE content_blocks ADD COLUMN guid TEXTse assente (PRAGMA table_info).- Backfill: per ogni riga con
guid IS NULL OR guid = '', assegnacrypto.randomUUID(). - Indice univoco:
CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)(creato dopo il backfill, così non fallisce su righe pre-esistenti a guid nullo). syncSeed(indb.tse incontent.ts): l'INSERT delle righe seed nuove valorizza il guid concrypto.randomUUID()alla creazione. Le righe già presenti non vengono toccate (INSERT OR IGNORE), quindi il loro guid è quello del backfill.- Il tag semantico resta la chiave primaria e resta l'identificatore usato nel codice (
t('...'),<T tag="...">) e nel pannello. Il GUID si affianca: è l'ID stabile esposto verso l'esterno (attributodata-guidnel DOM, badge, eventuale API/link). Nessun tag cambia nome.
-
Autore articoli — colonna
posts.author_id INTEGER(nullable).ALTER TABLE posts ADD COLUMN author_id INTEGERse assente.- Nessun vincolo FK stretto a livello schema (coerente con lo stile del repo, dove le FK esplicite
sono solo su
sessions); l'integrità è gestita nel codice. - Post storici:
author_idresta nullo → firma di fallback "Staff InsanityLab" nel blog pubblico.
Auth e sessioni (src/lib/auth.ts)
-
Rolediventa'admin' | 'superuser' | 'user'.createUseraccetta i tre ruoli. -
SESSION_COOKIE,login,getSessionUser,logoutrestano invariati. -
Nuova funzione di autorizzazione che sostituisce
EDITOR_ALLOWED/canAccessAdminPathcon una matrice esplicita per prefisso di rotta:/admin/users, /api/admin/users* → admin /admin/content, /api/admin/content* → superuser, admin /admin (blog: index/new/edit/posts), /api/admin/posts*, /api/admin/upload → user, superuser, admin /admin/logout, /admin/login → chiunque (login pubblico)Firma proposta:
canAccessAdminPath(role, pathname): boolean. La landing per redirect dipende dal ruolo (funzionelandingFor(role)):user → /admin(lista blog),superuser → /admin/content,admin → /admin. -
L'hashing password è già in
hashPassword/verifyPassword;scripts/create-user.mjscontinua a funzionare. Nessuna duplicazione: le API utenti richiamano le funzioni diauth.ts.
Middleware (src/middleware.ts)
Struttura invariata (protegge /admin e /api/admin, esclude /admin/login). Cambia solo il ramo
autorizzazione: usa la nuova matrice. Utente loggato ma senza permesso su una rotta pagina →
redirect(landingFor(role)); su una rotta API → 403 JSON. context.locals.user continua a esporre
{ id, username, role }.
Login pubblico e header
Pagina /login (src/pages/login.astro)
- Layout
Base(stile sito pubblico), nonAdmin. - Form username+password. Riusa la logica di
/admin/login: stessorateLimit, stessologin(), stesso set del cookiesession(httpOnly, sameSite lax, secure in prod, maxAge 7 giorni). - Query
?next=<path>: dopo login valido redirige anextse è un path interno sicuro (inizia con/, non//, non/api/); altrimenti alla home/. - Se già loggato: redirige a
nextsicuro o a/. /admin/loginresta come alias funzionante (nessuna rimozione).
Header (src/components/Header.astro, già incluso da Base.astro)
Reso SSR con lettura sessione dal cookie:
- Anonimo: link "Accedi" →
/login?next=<pagina-corrente>. - Loggato: nome utente + menu a tendina:
- "Area riservata" →
landingFor(role). - Toggle "Mostra tag" (solo superuser/admin) — vedi sezione successiva.
- "Esci" →
/admin/logout.
- "Area riservata" →
- Il menu è vanilla (dettaglio/summary o piccolo script già in stile repo), senza librerie.
Toggle "Mostra tag" e modifica inline
Attivazione
- Cookie
showtags=1(non httpOnly, così lo script client può leggerlo; path/), impostato/rimosso dal toggle nel menu header. Solo superuser/admin possono attivarlo (l'endpoint che lo setta verifica il ruolo). Base.astrocalcolashowTags = (cookie showtags === '1') && sessione con ruolo superuser|admine passa il flag al layout. La persistenza tra pagine è data dal cookie (niente più?annotate=1da trascinare nell'URL).- Compat:
?annotate=1continua a funzionare attivando il cookie/flag per la richiesta corrente.
Badge
- Quando
showTagsè attivo, lo script badge esistente inBase.astro(overlay assoluto sugli elementi condata-tag) viene esteso per: mostrare il nome tag, esporre ildata-guid, e aggiungere un pulsante matita ✎ cliccabile. - Nessun badge per i visitatori anonimi o per il ruolo
user(lo script e il markup non vengono nemmeno emessi).
Modal di modifica inline
Click sulla matita → apre un modal vanilla sovrapposto alla pagina corrente (nessun redirect al pannello):
- Nuovo endpoint
GET /api/admin/content/[tag]→{ tag, guid, type, value, styleOptions }(styleOptionsdaseedByTag/style-presets, come già faGET /api/admin/content). - Il form si costruisce in base a
type:text/html→ textarea + eventuali select di stile daglistyleOptions; salva conPUT /api/admin/content/[tag](esistente, ritorna{ ok, value }post-sanitizzazione).image→ input file →POST /api/admin/content/[tag]/image(esistente) e rimozione viaDELETE(esistente).
- Al salvataggio riuscito il modal aggiorna il contenuto in-place nel DOM (dal
valueritornato) e si chiude, senza reload. Errori mostrati dentro il modal. - Il codice del modal (markup + script) è caricato solo quando
showTagsè attivo: zero peso per chi non è superuser/admin con toggle attivo.
Riuso vs nuovo
Le API di scrittura contenuti (PUT, POST/DELETE image) restano invariate — sono le stesse usate dal
pannello /admin/content. L'unica aggiunta backend è il GET /api/admin/content/[tag] singolo. Il
modal è l'unico componente frontend nuovo.
Gestione utenti (/admin/users, solo admin)
Pagina src/pages/admin/users.astro (layout Admin)
Lista utenti: username, ruolo, ultimo accesso (se ricavabile dalle sessioni). Form/azioni per: crea, reset password, cambia ruolo, elimina.
API src/pages/api/admin/users/* (admin-only via middleware)
POST /api/admin/users→ crea utente:username+password+role(user|superuser|admin). Rifiuta username duplicato (409/400 con messaggio). UsacreateUser.POST /api/admin/users/[id]/reset-password→ genera password casuale, la ritorna una sola volta in chiaro nella risposta JSON (l'admin la copia e la consegna). Aggiornapassword_hash. Non viene mai ri-mostrata né loggata.PUT /api/admin/users/[id]/role→ cambia ruolo.DELETE /api/admin/users/[id]→ elimina l'utente.
Guardie (invarianti di sicurezza)
- Non puoi eliminare te stesso (confronto con
locals.user.id). - Non puoi eliminare l'ultimo admin rimasto, né declassarne il ruolo se è l'ultimo admin
(query di conteggio
role='admin'nella stessa transazione). - Eliminazione utente → cascade sulle sue sessioni (già
ON DELETE CASCADEsusessions.user_id). Gli articoli conauthor_iddi un utente eliminato:author_idresta valorizzato ma "orfano" → firma di fallback "Staff InsanityLab" nel pubblico (nessuna cancellazione a cascata dei post).
Blog ownership
API /api/admin/posts* e /api/admin/posts/[id]
user: la lista in/adminè filtrata sui propriauthor_id;POSTforzaauthor_id = locals.user.id;PUT/DELETEsu un post conauthor_iddiverso → 403.superuser/admin: vedono e gestiscono tutti i post; ilPOSTvalorizza comunqueauthor_id = locals.user.id(l'autore è chi crea).- Implementazione:
createPostaccettaauthor_id;updatePost/deletePostrestano invariati ma le route caricano il post e verificano la ownership prima di procedere (già caricano viagetPostByIdsu PUT; aggiungere il caricamento su DELETE che oggi non lo fa).
Pannello blog (/admin, /admin/new, /admin/edit/[id])
uservede solo i propri articoli; superuser/admin vedono tutti con una colonna "Autore".- Editing di un post altrui bloccato lato UI per
user(e comunque 403 lato API).
Blog pubblico
- Firma autore sotto il titolo dell'articolo: nome dell'utente
author_id; fallback "Staff InsanityLab" quandoauthor_idè nullo o orfano.
Gestione errori
- API: JSON
{ error }con status coerente (400 input non valido, 401 non autenticato — dal middleware, 403 permesso/ownership, 404 risorsa inesistente, 409/400 duplicati). nextnon sicuro nel login → ignorato, redirect a/(niente open redirect).- Cookie
showtagspresente ma ruolo insufficiente → nessun badge (il flag è ricalcolato server-side con il ruolo reale, il cookie da solo non basta). - Reset password / creazione: password mostrata una sola volta, mai persistita in chiaro.
Testing
- Unit (
tests/roles.test.tsesteso): matricecanAccessAdminPathper i tre ruoli su tutti i prefissi;landingFor. - Unit ownership:
usernon può PUT/DELETE post altrui;POSTimpostaauthor_id; guardie gestione utenti (no self-delete, no last-admin delete/demote). - Unit migrazioni: su DB in-memory con righe
editorecontent_blockssenza guid → dopocreateDb: ruoli migrati, guid non nulli e univoci, colonnaposts.author_idpresente. - Smoke (
scripts/smoke.shesteso):/login200; header mostra "Accedi" da anonimo; rotte admin → redirect/403 dauser; badge condata-guidpresenti da superuser+ con toggle; modal/badge assenti da anonimo.
Fuori scope (YAGNI)
- Nessun framework/reattività frontend (no Alpine/petite-vue).
- Nessuna tabella permessi configurabile: i tre ruoli sono fissi in codice.
- Nessuna registrazione self-service: gli utenti li crea l'admin.
- Nessun cambio del nome tag semantico: il GUID si affianca, non sostituisce.
- Nessuna gestione avatar/profilo utente oltre username + ruolo.