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