docs: spec ruoli/login/GUID/modifica inline
This commit is contained in:
@@ -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('...')`,
|
||||||
|
`<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.
|
||||||
Reference in New Issue
Block a user