From 6c225281e056b57be794e2b09a8f4ead90a0b61b Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sun, 5 Jul 2026 21:14:30 +0200 Subject: [PATCH] docs: spec ruoli/login/GUID/modifica inline --- .../2026-07-05-utenti-ruoli-login-design.md | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-05-utenti-ruoli-login-design.md diff --git a/docs/superpowers/specs/2026-07-05-utenti-ruoli-login-design.md b/docs/superpowers/specs/2026-07-05-utenti-ruoli-login-design.md new file mode 100644 index 0000000..d2250a3 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-utenti-ruoli-login-design.md @@ -0,0 +1,210 @@ +# 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 `editor` → `superuser`** + `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('...')`, + ``) 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=`: 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=`. +- **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.