1823 lines
72 KiB
Markdown
1823 lines
72 KiB
Markdown
# Utenti, ruoli, login pubblico, GUID e modifica inline — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Portare il sito da due ruoli (`admin`|`editor`) a tre ruoli (`admin`|`superuser`|`user`) con login pubblico, identità nell'header, blog con ownership per autore, un GUID stabile per ogni contenuto taggato e modifica dei contenuti in pagina tramite overlay attivabile con toggle.
|
||
|
||
**Architecture:** Estensione incrementale vanilla dell'esistente. Migrazioni SQLite additive e idempotenti in `createDb()`. Autorizzazione per matrice di prefissi in `auth.ts`, applicata dal middleware. Login pubblico che riusa la logica di `/admin/login`. Overlay tag esistente evoluto: attivazione via cookie `showtags`, badge con GUID e modal di modifica in pagina che riusa le API contenuti già deployate (unica aggiunta backend: `GET /api/admin/content/[tag]`). Zero dipendenze frontend nuove.
|
||
|
||
**Tech Stack:** Astro 7 SSR (adapter node standalone, `prerender = false`), better-sqlite3, bcryptjs, vitest, TipTap (già presente per il blog), CSS/JS vanilla.
|
||
|
||
## Global Constraints
|
||
|
||
- Tutte le pagine e API SSR: `export const prerender = false` in ogni nuovo file di pagina/endpoint.
|
||
- Migrazioni DB SOLO additive e idempotenti, in `createDb()` (`src/lib/db.ts`), PRIMA del seed `syncSeed`; verificare l'esistenza di colonne con `PRAGMA table_info` prima di ogni `ALTER`.
|
||
- Ruoli ammessi: esattamente `'admin' | 'superuser' | 'user'`. Il vecchio `'editor'` non esiste più a runtime dopo la migrazione.
|
||
- Il tag semantico (`about.mission.title`) resta chiave primaria e identificatore usato nel codice e nel pannello. Il GUID si **affianca**, non sostituisce: nessun tag cambia nome.
|
||
- I POST in dev richiedono l'header `Origin` (checkOrigin CSRF di Astro); i `fetch` same-origin del browser lo inviano automaticamente.
|
||
- Nomi file upload già gestiti da `validateUploadFile`/`TAG_RE` esistenti: non reintrodurre nomi prevedibili.
|
||
- Prosa pubblica in italiano corretto con accenti; chat/log/commit in stile breve.
|
||
- Commit frequenti, uno per task. Messaggi commit brevi in italiano.
|
||
- Test runner: `npm test` (vitest). Build di verifica: `npm run build`. Type check: `npm run astro -- check` (o `npx astro check`).
|
||
|
||
---
|
||
|
||
### Task 1: Migrazioni DB — ruoli, GUID contenuti, autore articoli
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/db.ts`
|
||
- Modify: `src/lib/content.ts` (funzione `syncSeed` duplicata — deve inserire il guid)
|
||
- Test: `tests/content-db.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `contentSeed` da `src/data/content-seed.ts`, tabelle `users`/`posts`/`content_blocks`.
|
||
- Produces: dopo `createDb()`, ogni riga `content_blocks` ha `guid` non nullo e univoco; nessun utente con `role='editor'` (migrato a `'superuser'`); colonna `posts.author_id INTEGER` presente.
|
||
|
||
- [ ] **Step 1: Scrivi i test di migrazione**
|
||
|
||
In `tests/content-db.test.ts` aggiungi (in coda, dentro il file esistente; se il file importa già `createDb` riusa l'import):
|
||
|
||
```typescript
|
||
import { describe, it, expect } from 'vitest';
|
||
import { createDb } from '../src/lib/db';
|
||
import { createUser } from '../src/lib/auth';
|
||
|
||
describe('migrazioni ruoli/guid/autore', () => {
|
||
it('ogni content_block ha un guid non vuoto e univoco', () => {
|
||
const db = createDb(':memory:');
|
||
const rows = db.prepare('SELECT guid FROM content_blocks').all() as { guid: string | null }[];
|
||
expect(rows.length).toBeGreaterThan(100);
|
||
expect(rows.every((r) => typeof r.guid === 'string' && r.guid!.length >= 10)).toBe(true);
|
||
const guids = new Set(rows.map((r) => r.guid));
|
||
expect(guids.size).toBe(rows.length);
|
||
});
|
||
|
||
it('lo statement di migrazione unifica editor in superuser', () => {
|
||
// La migrazione gira dentro createDb, ma :memory: non persiste tra due boot: qui
|
||
// verifichiamo l'invariante SQL applicata dalla migrazione (editor → superuser).
|
||
// L'end-to-end su DB pre-esistente è coperto dallo smoke di produzione (Task 15).
|
||
const db = createDb(':memory:');
|
||
db.prepare("INSERT INTO users (username, password_hash, role) VALUES ('vecchio', 'x', 'editor')").run();
|
||
db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run();
|
||
const row = db.prepare("SELECT role FROM users WHERE username = 'vecchio'").get() as { role: string };
|
||
expect(row.role).toBe('superuser');
|
||
});
|
||
|
||
it('posts ha la colonna author_id', () => {
|
||
const db = createDb(':memory:');
|
||
const cols = db.prepare('PRAGMA table_info(posts)').all() as { name: string }[];
|
||
expect(cols.some((c) => c.name === 'author_id')).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
Nota: `:memory:` non persiste tra due `createDb`, quindi la migrazione `editor→superuser` su DB pre-esistente si verifica end-to-end nello smoke in produzione (Task 14); qui verifichiamo l'invariante SQL e la presenza colonna.
|
||
|
||
- [ ] **Step 2: Esegui i test e verifica che falliscano**
|
||
|
||
Run: `npm test -- tests/content-db.test.ts`
|
||
Expected: FAIL — `no such column: guid` / `author_id` assente.
|
||
|
||
- [ ] **Step 3: Aggiungi le migrazioni in `db.ts`**
|
||
|
||
In `src/lib/db.ts`, aggiungi in cima l'import:
|
||
|
||
```typescript
|
||
import { randomUUID } from 'node:crypto';
|
||
```
|
||
|
||
Dopo il blocco che aggiunge `users.role` (dopo la riga `db.exec(\`ALTER TABLE users ADD COLUMN role ...\`)` — cioè dopo la chiusura di quell'`if`, riga ~52), inserisci:
|
||
|
||
```typescript
|
||
// Ruolo editor unificato in superuser (3 ruoli: admin, superuser, user). Idempotente.
|
||
db.prepare("UPDATE users SET role = 'superuser' WHERE role = 'editor'").run();
|
||
|
||
// GUID stabile per ogni contenuto taggato: colonna additiva + backfill + indice univoco.
|
||
const contentCols = db.prepare(`PRAGMA table_info(content_blocks)`).all() as { name: string }[];
|
||
if (!contentCols.some((c) => c.name === 'guid')) {
|
||
db.exec(`ALTER TABLE content_blocks ADD COLUMN guid TEXT`);
|
||
}
|
||
|
||
// Autore articoli: colonna nullable (post storici restano senza autore).
|
||
const postCols = db.prepare(`PRAGMA table_info(posts)`).all() as { name: string }[];
|
||
if (!postCols.some((c) => c.name === 'author_id')) {
|
||
db.exec(`ALTER TABLE posts ADD COLUMN author_id INTEGER`);
|
||
}
|
||
```
|
||
|
||
Poi sostituisci il blocco seed finale (righe ~99-101, `const ins = ...; syncTx();`) con:
|
||
|
||
```typescript
|
||
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)');
|
||
const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); });
|
||
syncTx();
|
||
// Backfill: righe pre-esistenti (già presenti prima dell'introduzione del guid) restano
|
||
// ignorate dall'INSERT OR IGNORE e hanno guid nullo → assegna un UUID a ciascuna.
|
||
const missing = db.prepare(`SELECT tag FROM content_blocks WHERE guid IS NULL OR guid = ''`).all() as { tag: string }[];
|
||
const backfill = db.prepare('UPDATE content_blocks SET guid = ? WHERE tag = ?');
|
||
const bfTx = db.transaction(() => { for (const m of missing) backfill.run(randomUUID(), m.tag); });
|
||
bfTx();
|
||
// Indice univoco creato dopo il backfill, così non fallisce su righe a guid nullo.
|
||
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_content_guid ON content_blocks(guid)`);
|
||
```
|
||
|
||
- [ ] **Step 4: Aggiorna `syncSeed` in `content.ts`**
|
||
|
||
In `src/lib/content.ts`, aggiungi l'import `import { randomUUID } from 'node:crypto';` e sostituisci il corpo di `syncSeed`:
|
||
|
||
```typescript
|
||
export function syncSeed(db: Database.Database): void {
|
||
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value, guid) VALUES (?, ?, ?, ?)');
|
||
const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value, randomUUID()); });
|
||
tx();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Esegui i test e verifica che passino**
|
||
|
||
Run: `npm test -- tests/content-db.test.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/lib/db.ts src/lib/content.ts tests/content-db.test.ts
|
||
git commit -m "feat(db): migrazioni ruoli editor->superuser, guid contenuti, posts.author_id"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Autorizzazione a matrice in `auth.ts`
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/auth.ts`
|
||
- Test: `tests/roles.test.ts` (riscrittura)
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `type Role = 'admin' | 'superuser' | 'user'`
|
||
- `canAccessAdminPath(role: string, pathname: string): boolean`
|
||
- `landingFor(role: string): string`
|
||
- `createUser(db, username, password, role?: Role): number` (default invariato `'admin'`)
|
||
|
||
- [ ] **Step 1: Riscrivi il test dei ruoli**
|
||
|
||
Sostituisci l'intero `tests/roles.test.ts` con:
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import type Database from 'better-sqlite3';
|
||
import { createDb } from '../src/lib/db';
|
||
import { createUser, login, getSessionUser, canAccessAdminPath, landingFor } from '../src/lib/auth';
|
||
|
||
let db: Database.Database;
|
||
beforeEach(() => { db = createDb(':memory:'); });
|
||
|
||
describe('ruoli', () => {
|
||
it('createUser accetta i tre ruoli e getSessionUser li ritorna', () => {
|
||
createUser(db, 'u', 'segreta123', 'user');
|
||
createUser(db, 's', 'segreta123', 'superuser');
|
||
expect(getSessionUser(db, login(db, 'u', 'segreta123')!)).toMatchObject({ role: 'user' });
|
||
expect(getSessionUser(db, login(db, 's', 'segreta123')!)).toMatchObject({ role: 'superuser' });
|
||
});
|
||
|
||
it('createUser senza ruolo crea admin', () => {
|
||
createUser(db, 'adriano', 'segreta123');
|
||
expect(getSessionUser(db, login(db, 'adriano', 'segreta123')!)).toMatchObject({ role: 'admin' });
|
||
});
|
||
});
|
||
|
||
describe('canAccessAdminPath', () => {
|
||
it('admin accede a tutto', () => {
|
||
for (const p of ['/admin', '/admin/users', '/admin/content', '/api/admin/posts', '/api/admin/users/1'])
|
||
expect(canAccessAdminPath('admin', p)).toBe(true);
|
||
});
|
||
|
||
it('superuser: contenuti + blog sì, utenti no', () => {
|
||
expect(canAccessAdminPath('superuser', '/admin/content')).toBe(true);
|
||
expect(canAccessAdminPath('superuser', '/admin/content/')).toBe(true);
|
||
expect(canAccessAdminPath('superuser', '/api/admin/content/home.hero.title')).toBe(true);
|
||
expect(canAccessAdminPath('superuser', '/admin')).toBe(true);
|
||
expect(canAccessAdminPath('superuser', '/admin/new')).toBe(true);
|
||
expect(canAccessAdminPath('superuser', '/api/admin/posts')).toBe(true);
|
||
expect(canAccessAdminPath('superuser', '/admin/users')).toBe(false);
|
||
expect(canAccessAdminPath('superuser', '/api/admin/users/1')).toBe(false);
|
||
});
|
||
|
||
it('user: blog sì, contenuti e utenti no', () => {
|
||
expect(canAccessAdminPath('user', '/admin')).toBe(true);
|
||
expect(canAccessAdminPath('user', '/admin/new')).toBe(true);
|
||
expect(canAccessAdminPath('user', '/api/admin/posts')).toBe(true);
|
||
expect(canAccessAdminPath('user', '/api/admin/upload')).toBe(true);
|
||
expect(canAccessAdminPath('user', '/admin/logout')).toBe(true);
|
||
expect(canAccessAdminPath('user', '/admin/content')).toBe(false);
|
||
expect(canAccessAdminPath('user', '/api/admin/content/x')).toBe(false);
|
||
expect(canAccessAdminPath('user', '/admin/users')).toBe(false);
|
||
});
|
||
|
||
it('la matrice non confonde prefissi simili', () => {
|
||
expect(canAccessAdminPath('user', '/admin/contentious')).toBe(true); // NON è /admin/content
|
||
expect(canAccessAdminPath('superuser', '/admin/users-x')).toBe(true); // NON è /admin/users
|
||
});
|
||
});
|
||
|
||
describe('landingFor', () => {
|
||
it('instrada ogni ruolo alla sua home', () => {
|
||
expect(landingFor('superuser')).toBe('/admin/content');
|
||
expect(landingFor('user')).toBe('/admin');
|
||
expect(landingFor('admin')).toBe('/admin');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Esegui e verifica il fallimento**
|
||
|
||
Run: `npm test -- tests/roles.test.ts`
|
||
Expected: FAIL — `landingFor` non esportata, matrice non aggiornata.
|
||
|
||
- [ ] **Step 3: Aggiorna `auth.ts`**
|
||
|
||
In `src/lib/auth.ts`:
|
||
|
||
1. Cambia il tipo: `export type Role = 'admin' | 'superuser' | 'user';`
|
||
2. Sostituisci il blocco finale (da `// Percorsi admin consentiti anche al ruolo editor.` fino a fine file) con:
|
||
|
||
```typescript
|
||
// Autorizzazione per prefisso di rotta. L'admin passa sempre; per gli altri, la prima regola
|
||
// che matcha decide; se nessuna regola matcha la rotta è "blog/upload/logout" → consentita a
|
||
// qualsiasi loggato (il middleware protegge solo /admin e /api/admin, quindi qui arrivano solo
|
||
// utenti già autenticati).
|
||
const RULES: [RegExp, Role[]][] = [
|
||
[/^\/admin\/users(\/|$)/, ['admin']],
|
||
[/^\/api\/admin\/users(\/|$)/, ['admin']],
|
||
[/^\/admin\/content(\/|$)/, ['admin', 'superuser']],
|
||
[/^\/api\/admin\/content(\/|$)/, ['admin', 'superuser']],
|
||
];
|
||
|
||
export function canAccessAdminPath(role: string, pathname: string): boolean {
|
||
if (role === 'admin') return true;
|
||
for (const [re, roles] of RULES) {
|
||
if (re.test(pathname)) return roles.includes(role as Role);
|
||
}
|
||
return true; // blog, upload, logout, showtags: tutti i loggati
|
||
}
|
||
|
||
export function landingFor(role: string): string {
|
||
if (role === 'superuser') return '/admin/content';
|
||
return '/admin';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Esegui e verifica il successo**
|
||
|
||
Run: `npm test -- tests/roles.test.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/lib/auth.ts tests/roles.test.ts
|
||
git commit -m "feat(auth): matrice permessi 3 ruoli + landingFor"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Middleware con matrice e redirect per ruolo
|
||
|
||
**Files:**
|
||
- Modify: `src/middleware.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `canAccessAdminPath`, `landingFor` (Task 2).
|
||
|
||
- [ ] **Step 1: Aggiorna il middleware**
|
||
|
||
In `src/middleware.ts` cambia l'import e il ramo autorizzazione. Import:
|
||
|
||
```typescript
|
||
import { getSessionUser, SESSION_COOKIE, canAccessAdminPath, landingFor } from './lib/auth';
|
||
```
|
||
|
||
Sostituisci il blocco `if (!canAccessAdminPath(...))` con:
|
||
|
||
```typescript
|
||
if (!canAccessAdminPath(user.role, pathname)) {
|
||
if (pathname.startsWith('/api/')) {
|
||
return new Response(JSON.stringify({ error: 'Permessi insufficienti' }), {
|
||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
}
|
||
return context.redirect(landingFor(user.role));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verifica build + type check**
|
||
|
||
Run: `npm run build`
|
||
Expected: build OK, nessun errore di tipo.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/middleware.ts
|
||
git commit -m "feat(middleware): autorizzazione a matrice + redirect per ruolo"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Repository posts — autore e liste per autore
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/posts.ts`
|
||
- Modify: `src/lib/auth.ts` (helper utenti riusati anche dal blog e dalla gestione utenti)
|
||
- Test: `tests/posts.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `interface Post` con `author_id: number | null`
|
||
- `interface PostInput` con `author_id?: number | null`
|
||
- `createPost(db, input)` scrive `author_id`
|
||
- `listByAuthor(db, authorId: number): Post[]`
|
||
- in `auth.ts`: `getUsernameById(db, id: number): string | null`, `listUsers(db): { id: number; username: string; role: string }[]`
|
||
|
||
- [ ] **Step 1: Scrivi i test del repository**
|
||
|
||
Aggiungi in `tests/posts.test.ts` (in coda, riusando `db`/`base`):
|
||
|
||
```typescript
|
||
import { listByAuthor } from '../src/lib/posts';
|
||
import { createUser, getUsernameById, listUsers } from '../src/lib/auth';
|
||
|
||
describe('autore articoli', () => {
|
||
it('createPost salva author_id e listByAuthor filtra', () => {
|
||
const aliceId = createUser(db, 'alice', 'segreta123', 'user');
|
||
const bobId = createUser(db, 'bob', 'segreta123', 'user');
|
||
createPost(db, { ...base, slug: 'a1', author_id: aliceId });
|
||
createPost(db, { ...base, slug: 'a2', author_id: aliceId });
|
||
createPost(db, { ...base, slug: 'b1', author_id: bobId });
|
||
expect(listByAuthor(db, aliceId).length).toBe(2);
|
||
expect(listByAuthor(db, bobId).length).toBe(1);
|
||
});
|
||
|
||
it('post senza author_id ha author_id null', () => {
|
||
const id = createPost(db, { ...base, slug: 'orfano' });
|
||
expect(getPostById(db, id)!.author_id).toBeNull();
|
||
});
|
||
|
||
it('getUsernameById e listUsers', () => {
|
||
const id = createUser(db, 'carla', 'segreta123', 'superuser');
|
||
expect(getUsernameById(db, id)).toBe('carla');
|
||
expect(getUsernameById(db, 99999)).toBeNull();
|
||
expect(listUsers(db).some((u) => u.username === 'carla' && u.role === 'superuser')).toBe(true);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Esegui e verifica il fallimento**
|
||
|
||
Run: `npm test -- tests/posts.test.ts`
|
||
Expected: FAIL — `listByAuthor`/`getUsernameById`/`listUsers` non esistono, `author_id` non nell'input.
|
||
|
||
- [ ] **Step 3: Aggiorna `posts.ts`**
|
||
|
||
Nell'`interface Post` aggiungi `author_id: number | null;`. Nell'`interface PostInput` aggiungi `author_id?: number | null;`. Sostituisci `createPost`:
|
||
|
||
```typescript
|
||
export function createPost(db: Database.Database, input: PostInput): number {
|
||
const res = db.prepare(
|
||
`INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, author_id, published_at)
|
||
VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft, @author_id,
|
||
CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)`
|
||
).run({ ...input, cover: input.cover ?? null, author_id: input.author_id ?? null, draft: input.draft ? 1 : 0 });
|
||
return Number(res.lastInsertRowid);
|
||
}
|
||
```
|
||
|
||
`updatePost` resta invariato (non tocca `author_id`: l'autore non cambia in modifica). Aggiungi in fondo:
|
||
|
||
```typescript
|
||
export function listByAuthor(db: Database.Database, authorId: number): Post[] {
|
||
return db.prepare('SELECT * FROM posts WHERE author_id = ? ORDER BY updated_at DESC').all(authorId) as Post[];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Aggiungi gli helper utenti in `auth.ts`**
|
||
|
||
In `src/lib/auth.ts` aggiungi:
|
||
|
||
```typescript
|
||
export function getUsernameById(db: Database.Database, id: number): string | null {
|
||
const row = db.prepare('SELECT username FROM users WHERE id = ?').get(id) as { username: string } | undefined;
|
||
return row?.username ?? null;
|
||
}
|
||
|
||
export function listUsers(db: Database.Database): { id: number; username: string; role: string }[] {
|
||
return db.prepare('SELECT id, username, role FROM users ORDER BY username').all() as
|
||
{ id: number; username: string; role: string }[];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Esegui e verifica il successo**
|
||
|
||
Run: `npm test -- tests/posts.test.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/lib/posts.ts src/lib/auth.ts tests/posts.test.ts
|
||
git commit -m "feat(posts): author_id nel repository + listByAuthor + helper utenti"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: API posts — ownership e assegnazione autore
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/api/admin/posts/index.ts`
|
||
- Modify: `src/pages/api/admin/posts/[id].ts`
|
||
- Test: `tests/posts-ownership.test.ts` (nuovo, a livello logico via funzione estratta)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `createPost`, `getPostById`, `updatePost`, `deletePost` (Task 4), `locals.user` dal middleware.
|
||
- Produces: helper `canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean` in `src/lib/posts.ts`.
|
||
|
||
- [ ] **Step 1: Scrivi il test dell'ownership (livello logico)**
|
||
|
||
Crea `tests/posts-ownership.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect } from 'vitest';
|
||
import { canModifyPost } from '../src/lib/posts';
|
||
|
||
describe('canModifyPost', () => {
|
||
const own = { author_id: 7 };
|
||
const other = { author_id: 8 };
|
||
const orphan = { author_id: null };
|
||
|
||
it('admin e superuser modificano qualsiasi post', () => {
|
||
for (const role of ['admin', 'superuser']) {
|
||
expect(canModifyPost(role, 7, other)).toBe(true);
|
||
expect(canModifyPost(role, 7, orphan)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('user modifica solo i propri', () => {
|
||
expect(canModifyPost('user', 7, own)).toBe(true);
|
||
expect(canModifyPost('user', 7, other)).toBe(false);
|
||
expect(canModifyPost('user', 7, orphan)).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Esegui e verifica il fallimento**
|
||
|
||
Run: `npm test -- tests/posts-ownership.test.ts`
|
||
Expected: FAIL — `canModifyPost` non esiste.
|
||
|
||
- [ ] **Step 3: Aggiungi `canModifyPost` in `posts.ts`**
|
||
|
||
```typescript
|
||
export function canModifyPost(role: string, userId: number, post: { author_id: number | null }): boolean {
|
||
if (role === 'admin' || role === 'superuser') return true;
|
||
return post.author_id === userId;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Esegui e verifica il successo**
|
||
|
||
Run: `npm test -- tests/posts-ownership.test.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Applica l'autore nel POST**
|
||
|
||
In `src/pages/api/admin/posts/index.ts`, cambia la firma per leggere `locals` e passa `author_id`:
|
||
|
||
```typescript
|
||
export const POST: APIRoute = async ({ request, locals }) => {
|
||
let data: Record<string, unknown>;
|
||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||
const parsed = parsePostBody(data);
|
||
if ('error' in parsed) return json(400, { error: parsed.error });
|
||
try {
|
||
const id = createPost(getDb(), { ...parsed.value, author_id: locals.user!.id });
|
||
return json(201, { id, slug: parsed.value.slug });
|
||
} catch (err: any) {
|
||
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
|
||
throw err;
|
||
}
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 6: Applica l'ownership su PUT e DELETE**
|
||
|
||
In `src/pages/api/admin/posts/[id].ts`, aggiorna gli import e i due handler:
|
||
|
||
```typescript
|
||
import { getPostById, updatePost, deletePost, canModifyPost } from '../../../../lib/posts';
|
||
|
||
export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||
const id = Number(params.id);
|
||
const existing = getPostById(getDb(), id);
|
||
if (!existing) return json(404, { error: 'Articolo non trovato.' });
|
||
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi modificare questo articolo.' });
|
||
let data: Record<string, unknown>;
|
||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||
const parsed = parsePostBody(data);
|
||
if ('error' in parsed) return json(400, { error: parsed.error });
|
||
try {
|
||
updatePost(getDb(), id, parsed.value);
|
||
return json(200, { ok: true });
|
||
} catch (err: any) {
|
||
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Slug già esistente.' });
|
||
throw err;
|
||
}
|
||
};
|
||
|
||
export const DELETE: APIRoute = ({ params, locals }) => {
|
||
const id = Number(params.id);
|
||
const existing = getPostById(getDb(), id);
|
||
if (!existing) return json(404, { error: 'Articolo non trovato.' });
|
||
if (!canModifyPost(locals.user!.role, locals.user!.id, existing)) return json(403, { error: 'Non puoi eliminare questo articolo.' });
|
||
deletePost(getDb(), id);
|
||
return json(200, { ok: true });
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 7: Verifica build + test**
|
||
|
||
Run: `npm run build && npm test`
|
||
Expected: build OK, tutti i test verdi.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/pages/api/admin/posts/index.ts src/pages/api/admin/posts/[id].ts src/lib/posts.ts tests/posts-ownership.test.ts
|
||
git commit -m "feat(posts-api): assegna autore in POST, ownership su PUT/DELETE"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: GUID esposto — resolve, componenti e GET singolo
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/content.ts`
|
||
- Modify: `src/components/content/T.astro`
|
||
- Modify: `src/components/content/TImg.astro`
|
||
- Create: `src/pages/api/admin/content/[tag]/index.ts`
|
||
- Test: `tests/content.test.ts`, `tests/content-api.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `content_blocks.guid` (Task 1), `seedByTag` da `content-seed.ts`.
|
||
- Produces:
|
||
- `resolveContent(tag)` ritorna `{ value, type, classes, guid }` (`guid: string` — stringa vuota se seed-only)
|
||
- `GET /api/admin/content/[tag]` → `{ tag, guid, type, value, styleOptions }`
|
||
|
||
- [ ] **Step 1: Test su resolve + GET singolo**
|
||
|
||
In `tests/content.test.ts` aggiungi:
|
||
|
||
```typescript
|
||
it('resolveContent restituisce il guid dal DB', () => {
|
||
const { resolveContent } = require('../src/lib/content');
|
||
const r = resolveContent('home.hero.title');
|
||
expect(typeof r.guid).toBe('string');
|
||
expect(r.guid.length).toBeGreaterThan(10);
|
||
});
|
||
```
|
||
|
||
In `tests/content-api.test.ts` aggiungi un test per il GET singolo seguendo il pattern già usato nel file per gli altri endpoint (stessa costruzione di `context`/`locals.user` degli altri test del file). Struttura:
|
||
|
||
```typescript
|
||
it('GET /api/admin/content/[tag] ritorna tag, guid, type, value, styleOptions', async () => {
|
||
const { GET } = await import('../src/pages/api/admin/content/[tag]/index');
|
||
const res = await GET({ params: { tag: 'home.hero.title' } } as any);
|
||
expect(res.status).toBe(200);
|
||
const body = await res.json();
|
||
expect(body.tag).toBe('home.hero.title');
|
||
expect(typeof body.guid).toBe('string');
|
||
expect(body.type).toBeDefined();
|
||
expect(Array.isArray(body.styleOptions)).toBe(true);
|
||
});
|
||
```
|
||
|
||
Se `content-api.test.ts` usa un DB di test isolato via `getDb`, riusa lo stesso setup già presente nel file (non reinventarlo).
|
||
|
||
- [ ] **Step 2: Esegui e verifica il fallimento**
|
||
|
||
Run: `npm test -- tests/content.test.ts tests/content-api.test.ts`
|
||
Expected: FAIL — `guid` assente / modulo `[tag]/index` inesistente.
|
||
|
||
- [ ] **Step 3: Espandi lo store in `content.ts`**
|
||
|
||
Aggiorna `interface Entry` e la `resolve`:
|
||
|
||
```typescript
|
||
interface Entry { value: string; type: ContentType; styles: Record<string, string>; guid: string }
|
||
```
|
||
|
||
Nel `load()`, cambia la query e la costruzione:
|
||
|
||
```typescript
|
||
const rows = dbGetter().prepare('SELECT tag, type, value, styles, guid FROM content_blocks').all() as
|
||
{ tag: string; type: ContentType; value: string; styles: string; guid: string | null }[];
|
||
cache = new Map(rows.map((r) => {
|
||
let styles: Record<string, string> = {};
|
||
try { styles = JSON.parse(r.styles); } catch { /* styles corrotto: si ignora */ }
|
||
if (typeof styles !== 'object' || styles === null) styles = {};
|
||
return [r.tag, { value: r.value, type: r.type, styles, guid: r.guid ?? '' }];
|
||
}));
|
||
```
|
||
|
||
Nel metodo `resolve`, aggiorna i tre `return`:
|
||
|
||
```typescript
|
||
resolve(tag: string): { value: string; type: ContentType; classes: string; guid: string } {
|
||
const hit = load().get(tag);
|
||
if (hit) return { value: hit.value, type: hit.type, classes: stylesToClasses(hit.styles), guid: hit.guid };
|
||
const seed = seedByTag.get(tag);
|
||
if (seed) return { value: seed.value, type: seed.type, classes: '', guid: '' };
|
||
console.warn(`[content] tag sconosciuto: ${tag}`);
|
||
return { value: '', type: 'text', classes: '', guid: '' };
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 4: Emetti `data-guid` nei componenti**
|
||
|
||
In `src/components/content/T.astro`, dopo `const c = resolveContent(tag);` usa `c.guid`:
|
||
|
||
```astro
|
||
{c.type === 'html'
|
||
? <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes} set:html={c.value} />
|
||
: <Tag data-tag={tag} data-guid={c.guid || undefined} class={classes}>{c.value}</Tag>}
|
||
```
|
||
|
||
In `src/components/content/TImg.astro`, aggiungi `data-guid` in entrambi i rami. Il ramo override usa `getImageOverride` che non dà il guid; recupera il guid da `resolveContent`:
|
||
|
||
```astro
|
||
---
|
||
import { Image } from 'astro:assets';
|
||
import type { ImageMetadata } from 'astro';
|
||
import { resolveContent } from '../../lib/content';
|
||
interface Props {
|
||
tag: string; src: ImageMetadata; alt: string;
|
||
class?: string; loading?: 'lazy' | 'eager'; widths?: number[]; sizes?: string; height?: number;
|
||
}
|
||
const { tag, src, alt, class: cls, ...rest } = Astro.props;
|
||
const c = resolveContent(tag);
|
||
const override = c.type === 'image' && c.value ? `/uploads/${c.value}` : null;
|
||
const guid = c.guid || undefined;
|
||
---
|
||
{override
|
||
? <img src={override} alt={alt} class={cls} data-tag={tag} data-guid={guid} loading={rest.loading} />
|
||
: <Image src={src} alt={alt} class={cls} data-tag={tag} data-guid={guid} {...rest} />}
|
||
```
|
||
|
||
- [ ] **Step 5: Crea l'endpoint GET singolo**
|
||
|
||
Crea `src/pages/api/admin/content/[tag]/index.ts`:
|
||
|
||
```typescript
|
||
import type { APIRoute } from 'astro';
|
||
import { getDb } from '../../../../../lib/db';
|
||
import { seedByTag } from '../../../../../data/content-seed';
|
||
export const prerender = false;
|
||
|
||
const json = (status: number, body: object) =>
|
||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||
|
||
export const GET: APIRoute = ({ params }) => {
|
||
const tag = params.tag ?? '';
|
||
const row = getDb().prepare('SELECT tag, type, value, guid FROM content_blocks WHERE tag = ?').get(tag) as
|
||
{ tag: string; type: string; value: string; guid: string | null } | undefined;
|
||
if (!row) return json(404, { error: 'Tag inesistente.' });
|
||
return json(200, {
|
||
tag: row.tag,
|
||
guid: row.guid ?? '',
|
||
type: row.type,
|
||
value: row.value,
|
||
styleOptions: seedByTag.get(row.tag)?.styleOptions ?? [],
|
||
});
|
||
};
|
||
```
|
||
|
||
Nota: convive con `PUT` di `src/pages/api/admin/content/[tag].ts` e con `POST/DELETE` di `[tag]/image.ts` — Astro risolve `/api/admin/content/<tag>` sull'endpoint `[tag].ts` (GET qui aggiunto come `[tag]/index.ts` risponde allo stesso path). **Verifica in Step 6** che il GET risponda; se il routing preferisce `[tag].ts`, sposta l'handler `GET` dentro `src/pages/api/admin/content/[tag].ts` accanto al `PUT` (stessa logica) ed elimina il file index. Decidi in base all'esito del test.
|
||
|
||
- [ ] **Step 6: Esegui e verifica il successo**
|
||
|
||
Run: `npm test -- tests/content.test.ts tests/content-api.test.ts && npm run build`
|
||
Expected: PASS + build OK. Se il test del GET fallisce per routing, applica la nota dello Step 5 (sposta `GET` in `[tag].ts`) e rilancia.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/lib/content.ts src/components/content/T.astro src/components/content/TImg.astro src/pages/api/admin/content tests/content.test.ts tests/content-api.test.ts
|
||
git commit -m "feat(content): guid in resolve/componenti + GET /api/admin/content/[tag]"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Pagina di login pubblica `/login`
|
||
|
||
**Files:**
|
||
- Create: `src/pages/login.astro`
|
||
- Create: `src/lib/safe-next.ts`
|
||
- Test: `tests/safe-next.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `login`, `SESSION_COOKIE` (auth.ts), `rateLimit` (rate-limit.ts).
|
||
- Produces: `safeNext(next: string | null): string` — ritorna un path interno sicuro o `'/'`.
|
||
|
||
- [ ] **Step 1: Test di `safeNext`**
|
||
|
||
Crea `tests/safe-next.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect } from 'vitest';
|
||
import { safeNext } from '../src/lib/safe-next';
|
||
|
||
describe('safeNext', () => {
|
||
it('accetta path interni', () => {
|
||
expect(safeNext('/about')).toBe('/about');
|
||
expect(safeNext('/blog/x?y=1')).toBe('/blog/x?y=1');
|
||
});
|
||
it('rifiuta esterni, protocol-relative, api e vuoti', () => {
|
||
expect(safeNext(null)).toBe('/');
|
||
expect(safeNext('')).toBe('/');
|
||
expect(safeNext('//evil.com')).toBe('/');
|
||
expect(safeNext('https://evil.com')).toBe('/');
|
||
expect(safeNext('/api/admin/posts')).toBe('/');
|
||
expect(safeNext('javascript:alert(1)')).toBe('/');
|
||
expect(safeNext('not-a-path')).toBe('/');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Esegui e verifica il fallimento**
|
||
|
||
Run: `npm test -- tests/safe-next.test.ts`
|
||
Expected: FAIL — modulo inesistente.
|
||
|
||
- [ ] **Step 3: Implementa `safeNext`**
|
||
|
||
Crea `src/lib/safe-next.ts`:
|
||
|
||
```typescript
|
||
// Path interno sicuro per il redirect post-login: deve iniziare con "/", non essere
|
||
// protocol-relative ("//"), non puntare alle API. Tutto il resto → home.
|
||
export function safeNext(next: string | null): string {
|
||
if (!next || !next.startsWith('/') || next.startsWith('//')) return '/';
|
||
if (next.startsWith('/api/')) return '/';
|
||
return next;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Esegui e verifica il successo**
|
||
|
||
Run: `npm test -- tests/safe-next.test.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Crea la pagina `/login`**
|
||
|
||
Crea `src/pages/login.astro`:
|
||
|
||
```astro
|
||
---
|
||
import Base from '../layouts/Base.astro';
|
||
import { getDb } from '../lib/db';
|
||
import { login, getSessionUser, SESSION_COOKIE } from '../lib/auth';
|
||
import { rateLimit } from '../lib/rate-limit';
|
||
import { safeNext } from '../lib/safe-next';
|
||
export const prerender = false;
|
||
|
||
const dest = safeNext(Astro.url.searchParams.get('next'));
|
||
|
||
// Già loggato → via dal login.
|
||
const existing = Astro.cookies.get(SESSION_COOKIE)?.value;
|
||
if (existing && getSessionUser(getDb(), existing)) return Astro.redirect(dest);
|
||
|
||
let error = '';
|
||
if (Astro.request.method === 'POST') {
|
||
if (!rateLimit(`login:${Astro.clientAddress}`, 5, 15 * 60 * 1000)) {
|
||
error = 'Troppi tentativi: riprova tra 15 minuti.';
|
||
} else {
|
||
const form = await Astro.request.formData();
|
||
const token = login(getDb(), String(form.get('username') ?? ''), String(form.get('password') ?? ''));
|
||
if (token) {
|
||
Astro.cookies.set(SESSION_COOKIE, token, {
|
||
httpOnly: true, sameSite: 'lax', path: '/',
|
||
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
||
});
|
||
return Astro.redirect(safeNext(String(form.get('next') ?? '') || dest));
|
||
}
|
||
error = 'Credenziali non valide.';
|
||
}
|
||
}
|
||
---
|
||
<Base title="Accedi" description="Area riservata InsanityLab">
|
||
<section class="section">
|
||
<div class="container login-wrap">
|
||
<h1 class="section-heading">Accedi</h1>
|
||
{error && <p class="login-err">{error}</p>}
|
||
<form method="post" class="login-form">
|
||
<input type="hidden" name="next" value={dest} />
|
||
<input class="login-field" name="username" placeholder="Utente" required autocomplete="username" />
|
||
<input class="login-field" type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
||
<button class="btn btn--dark" type="submit">Entra</button>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
</Base>
|
||
|
||
<style>
|
||
.login-wrap { max-width: 380px; margin: 0 auto; }
|
||
.login-form { display: grid; gap: 14px; margin-top: 20px; }
|
||
.login-field { padding: 12px; border: 1px solid #ccc; font: inherit; }
|
||
.login-err { color: #a33; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 6: Verifica build**
|
||
|
||
Run: `npm run build`
|
||
Expected: build OK.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/pages/login.astro src/lib/safe-next.ts tests/safe-next.test.ts
|
||
git commit -m "feat(login): pagina pubblica /login con next sicuro"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Header SSR con identità utente
|
||
|
||
**Files:**
|
||
- Modify: `src/layouts/Base.astro`
|
||
- Modify: `src/components/Header.astro`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getSessionUser`, `SESSION_COOKIE` (auth.ts).
|
||
- Produces: `Header` accetta `Props { user: { username: string; role: string } | null; showTags: boolean; canTags: boolean }`. In questo task `showTags`/`canTags` sono passati ma il toggle è aggiunto nel Task 9 (qui il menu mostra solo identità + area riservata + esci).
|
||
|
||
- [ ] **Step 1: Calcola la sessione in `Base.astro` e passala all'header**
|
||
|
||
In `src/layouts/Base.astro`, sostituisci il blocco `// Overlay annotate...` con:
|
||
|
||
```typescript
|
||
const tok = Astro.cookies.get(SESSION_COOKIE)?.value;
|
||
const user = tok ? getSessionUser(getDb(), tok) : null;
|
||
const canTags = !!user && (user.role === 'superuser' || user.role === 'admin');
|
||
const wantTags = Astro.cookies.get('showtags')?.value === '1' || Astro.url.searchParams.get('annotate') === '1';
|
||
const showTags = canTags && wantTags;
|
||
```
|
||
|
||
E cambia `<Header />` in:
|
||
|
||
```astro
|
||
<Header user={user} showTags={showTags} canTags={canTags} />
|
||
```
|
||
|
||
(La sostituzione della variabile `annotate` con `showTags` nell'overlay avviene nel Task 10; per ora l'overlay resta gestito da `showTags` — aggiorna il blocco `{annotate && (` in `{showTags && (` già qui, così la pagina compila.)
|
||
|
||
- [ ] **Step 2: Rendi l'header consapevole della sessione**
|
||
|
||
Sostituisci il frontmatter e il markup di `src/components/Header.astro`:
|
||
|
||
```astro
|
||
---
|
||
import { site } from '../data/site';
|
||
import { t, contentImageUrl } from '../lib/content';
|
||
interface Props {
|
||
user: { username: string; role: string } | null;
|
||
showTags: boolean;
|
||
canTags: boolean;
|
||
}
|
||
const { user, canTags } = Astro.props;
|
||
const path = Astro.url.pathname;
|
||
const loginHref = `/login?next=${encodeURIComponent(path)}`;
|
||
const areaHref = user?.role === 'superuser' ? '/admin/content' : '/admin';
|
||
---
|
||
<header class="hdr" id="site-header">
|
||
<div class="container hdr__in">
|
||
<a href="/" class="hdr__logo"><img src={contentImageUrl('header.logo.image', '/img/logo-dark.png')} alt={t('header.logo.alt')} width="150" /></a>
|
||
<nav class="hdr__nav" id="site-nav" aria-label="principale">
|
||
{site.navigation.map((item, i) => (
|
||
<a href={item.href} class:list={['hdr__link', { 'is-active': path === item.href || (item.href !== '/' && path.startsWith(item.href)) }]}>{t(`global.nav.${i + 1}.label`)}</a>
|
||
))}
|
||
</nav>
|
||
<div class="hdr__user">
|
||
{user ? (
|
||
<details class="umenu">
|
||
<summary class="umenu__btn">{user.username} ▾</summary>
|
||
<div class="umenu__panel">
|
||
<a href={areaHref}>Area riservata</a>
|
||
{canTags && (
|
||
<button type="button" class="umenu__tags" id="toggle-tags" data-slot="tags">Mostra tag</button>
|
||
)}
|
||
<a href="/admin/logout">Esci</a>
|
||
</div>
|
||
</details>
|
||
) : (
|
||
<a class="hdr__login" href={loginHref}>Accedi</a>
|
||
)}
|
||
</div>
|
||
<button class="hdr__burger" id="nav-toggle" aria-label="Apri menu" aria-expanded="false">
|
||
<span></span><span></span><span></span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
```
|
||
|
||
Aggiungi in fondo al blocco `<style>` esistente dell'header:
|
||
|
||
```css
|
||
.hdr__user { display: flex; align-items: center; }
|
||
.hdr__login { font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; text-decoration: none; color: var(--c-heading); }
|
||
.hdr__login:hover { color: var(--c-accent-dark); }
|
||
.umenu { position: relative; }
|
||
.umenu__btn { cursor: pointer; list-style: none; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--c-heading); }
|
||
.umenu__btn::-webkit-details-marker { display: none; }
|
||
.umenu__panel { position: absolute; right: 0; top: calc(100% + 8px); background: #fff; box-shadow: 0 8px 22px rgba(0,0,0,.12); min-width: 180px; display: flex; flex-direction: column; padding: 8px 0; z-index: 200; }
|
||
.umenu__panel a, .umenu__tags { padding: 10px 16px; text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: .8rem; text-decoration: none; color: var(--c-heading); }
|
||
.umenu__panel a:hover, .umenu__tags:hover { background: #f4f2ef; }
|
||
```
|
||
|
||
(Il pulsante `#toggle-tags` è reso qui ma il suo comportamento è cablato nel Task 9.)
|
||
|
||
- [ ] **Step 3: Verifica build**
|
||
|
||
Run: `npm run build`
|
||
Expected: build OK.
|
||
|
||
- [ ] **Step 4: Verifica manuale in dev**
|
||
|
||
Run: `fuser -k 4321/tcp 2>/dev/null; npm run dev &` poi `curl -s localhost:4321/ | grep -o 'Accedi'`
|
||
Expected: `Accedi` presente da anonimo. Ferma il server dopo la verifica (`fuser -k 4321/tcp`).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/layouts/Base.astro src/components/Header.astro
|
||
git commit -m "feat(header): identità utente SSR, link Accedi / menu utente"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Toggle "Mostra tag" — endpoint cookie e cablaggio
|
||
|
||
**Files:**
|
||
- Create: `src/pages/api/admin/showtags.ts`
|
||
- Modify: `src/components/Header.astro` (script del pulsante)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `locals.user` dal middleware (rotta sotto `/api/admin`, quindi login garantito).
|
||
- Produces: `POST /api/admin/showtags` con body `{ on: boolean }`; imposta/rimuove il cookie `showtags`; `403` se il ruolo non è `superuser`/`admin`.
|
||
|
||
- [ ] **Step 1: Crea l'endpoint**
|
||
|
||
Crea `src/pages/api/admin/showtags.ts`:
|
||
|
||
```typescript
|
||
import type { APIRoute } from 'astro';
|
||
export const prerender = false;
|
||
|
||
const json = (status: number, body: object) =>
|
||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||
|
||
export const POST: APIRoute = async ({ request, cookies, locals }) => {
|
||
const role = locals.user!.role;
|
||
if (role !== 'superuser' && role !== 'admin') return json(403, { error: 'Permessi insufficienti' });
|
||
let data: { on?: unknown };
|
||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||
if (data.on) {
|
||
cookies.set('showtags', '1', { httpOnly: true, sameSite: 'lax', path: '/', secure: import.meta.env.PROD });
|
||
} else {
|
||
cookies.delete('showtags', { path: '/' });
|
||
}
|
||
return json(200, { on: Boolean(data.on) });
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Cabla il pulsante nell'header**
|
||
|
||
In `src/components/Header.astro`, dentro il blocco `<script>` esistente (dopo il codice del burger), aggiungi:
|
||
|
||
```javascript
|
||
const tagsBtn = document.getElementById('toggle-tags');
|
||
if (tagsBtn) {
|
||
// Lo stato attuale è riflesso server-side; il testo lo deriviamo dal cookie.
|
||
const on = document.cookie.split('; ').some((c) => c === 'showtags=1');
|
||
tagsBtn.textContent = on ? 'Nascondi tag' : 'Mostra tag';
|
||
tagsBtn.addEventListener('click', async () => {
|
||
const res = await fetch('/api/admin/showtags', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ on: !on }),
|
||
});
|
||
if (res.ok) location.reload();
|
||
else alert('Operazione non riuscita.');
|
||
});
|
||
}
|
||
```
|
||
|
||
Nota: il cookie `showtags` è `httpOnly`, quindi `document.cookie` non lo espone. Per riflettere lo stato senza reload, l'header riceve già `showTags` come prop: passa lo stato al pulsante via attributo. Correzione da applicare in questo step — nel markup del Task 8 il pulsante diventa:
|
||
|
||
```astro
|
||
<button type="button" class="umenu__tags" id="toggle-tags" data-on={Astro.props.showTags ? '1' : '0'}>{Astro.props.showTags ? 'Nascondi tag' : 'Mostra tag'}</button>
|
||
```
|
||
|
||
e lo script usa `data-on`:
|
||
|
||
```javascript
|
||
const tagsBtn = document.getElementById('toggle-tags');
|
||
if (tagsBtn) {
|
||
const on = tagsBtn.dataset.on === '1';
|
||
tagsBtn.addEventListener('click', async () => {
|
||
const res = await fetch('/api/admin/showtags', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ on: !on }),
|
||
});
|
||
if (res.ok) location.reload();
|
||
else alert('Operazione non riuscita.');
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verifica build**
|
||
|
||
Run: `npm run build`
|
||
Expected: build OK.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/pages/api/admin/showtags.ts src/components/Header.astro
|
||
git commit -m "feat(showtags): endpoint cookie + toggle nel menu utente"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Badge con GUID e modal di modifica inline
|
||
|
||
**Files:**
|
||
- Modify: `src/layouts/Base.astro` (blocco overlay `{showTags && (...)}`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: elementi con `data-tag`/`data-guid` nel DOM; `GET /api/admin/content/[tag]` (Task 6); `PUT /api/admin/content/[tag]` e `POST/DELETE /api/admin/content/[tag]/image` (esistenti); `showTags` (Task 8).
|
||
- Produces: overlay con badge (nome tag + guid abbreviato + matita) e modal di modifica in pagina.
|
||
|
||
- [ ] **Step 1: Sostituisci l'overlay in `Base.astro`**
|
||
|
||
Rimpiazza l'intero blocco `{showTags && ( ... )}` (ex `{annotate && (...)}`, righe ~77-102) con:
|
||
|
||
```astro
|
||
{showTags && (
|
||
<style is:global>
|
||
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
|
||
.tag-badge { position: absolute; z-index: 9999; display: inline-flex; align-items: center; gap: 4px;
|
||
background: #3a2929; color: #fff; font: 600 10px/1.6 monospace; padding: 0 4px 0 6px; opacity: .85; }
|
||
.tag-badge:hover { opacity: 1; }
|
||
.tag-badge__edit { background: #9c8b70; color: #fff; border: 0; cursor: pointer; font: inherit; padding: 0 5px; }
|
||
.tag-modal { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; }
|
||
.tag-modal[hidden] { display: none; }
|
||
.tag-modal__box { background: #fff; color: #222; width: min(560px, 92vw); max-height: 88vh; overflow: auto; padding: 20px; border-radius: 4px; }
|
||
.tag-modal__box h3 { margin: 0 0 4px; font: 600 14px monospace; }
|
||
.tag-modal__box .guid { font: 11px monospace; color: #888; margin: 0 0 14px; }
|
||
.tag-modal__box textarea { width: 100%; min-height: 140px; font: inherit; padding: 8px; box-sizing: border-box; }
|
||
.tag-modal__box select { margin: 6px 8px 6px 0; }
|
||
.tag-modal__row { display: flex; gap: 10px; margin-top: 14px; }
|
||
.tag-modal__row button { padding: 9px 16px; border: 0; cursor: pointer; }
|
||
.tag-modal__save { background: #3a2d26; color: #fff; }
|
||
.tag-modal__cancel { background: #ddd; }
|
||
.tag-modal__msg { color: #a33; margin-top: 8px; min-height: 18px; }
|
||
</style>
|
||
<div class="tag-modal" id="tag-modal" hidden>
|
||
<div class="tag-modal__box">
|
||
<h3 id="tm-tag"></h3>
|
||
<p class="guid" id="tm-guid"></p>
|
||
<div id="tm-body"></div>
|
||
<div class="tag-modal__msg" id="tm-msg"></div>
|
||
<div class="tag-modal__row">
|
||
<button type="button" class="tag-modal__save" id="tm-save">Salva</button>
|
||
<button type="button" class="tag-modal__cancel" id="tm-cancel">Annulla</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script is:inline>
|
||
(() => {
|
||
const STYLE_GROUPS = { size: ['s','m','l'], color: ['accent','accent-dark','dark','heading','text','text-light','white'], weight: ['normal','bold'], style: ['normal','italic'], align: ['left','center','right'] };
|
||
const modal = document.getElementById('tag-modal');
|
||
const tmTag = document.getElementById('tm-tag');
|
||
const tmGuid = document.getElementById('tm-guid');
|
||
const tmBody = document.getElementById('tm-body');
|
||
const tmMsg = document.getElementById('tm-msg');
|
||
const tmSave = document.getElementById('tm-save');
|
||
let current = null; // { tag, type, el }
|
||
|
||
function closeModal() { modal.hidden = true; tmBody.innerHTML = ''; tmMsg.textContent = ''; current = null; }
|
||
document.getElementById('tm-cancel').addEventListener('click', closeModal);
|
||
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
|
||
|
||
async function openModal(tag, el) {
|
||
tmMsg.textContent = '';
|
||
let data;
|
||
try {
|
||
const res = await fetch('/api/admin/content/' + encodeURIComponent(tag));
|
||
if (!res.ok) { alert('Impossibile caricare il contenuto.'); return; }
|
||
data = await res.json();
|
||
} catch { alert('Errore di rete.'); return; }
|
||
current = { tag: data.tag, type: data.type, el };
|
||
tmTag.textContent = data.tag;
|
||
tmGuid.textContent = data.guid || '';
|
||
tmBody.innerHTML = '';
|
||
if (data.type === 'image') {
|
||
const inp = document.createElement('input');
|
||
inp.type = 'file'; inp.accept = 'image/*'; inp.id = 'tm-file';
|
||
tmBody.appendChild(inp);
|
||
const del = document.createElement('button');
|
||
del.type = 'button'; del.textContent = 'Rimuovi immagine'; del.id = 'tm-del';
|
||
del.style.cssText = 'margin-top:10px;padding:8px 14px;border:0;background:#a33;color:#fff;cursor:pointer';
|
||
tmBody.appendChild(del);
|
||
del.addEventListener('click', async () => {
|
||
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'DELETE' });
|
||
if (r.ok) { location.reload(); } else { tmMsg.textContent = 'Rimozione non riuscita.'; }
|
||
});
|
||
} else {
|
||
const ta = document.createElement('textarea');
|
||
ta.id = 'tm-value'; ta.value = data.value;
|
||
tmBody.appendChild(ta);
|
||
(data.styleOptions || []).forEach((group) => {
|
||
const opts = STYLE_GROUPS[group];
|
||
if (!opts) return;
|
||
const sel = document.createElement('select');
|
||
sel.dataset.group = group;
|
||
sel.className = 'tm-style';
|
||
const none = document.createElement('option');
|
||
none.value = ''; none.textContent = group + ': —';
|
||
sel.appendChild(none);
|
||
opts.forEach((v) => { const o = document.createElement('option'); o.value = v; o.textContent = group + ': ' + v; sel.appendChild(o); });
|
||
tmBody.appendChild(sel);
|
||
});
|
||
}
|
||
modal.hidden = false;
|
||
}
|
||
|
||
tmSave.addEventListener('click', async () => {
|
||
if (!current) return;
|
||
tmMsg.textContent = '';
|
||
if (current.type === 'image') {
|
||
const file = document.getElementById('tm-file').files[0];
|
||
if (!file) { tmMsg.textContent = 'Scegli un file.'; return; }
|
||
const fd = new FormData(); fd.append('file', file);
|
||
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag) + '/image', { method: 'POST', body: fd });
|
||
if (r.ok) { location.reload(); } else { tmMsg.textContent = (await r.json()).error || 'Upload non riuscito.'; }
|
||
return;
|
||
}
|
||
const value = document.getElementById('tm-value').value;
|
||
const styles = {};
|
||
document.querySelectorAll('.tm-style').forEach((s) => { if (s.value) styles[s.dataset.group] = s.value; });
|
||
const body = { value };
|
||
if (Object.keys(styles).length) body.styles = styles;
|
||
const r = await fetch('/api/admin/content/' + encodeURIComponent(current.tag), {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||
});
|
||
if (!r.ok) { tmMsg.textContent = (await r.json()).error || 'Salvataggio non riuscito.'; return; }
|
||
const saved = await r.json();
|
||
// Aggiorna in-place il contenuto nel DOM.
|
||
if (current.type === 'html') current.el.innerHTML = saved.value;
|
||
else current.el.textContent = saved.value;
|
||
closeModal();
|
||
});
|
||
|
||
document.querySelectorAll('[data-tag]').forEach((el) => {
|
||
const tag = el.dataset.tag;
|
||
const guid = el.dataset.guid || '';
|
||
const badge = document.createElement('span');
|
||
badge.className = 'tag-badge';
|
||
const label = document.createElement('span');
|
||
label.textContent = guid ? tag + ' · ' + guid.slice(0, 8) : tag;
|
||
const edit = document.createElement('button');
|
||
edit.type = 'button'; edit.className = 'tag-badge__edit'; edit.textContent = '✎';
|
||
edit.addEventListener('click', () => openModal(tag, el));
|
||
badge.appendChild(label); badge.appendChild(edit);
|
||
document.body.appendChild(badge);
|
||
const place = () => {
|
||
const r = el.getBoundingClientRect();
|
||
badge.style.top = window.scrollY + r.top - 16 + 'px';
|
||
badge.style.left = window.scrollX + r.left + 'px';
|
||
};
|
||
place();
|
||
addEventListener('scroll', place, { passive: true });
|
||
addEventListener('resize', place);
|
||
});
|
||
})();
|
||
</script>
|
||
)}
|
||
```
|
||
|
||
- [ ] **Step 2: Verifica build**
|
||
|
||
Run: `npm run build`
|
||
Expected: build OK.
|
||
|
||
- [ ] **Step 3: Verifica manuale in dev (superuser)**
|
||
|
||
Crea un utente superuser locale: `npm run create-user -- super prova1234 superuser`. Avvia dev (`fuser -k 4321/tcp; npm run dev &`), fai login su `/login`, attiva "Mostra tag", verifica i badge con guid e apri il modal su un tag testo: modifica → salva → il testo cambia in pagina senza reload. Ferma il server. Documenta l'esito nel report.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/layouts/Base.astro
|
||
git commit -m "feat(inline): badge con guid + modal di modifica contenuti in pagina"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Nav admin per 3 ruoli e blog filtrato per autore
|
||
|
||
**Files:**
|
||
- Modify: `src/layouts/Admin.astro`
|
||
- Modify: `src/pages/admin/index.astro`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `listAllPosts`, `listByAuthor` (posts.ts), `listUsers` (auth.ts), `Astro.locals.user`.
|
||
|
||
- [ ] **Step 1: Nav admin per ruolo**
|
||
|
||
In `src/layouts/Admin.astro`, sostituisci le due righe dentro `<span>` (righe ~34-35) con:
|
||
|
||
```astro
|
||
{user && (
|
||
<>
|
||
{`Ciao, ${user.username}`}
|
||
<a href="/admin">Articoli</a>
|
||
{(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</a>}
|
||
{user.role === 'admin' && <a href="/admin/users">Utenti</a>}
|
||
<a href="/admin/new">Nuovo</a>
|
||
<a href="/admin/logout">Esci</a>
|
||
<a href="/blog" target="_blank">Vedi sito ↗</a>
|
||
</>
|
||
)}
|
||
```
|
||
|
||
- [ ] **Step 2: Blog admin filtrato + colonna autore**
|
||
|
||
Sostituisci il frontmatter di `src/pages/admin/index.astro`:
|
||
|
||
```astro
|
||
---
|
||
import Admin from '../../layouts/Admin.astro';
|
||
import { getDb } from '../../lib/db';
|
||
import { listAllPosts, listByAuthor } from '../../lib/posts';
|
||
import { listUsers } from '../../lib/auth';
|
||
export const prerender = false;
|
||
const user = Astro.locals.user!;
|
||
const isManager = user.role === 'superuser' || user.role === 'admin';
|
||
const posts = isManager ? listAllPosts(getDb()) : listByAuthor(getDb(), user.id);
|
||
const nameById = new Map(listUsers(getDb()).map((u) => [u.id, u.username]));
|
||
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'short', timeStyle: 'short' });
|
||
---
|
||
```
|
||
|
||
Nella `<thead>` aggiungi la colonna Autore solo per i manager, e nella riga il valore. Sostituisci la tabella:
|
||
|
||
```astro
|
||
<table>
|
||
<thead><tr><th>Titolo</th><th>Categoria</th>{isManager && <th>Autore</th>}<th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
|
||
<tbody>
|
||
{posts.map((p) => (
|
||
<tr>
|
||
<td><a href={`/admin/edit/${p.id}`}>{p.title}</a></td>
|
||
<td>{p.category}</td>
|
||
{isManager && <td>{p.author_id ? (nameById.get(p.author_id) ?? 'Staff InsanityLab') : 'Staff InsanityLab'}</td>}
|
||
<td><span class:list={['pill', { 'pill--draft': p.draft }]}>{p.draft ? 'Bozza' : 'Pubblicato'}</span></td>
|
||
<td>{fmt.format(new Date(p.updated_at.replace(' ', 'T') + 'Z'))}</td>
|
||
<td><button class="abtn abtn--danger" data-del={p.id}>Elimina</button></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
```
|
||
|
||
- [ ] **Step 3: Verifica build**
|
||
|
||
Run: `npm run build`
|
||
Expected: build OK.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/layouts/Admin.astro src/pages/admin/index.astro
|
||
git commit -m "feat(admin): nav per 3 ruoli, blog filtrato per autore + colonna autore"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Firma autore nel blog pubblico
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/blog/[slug].astro`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `getUsernameById` (auth.ts), `post.author_id`.
|
||
|
||
- [ ] **Step 1: Aggiungi la firma**
|
||
|
||
In `src/pages/blog/[slug].astro`, aggiungi l'import e calcola l'autore nel frontmatter:
|
||
|
||
```astro
|
||
import { getUsernameById } from '../../lib/auth';
|
||
```
|
||
|
||
Dopo `const post = getPublishedBySlug(...)` e il check 404, aggiungi:
|
||
|
||
```typescript
|
||
const author = post.author_id ? getUsernameById(getDb(), post.author_id) : null;
|
||
const byline = author ?? 'Staff InsanityLab';
|
||
```
|
||
|
||
Nel markup, cambia la riga meta per includere la firma:
|
||
|
||
```astro
|
||
<p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category} · di {byline}</p>
|
||
```
|
||
|
||
- [ ] **Step 2: Verifica build**
|
||
|
||
Run: `npm run build`
|
||
Expected: build OK.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/pages/blog/[slug].astro
|
||
git commit -m "feat(blog): firma autore sotto il titolo, fallback Staff InsanityLab"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: Gestione utenti — pagina e API
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/auth.ts` (helper gestione)
|
||
- Create: `src/pages/api/admin/users/index.ts`
|
||
- Create: `src/pages/api/admin/users/[id]/role.ts`
|
||
- Create: `src/pages/api/admin/users/[id]/reset-password.ts`
|
||
- Create: `src/pages/api/admin/users/[id]/index.ts`
|
||
- Create: `src/pages/admin/users.astro`
|
||
- Test: `tests/users.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `createUser`, `hashPassword`, `listUsers` (auth.ts), `Astro.locals.user`.
|
||
- Produces in `auth.ts`:
|
||
- `countAdmins(db): number`
|
||
- `getUserById(db, id): { id: number; username: string; role: string } | null`
|
||
- `updateUserRole(db, id, role: Role): void`
|
||
- `updateUserPassword(db, id, hash: string): void`
|
||
- `deleteUser(db, id): void`
|
||
- `randomPassword(): string`
|
||
- `isRole(v: unknown): v is Role`
|
||
|
||
- [ ] **Step 1: Test degli helper gestione**
|
||
|
||
Crea `tests/users.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach } from 'vitest';
|
||
import type Database from 'better-sqlite3';
|
||
import { createDb } from '../src/lib/db';
|
||
import {
|
||
createUser, countAdmins, getUserById, updateUserRole,
|
||
updateUserPassword, deleteUser, randomPassword, isRole, hashPassword, verifyPassword,
|
||
} from '../src/lib/auth';
|
||
|
||
let db: Database.Database;
|
||
beforeEach(() => { db = createDb(':memory:'); });
|
||
|
||
describe('gestione utenti', () => {
|
||
it('countAdmins conta i soli admin', () => {
|
||
createUser(db, 'a1', 'segreta123', 'admin');
|
||
createUser(db, 's1', 'segreta123', 'superuser');
|
||
expect(countAdmins(db)).toBe(1);
|
||
});
|
||
|
||
it('updateUserRole e getUserById', () => {
|
||
const id = createUser(db, 'u1', 'segreta123', 'user');
|
||
updateUserRole(db, id, 'superuser');
|
||
expect(getUserById(db, id)!.role).toBe('superuser');
|
||
});
|
||
|
||
it('updateUserPassword cambia l’hash', () => {
|
||
const id = createUser(db, 'u2', 'vecchia123', 'user');
|
||
updateUserPassword(db, id, hashPassword('nuova12345'));
|
||
const hash = (db.prepare('SELECT password_hash AS h FROM users WHERE id = ?').get(id) as { h: string }).h;
|
||
expect(verifyPassword('nuova12345', hash)).toBe(true);
|
||
});
|
||
|
||
it('deleteUser rimuove', () => {
|
||
const id = createUser(db, 'u3', 'segreta123', 'user');
|
||
deleteUser(db, id);
|
||
expect(getUserById(db, id)).toBeNull();
|
||
});
|
||
|
||
it('randomPassword genera almeno 12 caratteri', () => {
|
||
expect(randomPassword().length).toBeGreaterThanOrEqual(12);
|
||
});
|
||
|
||
it('isRole valida i ruoli', () => {
|
||
expect(isRole('admin')).toBe(true);
|
||
expect(isRole('superuser')).toBe(true);
|
||
expect(isRole('user')).toBe(true);
|
||
expect(isRole('editor')).toBe(false);
|
||
expect(isRole(3)).toBe(false);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Esegui e verifica il fallimento**
|
||
|
||
Run: `npm test -- tests/users.test.ts`
|
||
Expected: FAIL — helper non esistono.
|
||
|
||
- [ ] **Step 3: Aggiungi gli helper in `auth.ts`**
|
||
|
||
```typescript
|
||
export function countAdmins(db: Database.Database): number {
|
||
return (db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get() as { n: number }).n;
|
||
}
|
||
|
||
export function getUserById(db: Database.Database, id: number): { id: number; username: string; role: string } | null {
|
||
const row = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(id) as
|
||
{ id: number; username: string; role: string } | undefined;
|
||
return row ?? null;
|
||
}
|
||
|
||
export function updateUserRole(db: Database.Database, id: number, role: Role): void {
|
||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
||
}
|
||
|
||
export function updateUserPassword(db: Database.Database, id: number, hash: string): void {
|
||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, id);
|
||
}
|
||
|
||
export function deleteUser(db: Database.Database, id: number): void {
|
||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||
}
|
||
|
||
export function randomPassword(): string {
|
||
return randomBytes(9).toString('base64url'); // ~12 caratteri
|
||
}
|
||
|
||
export function isRole(v: unknown): v is Role {
|
||
return v === 'admin' || v === 'superuser' || v === 'user';
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Esegui e verifica il successo**
|
||
|
||
Run: `npm test -- tests/users.test.ts`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Crea l'API di creazione/lista**
|
||
|
||
Crea `src/pages/api/admin/users/index.ts`:
|
||
|
||
```typescript
|
||
import type { APIRoute } from 'astro';
|
||
import { getDb } from '../../../../lib/db';
|
||
import { createUser, listUsers, isRole } from '../../../../lib/auth';
|
||
export const prerender = false;
|
||
|
||
const json = (status: number, body: object) =>
|
||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||
|
||
export const GET: APIRoute = () => json(200, { users: listUsers(getDb()) });
|
||
|
||
export const POST: APIRoute = async ({ request }) => {
|
||
let data: { username?: unknown; password?: unknown; role?: unknown };
|
||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||
const username = String(data.username ?? '').trim();
|
||
const password = String(data.password ?? '');
|
||
if (username.length < 2) return json(400, { error: 'Nome utente troppo corto.' });
|
||
if (password.length < 8) return json(400, { error: 'Password troppo corta (min 8).' });
|
||
if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' });
|
||
try {
|
||
const id = createUser(getDb(), username, password, data.role);
|
||
return json(201, { id });
|
||
} catch (err: any) {
|
||
if (String(err?.message).includes('UNIQUE')) return json(400, { error: 'Nome utente già esistente.' });
|
||
throw err;
|
||
}
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 6: Crea l'API cambio ruolo (con guardia ultimo admin)**
|
||
|
||
Crea `src/pages/api/admin/users/[id]/role.ts`:
|
||
|
||
```typescript
|
||
import type { APIRoute } from 'astro';
|
||
import { getDb } from '../../../../../lib/db';
|
||
import { getUserById, updateUserRole, countAdmins, isRole } from '../../../../../lib/auth';
|
||
export const prerender = false;
|
||
|
||
const json = (status: number, body: object) =>
|
||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||
|
||
export const PUT: APIRoute = async ({ params, request }) => {
|
||
const id = Number(params.id);
|
||
const target = getUserById(getDb(), id);
|
||
if (!target) return json(404, { error: 'Utente inesistente.' });
|
||
let data: { role?: unknown };
|
||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||
if (!isRole(data.role)) return json(400, { error: 'Ruolo non valido.' });
|
||
if (target.role === 'admin' && data.role !== 'admin' && countAdmins(getDb()) <= 1) {
|
||
return json(400, { error: 'Deve restare almeno un admin.' });
|
||
}
|
||
updateUserRole(getDb(), id, data.role);
|
||
return json(200, { ok: true });
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 7: Crea l'API reset password**
|
||
|
||
Crea `src/pages/api/admin/users/[id]/reset-password.ts`:
|
||
|
||
```typescript
|
||
import type { APIRoute } from 'astro';
|
||
import { getDb } from '../../../../../lib/db';
|
||
import { getUserById, updateUserPassword, hashPassword, randomPassword } from '../../../../../lib/auth';
|
||
export const prerender = false;
|
||
|
||
const json = (status: number, body: object) =>
|
||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||
|
||
export const POST: APIRoute = ({ params }) => {
|
||
const id = Number(params.id);
|
||
if (!getUserById(getDb(), id)) return json(404, { error: 'Utente inesistente.' });
|
||
const password = randomPassword();
|
||
updateUserPassword(getDb(), id, hashPassword(password));
|
||
// La password in chiaro è restituita UNA sola volta: l'admin la copia e la consegna.
|
||
return json(200, { password });
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 8: Crea l'API elimina (guardie self + ultimo admin)**
|
||
|
||
Crea `src/pages/api/admin/users/[id]/index.ts`:
|
||
|
||
```typescript
|
||
import type { APIRoute } from 'astro';
|
||
import { getDb } from '../../../../../lib/db';
|
||
import { getUserById, deleteUser, countAdmins } from '../../../../../lib/auth';
|
||
export const prerender = false;
|
||
|
||
const json = (status: number, body: object) =>
|
||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||
|
||
export const DELETE: APIRoute = ({ params, locals }) => {
|
||
const id = Number(params.id);
|
||
const target = getUserById(getDb(), id);
|
||
if (!target) return json(404, { error: 'Utente inesistente.' });
|
||
if (id === locals.user!.id) return json(400, { error: 'Non puoi eliminare te stesso.' });
|
||
if (target.role === 'admin' && countAdmins(getDb()) <= 1) {
|
||
return json(400, { error: 'Deve restare almeno un admin.' });
|
||
}
|
||
deleteUser(getDb(), id);
|
||
return json(200, { ok: true });
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 9: Crea la pagina `/admin/users`**
|
||
|
||
Crea `src/pages/admin/users.astro`:
|
||
|
||
```astro
|
||
---
|
||
import Admin from '../../layouts/Admin.astro';
|
||
import { getDb } from '../../lib/db';
|
||
import { listUsers } from '../../lib/auth';
|
||
export const prerender = false;
|
||
const users = listUsers(getDb());
|
||
const me = Astro.locals.user!;
|
||
---
|
||
<Admin title="Utenti">
|
||
<h1>Utenti</h1>
|
||
|
||
<h2>Nuovo utente</h2>
|
||
<form id="new-user" style="max-width:420px">
|
||
<input class="afield" name="username" placeholder="Nome utente" required />
|
||
<input class="afield" name="password" type="text" placeholder="Password (min 8)" required />
|
||
<select class="afield" name="role">
|
||
<option value="user">user</option>
|
||
<option value="superuser">superuser</option>
|
||
<option value="admin">admin</option>
|
||
</select>
|
||
<button class="abtn" type="submit">Crea</button>
|
||
<span class="form-msg" id="new-msg"></span>
|
||
</form>
|
||
|
||
<h2 style="margin-top:30px">Elenco</h2>
|
||
<table>
|
||
<thead><tr><th>Utente</th><th>Ruolo</th><th></th></tr></thead>
|
||
<tbody>
|
||
{users.map((u) => (
|
||
<tr data-id={u.id}>
|
||
<td>{u.username}{u.id === me.id && ' (tu)'}</td>
|
||
<td>
|
||
<select class="role-sel" data-id={u.id}>
|
||
{['user', 'superuser', 'admin'].map((r) => <option value={r} selected={u.role === r}>{r}</option>)}
|
||
</select>
|
||
</td>
|
||
<td>
|
||
<button class="abtn" data-reset={u.id}>Reset password</button>
|
||
{u.id !== me.id && <button class="abtn abtn--danger" data-del={u.id}>Elimina</button>}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
<p class="form-msg" id="row-msg"></p>
|
||
</Admin>
|
||
|
||
<script>
|
||
const rowMsg = document.getElementById('row-msg')!;
|
||
const show = (el: HTMLElement, text: string, ok = false) => {
|
||
el.textContent = text;
|
||
el.className = 'form-msg ' + (ok ? 'form-msg--ok' : 'form-msg--err');
|
||
};
|
||
|
||
document.getElementById('new-user')!.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const f = e.target as HTMLFormElement;
|
||
const body = {
|
||
username: (f.elements.namedItem('username') as HTMLInputElement).value,
|
||
password: (f.elements.namedItem('password') as HTMLInputElement).value,
|
||
role: (f.elements.namedItem('role') as HTMLSelectElement).value,
|
||
};
|
||
const res = await fetch('/api/admin/users', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||
});
|
||
const msg = document.getElementById('new-msg') as HTMLElement;
|
||
if (res.ok) { show(msg, 'Creato ✓', true); setTimeout(() => location.reload(), 600); }
|
||
else show(msg, (await res.json()).error ?? 'Errore');
|
||
});
|
||
|
||
document.querySelectorAll<HTMLSelectElement>('.role-sel').forEach((sel) =>
|
||
sel.addEventListener('change', async () => {
|
||
const res = await fetch(`/api/admin/users/${sel.dataset.id}/role`, {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: sel.value }),
|
||
});
|
||
if (res.ok) show(rowMsg, 'Ruolo aggiornato ✓', true);
|
||
else { show(rowMsg, (await res.json()).error ?? 'Errore'); location.reload(); }
|
||
}));
|
||
|
||
document.querySelectorAll<HTMLButtonElement>('[data-reset]').forEach((b) =>
|
||
b.addEventListener('click', async () => {
|
||
if (!confirm('Generare una nuova password per questo utente?')) return;
|
||
const res = await fetch(`/api/admin/users/${b.dataset.reset}/reset-password`, { method: 'POST' });
|
||
if (res.ok) { const { password } = await res.json(); prompt('Nuova password (copiala e consegnala ora):', password); }
|
||
else show(rowMsg, (await res.json()).error ?? 'Errore');
|
||
}));
|
||
|
||
document.querySelectorAll<HTMLButtonElement>('[data-del]').forEach((b) =>
|
||
b.addEventListener('click', async () => {
|
||
if (!confirm('Eliminare definitivamente questo utente?')) return;
|
||
const res = await fetch(`/api/admin/users/${b.dataset.del}`, { method: 'DELETE' });
|
||
if (res.ok) location.reload();
|
||
else show(rowMsg, (await res.json()).error ?? 'Errore');
|
||
}));
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 10: Verifica build + test**
|
||
|
||
Run: `npm run build && npm test`
|
||
Expected: build OK, tutti i test verdi.
|
||
|
||
- [ ] **Step 11: Commit**
|
||
|
||
```bash
|
||
git add src/lib/auth.ts src/pages/api/admin/users src/pages/admin/users.astro tests/users.test.ts
|
||
git commit -m "feat(users): gestione utenti /admin/users con guardie self/ultimo-admin"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: Smoke esteso e verifica end-to-end
|
||
|
||
**Files:**
|
||
- Modify: `scripts/smoke.sh`
|
||
|
||
**Interfaces:**
|
||
- Consumes: build di produzione, utenti locali di prova.
|
||
|
||
- [ ] **Step 1: Estendi lo smoke**
|
||
|
||
In `scripts/smoke.sh`, aggiungi controlli (adeguandoti allo stile e alle variabili già presenti nel file — porta, `BASE`, helper di asserzione esistenti). Aggiungi:
|
||
|
||
```bash
|
||
# /login pubblico raggiungibile
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/login")
|
||
[ "$code" = "200" ] || { echo "FAIL: /login $code"; exit 1; }
|
||
|
||
# Header mostra "Accedi" da anonimo
|
||
curl -s "$BASE/" | grep -q 'Accedi' || { echo "FAIL: link Accedi assente"; exit 1; }
|
||
|
||
# Nessun badge/modal da anonimo
|
||
if curl -s "$BASE/" | grep -q 'tag-badge'; then echo "FAIL: badge visibile da anonimo"; exit 1; fi
|
||
|
||
# Rotta gestione utenti protetta (redirect al login da anonimo)
|
||
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/admin/users")
|
||
[ "$code" = "302" ] || { echo "FAIL: /admin/users non protetta ($code)"; exit 1; }
|
||
|
||
echo "smoke ruoli/login/inline OK"
|
||
```
|
||
|
||
- [ ] **Step 2: Esegui lo smoke sulla build di produzione**
|
||
|
||
Run: `fuser -k 4399/tcp 2>/dev/null; npm run build && bash scripts/smoke.sh`
|
||
Expected: exit 0, riga finale `smoke ruoli/login/inline OK`. (Ricorda: `fuser -k 4399/tcp` prima, per evitare il falso positivo del server stantio.)
|
||
|
||
- [ ] **Step 3: Verifica funzionale locale completa**
|
||
|
||
Crea tre utenti: `npm run create-user -- u1 prova1234 user`, `npm run create-user -- s1 prova1234 superuser` (l'admin esiste già). Con dev server, verifica in browser:
|
||
- `user`: vede solo i propri articoli, non `/admin/content` (redirect), non `/admin/users`; crea un articolo → compare firmato.
|
||
- `superuser`: vede tutti gli articoli con colonna Autore, apre `/admin/content`, attiva "Mostra tag" e modifica un tag inline; non vede `/admin/users`.
|
||
- `admin`: apre `/admin/users`, crea un utente, reset password (mostra la password una volta), cambia ruolo, prova a eliminare se stesso (bloccato) e prova a declassare l'unico admin (bloccato).
|
||
Documenta gli esiti nel report del task.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add scripts/smoke.sh
|
||
git commit -m "test(smoke): login pubblico, badge, protezione /admin/users"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Deploy in produzione e verifica
|
||
|
||
**Files:** nessuna modifica di codice.
|
||
|
||
**Interfaces:**
|
||
- Consumes: repo Gitea, VPS `/opt/docker/insanitylab/src`.
|
||
|
||
- [ ] **Step 1: Push del branch/merge in main**
|
||
|
||
Segui la convenzione del progetto (merge ff in `main` come nei piani precedenti) e push su Gitea. Verifica albero pulito con `git status`.
|
||
|
||
- [ ] **Step 2: Deploy sul VPS**
|
||
|
||
Run (via ssh `HOSTINGER_Tielogic_XYZ`): `cd /opt/docker/insanitylab/src && git pull && docker compose up -d --build`
|
||
Expected: build immagine OK, container `insanitylab` up.
|
||
|
||
- [ ] **Step 3: Verifica la migrazione dei ruoli in produzione**
|
||
|
||
L'utente `editor` di produzione deve risultare `superuser` dopo il boot (la migrazione gira in `createDb`). Verifica:
|
||
Run: `docker compose exec -T insanitylab node -e "const D=require('better-sqlite3');const db=new D(process.env.DB_PATH||'data/insanitylab.db');console.log(db.prepare('SELECT username, role FROM users').all())"`
|
||
Expected: nessun ruolo `editor`; l'ex editor è `superuser`.
|
||
|
||
- [ ] **Step 4: Verifica funzionale in produzione**
|
||
|
||
- `https://insanitylab.tielogic.xyz/login` → 200, form stile sito.
|
||
- Login con l'ex editor (ora superuser): header mostra il nome, "Mostra tag" attivabile, modal inline salva un tag e il sito riflette la modifica; poi ripristina il valore.
|
||
- `https://insanitylab.tielogic.xyz/admin/users` con l'admin: crea/reset/ruolo/elimina funzionano; guardie attive.
|
||
- Un GUID è presente come `data-guid` nel sorgente della home.
|
||
- Blog pubblico: un articolo con autore mostra la firma.
|
||
Documenta gli esiti.
|
||
|
||
- [ ] **Step 5: Aggiorna la memoria di progetto**
|
||
|
||
Aggiorna `~/.claude/projects/-home-adriano-Documenti-Clienti-InsanityLab/memory/insanitylab-stato-progetto.md` con: sistema 3 ruoli, login pubblico, GUID contenuti, modifica inline, gestione utenti — deployati. Nessuna credenziale nei file di memoria.
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage:**
|
||
- Ruoli + matrice → Task 1 (migrazione), Task 2 (matrice), Task 3 (enforcement). ✓
|
||
- GUID interno + nome leggibile → Task 1 (colonna/backfill), Task 6 (resolve/componenti/GET). ✓
|
||
- editor → superuser → Task 1. ✓
|
||
- Pagina `/login` + `?next` sicuro + alias `/admin/login` → Task 7 (alias resta: `/admin/login.astro` non rimosso). ✓
|
||
- Nome nel header + menu → Task 8. ✓
|
||
- Blog ownership (solo propri per user, tutti per superuser/admin, firma autore) → Task 4/5 (repo+API), Task 11 (lista), Task 12 (firma pubblica). ✓
|
||
- Toggle "Mostra tag" persistente + badge + modifica inline modal → Task 9 (toggle/cookie), Task 10 (badge+modal). ✓
|
||
- Gestione utenti completa (crea, reset psw una-volta, ruolo, elimina; guardie self/ultimo-admin) → Task 13. ✓
|
||
- Testing (unit matrice, ownership, migrazioni, gestione utenti; smoke) → Task 1/2/4/5/6/13 + Task 14. ✓
|
||
- Fuori scope (no framework, no tabella permessi, no self-registration) → rispettato: tutto vanilla, ruoli fissi. ✓
|
||
|
||
**Placeholder scan:** nessun TBD/TODO; ogni step ha codice o comando reale. Le due note di contingenza (routing GET in Task 6, stato pulsante in Task 9) contengono entrambe la correzione esatta da applicare, non un rimando generico.
|
||
|
||
**Type consistency:** `Role` unica definizione (Task 2), riusata da `createUser`/`updateUserRole`/`isRole`. `canModifyPost(role, userId, post)` firma coerente tra Task 5 (def) e API. `resolveContent` ritorna `guid` (Task 6) consumato da T/TImg e — indirettamente via query — dal GET singolo. `listUsers`/`getUsernameById`/`getUserById`/`countAdmins` definiti in Task 4/13 e consumati da Task 11/12/13 con le stesse firme. `safeNext` (Task 7) usato solo in `/login`.
|
||
|
||
## Execution Handoff
|
||
|
||
Salvo qui sotto. Due opzioni di esecuzione:
|
||
|
||
1. **Subagent-Driven (consigliata)** — un subagent fresco per task + review in due fasi tra un task e l'altro, iterazione rapida.
|
||
2. **Inline** — eseguo i task in questa sessione con checkpoint di review a batch.
|