# 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('...')`,
     `<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.
