Files
InsanityLab-web/docs/superpowers/specs/2026-07-05-utenti-ruoli-login-design.md
T

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).

  1. Ruolo editorsuperuser UPDATE users SET role = 'superuser' WHERE role = 'editor'. Idempotente: al secondo giro nessuna riga editor resta.

  2. GUID contenuti — colonna content_blocks.guid TEXT.

    • ALTER TABLE content_blocks ADD COLUMN guid TEXT se assente (PRAGMA table_info).
    • Backfill: per ogni riga con guid IS NULL OR guid = '', assegna crypto.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 (in db.ts e in content.ts): l'INSERT delle righe seed nuove valorizza il guid con crypto.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 (attributo data-guid nel DOM, badge, eventuale API/link). Nessun tag cambia nome.
  3. Autore articoli — colonna posts.author_id INTEGER (nullable).

    • ALTER TABLE posts ADD COLUMN author_id INTEGER se 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_id resta nullo → firma di fallback "Staff InsanityLab" nel blog pubblico.

Auth e sessioni (src/lib/auth.ts)

  • Role diventa 'admin' | 'superuser' | 'user'. createUser accetta i tre ruoli.

  • SESSION_COOKIE, login, getSessionUser, logout restano invariati.

  • Nuova funzione di autorizzazione che sostituisce EDITOR_ALLOWED/canAccessAdminPath con 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 (funzione landingFor(role)): user → /admin (lista blog), superuser → /admin/content, admin → /admin.

  • L'hashing password è già in hashPassword/verifyPassword; scripts/create-user.mjs continua a funzionare. Nessuna duplicazione: le API utenti richiamano le funzioni di auth.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), non Admin.
  • Form username+password. Riusa la logica di /admin/login: stesso rateLimit, stesso login(), stesso set del cookie session (httpOnly, sameSite lax, secure in prod, maxAge 7 giorni).
  • Query ?next=<path>: dopo login valido redirige a next se è un path interno sicuro (inizia con /, non //, non /api/); altrimenti alla home /.
  • Se già loggato: redirige a next sicuro o a /.
  • /admin/login resta 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.
  • 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.astro calcola showTags = (cookie showtags === '1') && sessione con ruolo superuser|admin e passa il flag al layout. La persistenza tra pagine è data dal cookie (niente più ?annotate=1 da trascinare nell'URL).
  • Compat: ?annotate=1 continua a funzionare attivando il cookie/flag per la richiesta corrente.

Badge

  • Quando showTags è attivo, lo script badge esistente in Base.astro (overlay assoluto sugli elementi con data-tag) viene esteso per: mostrare il nome tag, esporre il data-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 } (styleOptions da seedByTag/style-presets, come già fa GET /api/admin/content).
  • Il form si costruisce in base a type:
    • text / html → textarea + eventuali select di stile dagli styleOptions; salva con PUT /api/admin/content/[tag] (esistente, ritorna { ok, value } post-sanitizzazione).
    • image → input file → POST /api/admin/content/[tag]/image (esistente) e rimozione via DELETE (esistente).
  • Al salvataggio riuscito il modal aggiorna il contenuto in-place nel DOM (dal value ritornato) 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). Usa createUser.
  • 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). Aggiorna password_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 CASCADE su sessions.user_id). Gli articoli con author_id di un utente eliminato: author_id resta 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 propri author_id; POST forza author_id = locals.user.id; PUT/DELETE su un post con author_id diverso → 403.
  • superuser / admin: vedono e gestiscono tutti i post; il POST valorizza comunque author_id = locals.user.id (l'autore è chi crea).
  • Implementazione: createPost accetta author_id; updatePost/deletePost restano invariati ma le route caricano il post e verificano la ownership prima di procedere (già caricano via getPostById su PUT; aggiungere il caricamento su DELETE che oggi non lo fa).

Pannello blog (/admin, /admin/new, /admin/edit/[id])

  • user vede 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" quando author_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).
  • next non sicuro nel login → ignorato, redirect a / (niente open redirect).
  • Cookie showtags presente 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.ts esteso): matrice canAccessAdminPath per i tre ruoli su tutti i prefissi; landingFor.
  • Unit ownership: user non può PUT/DELETE post altrui; POST imposta author_id; guardie gestione utenti (no self-delete, no last-admin delete/demote).
  • Unit migrazioni: su DB in-memory con righe editor e content_blocks senza guid → dopo createDb: ruoli migrati, guid non nulli e univoci, colonna posts.author_id presente.
  • Smoke (scripts/smoke.sh esteso): /login 200; header mostra "Accedi" da anonimo; rotte admin → redirect/403 da user; badge con data-guid presenti 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.