Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ed7169b88 | |||
| 7211d74d3a | |||
| e2bbc9074f | |||
| be77285613 | |||
| c36ad4cb2d | |||
| 0f2b2ba014 | |||
| 2bf6b3d4de | |||
| 22d72be74d | |||
| 842371604b | |||
| 71cd1626e0 | |||
| 6b85872515 | |||
| 5ce10da9bd | |||
| c3267c9be2 | |||
| 6d1e2607ee | |||
| db92fc9c02 | |||
| e87a464090 | |||
| 5cea895d51 | |||
| d4cf359805 | |||
| d0fa75a3d3 | |||
| 6c225281e0 | |||
| 8ceea9b16f |
@@ -10,4 +10,9 @@ export default defineConfig({
|
||||
allowedDomains: [{ hostname: 'insanitylab.tielogic.xyz', protocol: 'https' }],
|
||||
},
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
// La sezione "Programmi" è stata rinominata "Servizi": redirect permanenti dai vecchi URL.
|
||||
redirects: {
|
||||
'/programs': { status: 301, destination: '/services' },
|
||||
'/programs/[slug]': { status: 301, destination: '/services/[slug]' },
|
||||
},
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -8,8 +8,8 @@ if (!username || !password) {
|
||||
console.error('Uso: npm run create-user -- <username> <password> [role]');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!['admin', 'editor'].includes(role)) {
|
||||
console.error('Ruolo non valido. Ammessi: admin, editor');
|
||||
if (!['admin', 'superuser', 'user'].includes(role)) {
|
||||
console.error('Ruolo non valido. Ammessi: admin, superuser, user');
|
||||
process.exit(1);
|
||||
}
|
||||
const path = process.env.DB_PATH ?? 'data/insanitylab.db';
|
||||
|
||||
+21
-1
@@ -6,7 +6,7 @@ PORT=4399 node --env-file=.env ./dist/server/entry.mjs & SRV=$!
|
||||
trap 'kill $SRV' EXIT
|
||||
sleep 2
|
||||
fail=0
|
||||
for path in / /about /training /programs /programs/performance-class /programs/rehab /blog /contact /admin/login /robots.txt; do
|
||||
for path in / /about /training /services /services/performance-class /services/rehab /blog /contact /admin/login /robots.txt; do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4399$path")
|
||||
echo "$code $path"
|
||||
[ "$code" = "200" ] || fail=1
|
||||
@@ -28,4 +28,24 @@ echo "$tags righe con data-tag= in home (atteso >= 1)"
|
||||
badges=$(curl -s 'http://localhost:4399/?annotate=1' | grep -c 'tag-badge' || true)
|
||||
echo "$badges tag-badge da anonimo con ?annotate=1 (atteso 0)"
|
||||
[ "$badges" = "0" ] || fail=1
|
||||
|
||||
# /login pubblico raggiungibile
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/login)
|
||||
echo "$code /login"
|
||||
[ "$code" = "200" ] || fail=1
|
||||
|
||||
# Header mostra "Accedi" da anonimo
|
||||
curl -s http://localhost:4399/ | grep -q 'Accedi' || { echo "FAIL: link Accedi assente"; fail=1; }
|
||||
|
||||
# Nessun badge tag-badge da anonimo sulla home semplice
|
||||
badges2=$(curl -s http://localhost:4399/ | grep -c 'tag-badge' || true)
|
||||
echo "$badges2 tag-badge da anonimo in home semplice (atteso 0)"
|
||||
[ "$badges2" = "0" ] || fail=1
|
||||
|
||||
# Rotta gestione utenti protetta (redirect al login da anonimo)
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:4399/admin/users)
|
||||
echo "$code /admin/users (atteso 302)"
|
||||
[ "$code" = "302" ] || fail=1
|
||||
|
||||
echo "smoke ruoli/login/inline OK"
|
||||
exit $fail
|
||||
|
||||
@@ -19,9 +19,9 @@ const year = new Date().getFullYear();
|
||||
<T tag="footer.col.training-title" as="h4" />
|
||||
{site.footerTraining.map((l, i) => <a href={l.href}>{t(`footer.training.${i + 1}.label`)}</a>)}
|
||||
</nav>
|
||||
<nav aria-label="programmi">
|
||||
<T tag="footer.col.programs-title" as="h4" />
|
||||
{site.footerPrograms.map((l, i) => <a href={l.href}>{t(`footer.programs.${i + 1}.label`)}</a>)}
|
||||
<nav aria-label="servizi">
|
||||
<T tag="footer.col.services-title" as="h4" />
|
||||
{site.footerServices.map((l, i) => <a href={l.href}>{t(`footer.services.${i + 1}.label`)}</a>)}
|
||||
</nav>
|
||||
<div>
|
||||
<T tag="footer.col.contact-title" as="h4" />
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
---
|
||||
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, showTags } = 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">
|
||||
@@ -11,6 +19,22 @@ const path = Astro.url.pathname;
|
||||
<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-on={showTags ? '1' : '0'}>{showTags ? 'Nascondi tag' : '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>
|
||||
@@ -32,6 +56,15 @@ const path = Astro.url.pathname;
|
||||
.hdr__link { padding: 12px 0; }
|
||||
.hdr__burger { display: block; }
|
||||
}
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -43,4 +76,18 @@ const path = Astro.url.pathname;
|
||||
const open = nav.classList.toggle('is-open');
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
});
|
||||
|
||||
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.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,5 +7,5 @@ const c = resolveContent(tag);
|
||||
const classes = [cls, c.classes].filter(Boolean).join(' ') || undefined;
|
||||
---
|
||||
{c.type === 'html'
|
||||
? <Tag data-tag={tag} class={classes} set:html={c.value} />
|
||||
: <Tag data-tag={tag} class={classes}>{c.value}</Tag>}
|
||||
? <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>}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { getImageOverride } from '../../lib/content';
|
||||
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 override = getImageOverride(tag);
|
||||
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} loading={rest.loading} />
|
||||
: <Image src={src} alt={alt} class={cls} data-tag={tag} {...rest} />}
|
||||
? <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} />}
|
||||
|
||||
@@ -19,7 +19,7 @@ const blocks = [
|
||||
<div class="feat__text" data-reveal={b.reverse ? 'left' : 'right'}>
|
||||
<T tag={`home.feature.${b.key}.title`} as="h2" />
|
||||
<T tag={`home.feature.${b.key}.body`} as="p" />
|
||||
<a class="btn" href="/programs">{t(`home.feature.${b.key}.cta`)}</a>
|
||||
<a class="btn" href="/services">{t(`home.feature.${b.key}.cta`)}</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -13,7 +13,7 @@ const slides = [
|
||||
<div class="container hero__in">
|
||||
<div class="hero__text">
|
||||
<h1>{s.titleTags.map((tag) => <T tag={tag} as="span" class="hero__line" />)}</h1>
|
||||
<a class="btn" href="/programs">{t('home.hero.cta')}</a>
|
||||
<a class="btn" href="/services">{t('home.hero.cta')}</a>
|
||||
</div>
|
||||
<TImg tag="home.hero.image" src={s.image} alt={s.alt} class="hero__img" widths={[600, 1000, 1400]} sizes="(max-width: 991px) 100vw, 60vw" loading="eager" />
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ const cols = await Promise.all([
|
||||
---
|
||||
<section class="info" aria-label="pilastri">
|
||||
{cols.map((c, i) => (
|
||||
<a class="info__col" href="/programs" style={`background-image:url(${c.bg});--reveal-delay:${i * 0.15}s`} data-reveal="fade" data-tag={c.imageTag}>
|
||||
<a class="info__col" href="/services" style={`background-image:url(${c.bg});--reveal-delay:${i * 0.15}s`} data-reveal="fade" data-tag={c.imageTag}>
|
||||
<T tag={c.titleTag} as="span" class="info__title" />
|
||||
</a>
|
||||
))}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
---
|
||||
import { programs } from '../../data/programs';
|
||||
import { programIcons as icons } from '../../data/program-icons';
|
||||
import { services } from '../../data/services';
|
||||
import { serviceIcons as icons } from '../../data/service-icons';
|
||||
import T from '../content/T.astro';
|
||||
---
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-heading" data-reveal>
|
||||
<T tag="home.programs.title" as="h2" />
|
||||
<T tag="home.programs.body" as="p" />
|
||||
<T tag="home.services.title" as="h2" />
|
||||
<T tag="home.services.body" as="p" />
|
||||
</div>
|
||||
<div class="pgrid">
|
||||
{programs.map((p, i) => (
|
||||
<a class="pgrid__item" href={`/programs/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}>
|
||||
{services.map((p, i) => (
|
||||
<a class="pgrid__item" href={`/services/${p.slug}`} data-reveal style={`--reveal-delay:${(i % 3) * 0.15}s`}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="var(--c-accent-dark)" stroke-width="1.4" width="52" height="52" aria-hidden="true"><path d={icons[p.slug]} /></svg>
|
||||
<T tag={`program.${p.slug}.title`} as="h3" />
|
||||
<T tag={`program.${p.slug}.subtitle`} as="p" />
|
||||
<T tag={`service.${p.slug}.title`} as="h3" />
|
||||
<T tag={`service.${p.slug}.subtitle`} as="p" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
+41
-40
@@ -5,7 +5,7 @@ import { team } from './team';
|
||||
import { methodIntro, methodExtraNote, methodSteps } from './method';
|
||||
import { partners } from './partners';
|
||||
import { disciplines, days, slots } from './agenda';
|
||||
import { programs } from './programs';
|
||||
import { services } from './services';
|
||||
import { performanceClassPricing } from './pricing';
|
||||
|
||||
export type ContentType = 'text' | 'html' | 'image';
|
||||
@@ -30,6 +30,7 @@ export const contentSeed: SeedEntry[] = [
|
||||
T('global.nav.3.label', site.navigation[2].label, []),
|
||||
T('global.nav.4.label', site.navigation[3].label, []),
|
||||
T('global.nav.5.label', site.navigation[4].label, []),
|
||||
T('global.nav.6.label', site.navigation[5].label, []),
|
||||
|
||||
// --- Header ---
|
||||
T('header.logo.alt', 'InsanityLab', []),
|
||||
@@ -40,7 +41,7 @@ export const contentSeed: SeedEntry[] = [
|
||||
H('footer.tagline', 'PERFORMANCE. BALANCE. LONGEVITY.'),
|
||||
T('footer.about', 'Promuoviamo il movimento come cura di sé. Abitudini sostenibili per benessere fisico e mentale.'),
|
||||
T('footer.col.training-title', 'Training'),
|
||||
T('footer.col.programs-title', 'Programmi'),
|
||||
T('footer.col.services-title', 'Servizi'),
|
||||
T('footer.col.contact-title', 'Contact'),
|
||||
H('footer.copyright', '<strong>Insanitylab</strong>, All Rights Reserved'),
|
||||
T('footer.social-label', 'Seguici', []),
|
||||
@@ -48,12 +49,12 @@ export const contentSeed: SeedEntry[] = [
|
||||
T('footer.training.2.label', site.footerTraining[1].label, []),
|
||||
T('footer.training.3.label', site.footerTraining[2].label, []),
|
||||
T('footer.training.4.label', site.footerTraining[3].label, []),
|
||||
T('footer.programs.1.label', site.footerPrograms[0].label, []),
|
||||
T('footer.programs.2.label', site.footerPrograms[1].label, []),
|
||||
T('footer.programs.3.label', site.footerPrograms[2].label, []),
|
||||
T('footer.programs.4.label', site.footerPrograms[3].label, []),
|
||||
T('footer.programs.5.label', site.footerPrograms[4].label, []),
|
||||
T('footer.programs.6.label', site.footerPrograms[5].label, []),
|
||||
T('footer.services.1.label', site.footerServices[0].label, []),
|
||||
T('footer.services.2.label', site.footerServices[1].label, []),
|
||||
T('footer.services.3.label', site.footerServices[2].label, []),
|
||||
T('footer.services.4.label', site.footerServices[3].label, []),
|
||||
T('footer.services.5.label', site.footerServices[4].label, []),
|
||||
T('footer.services.6.label', site.footerServices[5].label, []),
|
||||
|
||||
// --- Contact form ---
|
||||
T('form.field.first-name', 'Nome', []),
|
||||
@@ -115,9 +116,9 @@ export const contentSeed: SeedEntry[] = [
|
||||
I(`training.${tr.id}.image`),
|
||||
]),
|
||||
|
||||
// --- Home: programmi ---
|
||||
T('home.programs.title', 'Programmi'),
|
||||
T('home.programs.body', 'Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.'),
|
||||
// --- Home: servizi ---
|
||||
T('home.services.title', 'Servizi'),
|
||||
T('home.services.body', 'Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.'),
|
||||
|
||||
// --- Home: team (data-driven da team.ts) ---
|
||||
H('home.team.title', 'Team<br />Trainer'),
|
||||
@@ -223,40 +224,40 @@ export const contentSeed: SeedEntry[] = [
|
||||
T('about.timeline.stage2.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
|
||||
T('about.timeline.stage3.label', 'INSANITYLAB OGGI', []),
|
||||
T('about.timeline.stage3.text', 'Contenuto in arrivo: il cliente fornirà il testo di questa tappa.'),
|
||||
// --- Programs: lista (/programs) ---
|
||||
T('programs.meta.title', 'Programmi', []),
|
||||
T('programs.meta.description', 'I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.', []),
|
||||
T('programs.hero.title', 'Programmi', []),
|
||||
T('programs.intro.title', 'Programmi'),
|
||||
T('programs.intro.body', "Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."),
|
||||
T('programs.card.more-label', 'Per saperne di più →', []),
|
||||
// --- Services: lista (/services) ---
|
||||
T('services.meta.title', 'Servizi', []),
|
||||
T('services.meta.description', 'I servizi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.', []),
|
||||
T('services.hero.title', 'Servizi', []),
|
||||
T('services.intro.title', 'Servizi'),
|
||||
T('services.intro.body', "Questi sono i servizi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."),
|
||||
T('services.card.more-label', 'Per saperne di più →', []),
|
||||
|
||||
// --- Programs: dettaglio (data-driven da programs.ts) ---
|
||||
...programs.flatMap((p) => [
|
||||
T(`program.${p.slug}.title`, p.title),
|
||||
T(`program.${p.slug}.subtitle`, p.subtitle),
|
||||
T(`program.${p.slug}.excerpt`, p.excerpt),
|
||||
T(`program.${p.slug}.long-description`, p.longDescription),
|
||||
...p.features.map((f, i) => T(`program.${p.slug}.feature.${i + 1}`, f)),
|
||||
// --- Services: dettaglio (data-driven da services.ts) ---
|
||||
...services.flatMap((p) => [
|
||||
T(`service.${p.slug}.title`, p.title),
|
||||
T(`service.${p.slug}.subtitle`, p.subtitle),
|
||||
T(`service.${p.slug}.excerpt`, p.excerpt),
|
||||
T(`service.${p.slug}.long-description`, p.longDescription),
|
||||
...p.features.map((f, i) => T(`service.${p.slug}.feature.${i + 1}`, f)),
|
||||
...p.faq.flatMap((item, i) => [
|
||||
T(`program.${p.slug}.faq.${i + 1}.q`, item.q),
|
||||
T(`program.${p.slug}.faq.${i + 1}.a`, item.a),
|
||||
T(`service.${p.slug}.faq.${i + 1}.q`, item.q),
|
||||
T(`service.${p.slug}.faq.${i + 1}.a`, item.a),
|
||||
]),
|
||||
I(`program.${p.slug}.image`),
|
||||
I(`service.${p.slug}.image`),
|
||||
]),
|
||||
|
||||
// --- Programs: dettaglio, template ---
|
||||
T('program.side.categories-label', 'Categorie'),
|
||||
T('program.side.category.1', 'Fitness', []),
|
||||
T('program.side.category.2', 'Salute', []),
|
||||
T('program.side.category.3', 'Stile di vita', []),
|
||||
T('program.side.category.4', 'Nutrizione', []),
|
||||
T('program.side.others-label', 'Altri programmi'),
|
||||
H('program.pricing.empty-note', 'Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a>'),
|
||||
T('program.pricing.per', 'al mese, semestrale', []),
|
||||
T('program.pricing.entries-singular', 'ingresso settimanale', []),
|
||||
T('program.pricing.entries-plural', 'ingressi settimanali', []),
|
||||
T('program.pricing.buy-cta', 'Acquista', []),
|
||||
// --- Services: dettaglio, template ---
|
||||
T('service.side.categories-label', 'Categorie'),
|
||||
T('service.side.category.1', 'Fitness', []),
|
||||
T('service.side.category.2', 'Salute', []),
|
||||
T('service.side.category.3', 'Stile di vita', []),
|
||||
T('service.side.category.4', 'Nutrizione', []),
|
||||
T('service.side.others-label', 'Altri servizi'),
|
||||
H('service.pricing.empty-note', 'Contattaci per i dettagli di questo servizio. <a href="/contact">Scrivici →</a>'),
|
||||
T('service.pricing.per', 'al mese, semestrale', []),
|
||||
T('service.pricing.entries-singular', 'ingresso settimanale', []),
|
||||
T('service.pricing.entries-plural', 'ingressi settimanali', []),
|
||||
T('service.pricing.buy-cta', 'Acquista', []),
|
||||
|
||||
// --- Pricing performance class (data-driven da pricing.ts; monthly/entries restano nel data file) ---
|
||||
T('pricing.heading', performanceClassPricing.heading),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Path SVG (stroke, viewBox 0 0 24 24) delle icone programmi, condivisi tra griglia home e sidebar
|
||||
export const programIcons: Record<string, string> = {
|
||||
// Path SVG (stroke, viewBox 0 0 24 24) delle icone servizi, condivisi tra griglia home e sidebar
|
||||
export const serviceIcons: Record<string, string> = {
|
||||
'personal-training': 'M4 12h3m10 0h3M9 8v8m6-8v8M7 10v4m10-4v4',
|
||||
'performance-class': 'M13 3l-2 7h4l-6 11 2-8H7l4-10z',
|
||||
'coaching': 'M12 3a4 4 0 110 8 4 4 0 010-8zm-7 18a7 7 0 0114 0',
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface Program {
|
||||
export interface Service {
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -9,7 +9,7 @@ export interface Program {
|
||||
hasPricing: boolean;
|
||||
}
|
||||
|
||||
export const programs: Program[] = [
|
||||
export const services: Service[] = [
|
||||
{
|
||||
slug: "personal-training",
|
||||
title: "Personal Training",
|
||||
@@ -99,6 +99,6 @@ export const programs: Program[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function getProgram(slug: string): Program | undefined {
|
||||
return programs.find((p) => p.slug === slug);
|
||||
export function getService(slug: string): Service | undefined {
|
||||
return services.find((p) => p.slug === slug);
|
||||
}
|
||||
+9
-8
@@ -13,9 +13,10 @@ export const site = {
|
||||
navigation: [
|
||||
{ label: "Insanitylab", href: "/about" },
|
||||
{ label: "Training", href: "/training" },
|
||||
{ label: "Programmi", href: "/programs" },
|
||||
{ label: "Servizi", href: "/services" },
|
||||
{ label: "Blog & Edugo", href: "/blog" },
|
||||
{ label: "Contatti", href: "/contact" },
|
||||
{ label: "Pacchetti", href: "/pacchetti" },
|
||||
],
|
||||
footerTraining: [
|
||||
{ label: "One to one", href: "/training#one-to-one" },
|
||||
@@ -23,12 +24,12 @@ export const site = {
|
||||
{ label: "Performance class", href: "/training#performance-class" },
|
||||
{ label: "Fit remote", href: "/training#fit-remote" },
|
||||
],
|
||||
footerPrograms: [
|
||||
{ label: "Personal Training", href: "/programs/personal-training" },
|
||||
{ label: "Performance Class", href: "/programs/performance-class" },
|
||||
{ label: "Coaching", href: "/programs/coaching" },
|
||||
{ label: "Rehab", href: "/programs/rehab" },
|
||||
{ label: "Pilates Flow", href: "/programs/pilates-flow" },
|
||||
{ label: "Nutrizione", href: "/programs/nutrition" },
|
||||
footerServices: [
|
||||
{ label: "Personal Training", href: "/services/personal-training" },
|
||||
{ label: "Performance Class", href: "/services/performance-class" },
|
||||
{ label: "Coaching", href: "/services/coaching" },
|
||||
{ label: "Rehab", href: "/services/rehab" },
|
||||
{ label: "Pilates Flow", href: "/services/pilates-flow" },
|
||||
{ label: "Nutrizione", href: "/services/nutrition" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const trainings = [
|
||||
{ id: "one-to-one", title: "One to One", caption: "Allenamento con un trainer dedicato", image: "training-one-to-one", description: "Allenati con un trainer dedicato che costruisce un percorso su misura per i tuoi obiettivi. Ogni sessione one to one è pensata per migliorare performance, tecnica e risultati. Supporto costante, attenzione totale e allenamenti personalizzati per dare il massimo.", cta: { label: "Contattaci", href: "/contact" } },
|
||||
{ id: "small-groups", title: "Small Groups", caption: "Mini gruppi (max 3 persone) con trainer dedicato", image: "training-small-groups", description: "Allenati in mini gruppi da massimo 3 persone con un trainer dedicato. Un’esperienza small group che unisce attenzione personalizzata, motivazione e condivisione. Programmi mirati e supporto costante per raggiungere i tuoi obiettivi insieme.", cta: { label: "Contattaci", href: "/contact" } },
|
||||
{ id: "performance-class", title: "Performance Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-performance-class", description: "Allenati in classi fino a 8 persone guidate da trainer certificati. Sessioni dinamiche ad alta intensità pensate per migliorare forza, resistenza e performance. Energia, motivazione e allenamenti strutturati per dare il massimo in ogni workout.", cta: { label: "Scopri", href: "/programs/performance-class" } },
|
||||
{ id: "performance-class", title: "Performance Class", caption: "Classi fino a 8 persone con trainer certificato", image: "training-performance-class", description: "Allenati in classi fino a 8 persone guidate da trainer certificati. Sessioni dinamiche ad alta intensità pensate per migliorare forza, resistenza e performance. Energia, motivazione e allenamenti strutturati per dare il massimo in ogni workout.", cta: { label: "Scopri", href: "/services/performance-class" } },
|
||||
{ id: "fit-remote", title: "Fit Remote", caption: "Piano di allenamento su misura, ovunque ti alleni.", image: "training-fit-remote", description: "Con Fit Remote hai un piano di allenamento su misura, ovunque ti alleni. Programmi personalizzati creati sui tuoi obiettivi, da seguire in palestra, a casa o in viaggio. Supporto costante e allenamenti pensati per adattarsi al tuo stile di vita.", cta: { label: "Contattaci", href: "/contact" } },
|
||||
] as const;
|
||||
|
||||
+11
-2
@@ -31,8 +31,17 @@ const user = Astro.locals.user;
|
||||
<div class="abar">
|
||||
<strong>InsanityLab · Admin</strong>
|
||||
<span>
|
||||
{user && user.role === 'editor' && <>{`Ciao, ${user.username}`} <a href="/admin/content">Contenuti</a> <a href="/admin/logout">Esci</a></>}
|
||||
{user && user.role !== 'editor' && <>{`Ciao, ${user.username}`} <a href="/admin">Articoli</a> <a href="/admin/content">Contenuti</a> <a href="/admin/new">Nuovo</a> <a href="/admin/logout">Esci</a> <a href="/blog" target="_blank">Vedi sito ↗</a></>}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="awrap"><slot /></div>
|
||||
|
||||
+134
-19
@@ -14,12 +14,11 @@ import { getSessionUser, SESSION_COOKIE } from '../lib/auth';
|
||||
interface Props { title: string; description?: string }
|
||||
const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props;
|
||||
|
||||
// Overlay annotate: badge coi tag contenuto, solo per utenti loggati con ?annotate=1.
|
||||
let annotate = false;
|
||||
if (Astro.url.searchParams.get('annotate') === '1') {
|
||||
const tok = Astro.cookies.get(SESSION_COOKIE)?.value;
|
||||
annotate = Boolean(tok && getSessionUser(getDb(), tok));
|
||||
}
|
||||
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;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
@@ -37,7 +36,7 @@ if (Astro.url.searchParams.get('annotate') === '1') {
|
||||
<script is:inline>document.documentElement.classList.add('js');</script>
|
||||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
<Header user={user} showTags={showTags} canTags={canTags} />
|
||||
<main><slot /></main>
|
||||
<Footer />
|
||||
<script>
|
||||
@@ -74,30 +73,146 @@ if (Astro.url.searchParams.get('annotate') === '1') {
|
||||
update();
|
||||
}
|
||||
</script>
|
||||
{annotate && (
|
||||
{showTags && (
|
||||
<style is:global>
|
||||
[data-tag] { outline: 1px dashed #b8a98c; outline-offset: 2px; }
|
||||
.tag-badge { position: absolute; z-index: 9999; background: #3a2929; color: #fff;
|
||||
font: 600 10px/1.6 monospace; padding: 0 6px; text-decoration: none; opacity: .85; }
|
||||
.tag-badge:hover { opacity: 1; background: #9c8b70; }
|
||||
.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 a = document.createElement('a');
|
||||
a.className = 'tag-badge';
|
||||
a.textContent = el.dataset.tag;
|
||||
a.href = '/admin/content#' + encodeURIComponent(el.dataset.tag);
|
||||
a.target = '_blank';
|
||||
document.body.appendChild(a);
|
||||
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();
|
||||
a.style.top = window.scrollY + r.top - 14 + 'px';
|
||||
a.style.left = window.scrollX + r.left + 'px';
|
||||
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>
|
||||
)}
|
||||
</body>
|
||||
|
||||
+60
-4
@@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto';
|
||||
export const SESSION_COOKIE = 'session';
|
||||
const SESSION_DAYS = 7;
|
||||
|
||||
export type Role = 'admin' | 'editor';
|
||||
export type Role = 'admin' | 'superuser' | 'user';
|
||||
|
||||
export function hashPassword(plain: string): string {
|
||||
return bcrypt.hashSync(plain, 12);
|
||||
@@ -49,10 +49,66 @@ export function logout(db: Database.Database, token: string): void {
|
||||
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||
}
|
||||
|
||||
// Percorsi admin consentiti anche al ruolo editor.
|
||||
const EDITOR_ALLOWED = [/^\/admin\/content(\/|$)/, /^\/api\/admin\/content(\/|$)/, /^\/admin\/logout$/];
|
||||
// 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;
|
||||
return EDITOR_ALLOWED.some((re) => re.test(pathname));
|
||||
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 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 }[];
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
export function landingFor(role: string): string {
|
||||
if (role === 'superuser') return '/admin/content';
|
||||
return '/admin';
|
||||
}
|
||||
|
||||
+11
-10
@@ -1,13 +1,14 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDb } from './db';
|
||||
import { contentSeed, seedByTag, type ContentType } from '../data/content-seed';
|
||||
import { stylesToClasses } from './style-presets';
|
||||
|
||||
interface Entry { value: string; type: ContentType; styles: Record<string, string> }
|
||||
interface Entry { value: string; type: ContentType; styles: Record<string, string>; guid: string }
|
||||
|
||||
export function syncSeed(db: Database.Database): void {
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
|
||||
const tx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); });
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -16,25 +17,25 @@ export function makeContentStore(dbGetter: () => Database.Database) {
|
||||
|
||||
const load = (): Map<string, Entry> => {
|
||||
if (cache) return cache;
|
||||
const rows = dbGetter().prepare('SELECT tag, type, value, styles FROM content_blocks').all() as
|
||||
{ tag: string; type: ContentType; value: string; styles: string }[];
|
||||
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 }];
|
||||
return [r.tag, { value: r.value, type: r.type, styles, guid: r.guid ?? '' }];
|
||||
}));
|
||||
return cache;
|
||||
};
|
||||
|
||||
return {
|
||||
resolve(tag: string): { value: string; type: ContentType; classes: string } {
|
||||
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) };
|
||||
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: '' };
|
||||
if (seed) return { value: seed.value, type: seed.type, classes: '', guid: '' };
|
||||
console.warn(`[content] tag sconosciuto: ${tag}`);
|
||||
return { value: '', type: 'text', classes: '' };
|
||||
return { value: '', type: 'text', classes: '', guid: '' };
|
||||
},
|
||||
invalidate(): void { cache = null; },
|
||||
};
|
||||
|
||||
+71
-2
@@ -1,6 +1,7 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { contentSeed } from '../data/content-seed';
|
||||
|
||||
const SCHEMA = `
|
||||
@@ -50,9 +51,77 @@ export function createDb(path?: string): Database.Database {
|
||||
if (!userCols.some((c) => c.name === 'role')) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`);
|
||||
}
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO content_blocks (tag, type, value) VALUES (?, ?, ?)');
|
||||
const syncTx = db.transaction(() => { for (const e of contentSeed) ins.run(e.tag, e.type, e.value); });
|
||||
// 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`);
|
||||
}
|
||||
// Migrazione: la sezione "Programmi" è diventata "Servizi" → rinomina dei tag esistenti
|
||||
// preservando le modifiche fatte dal pannello. Ordine: prima i prefissi più specifici.
|
||||
// UPDATE OR IGNORE salta le righe in conflitto (tag nuovo già presente); la DELETE
|
||||
// successiva ripulisce le righe vecchie rimaste. Idempotente: al secondo giro nessun match.
|
||||
const TAG_RENAMES: [string, string][] = [
|
||||
['home.programs.', 'home.services.'],
|
||||
['footer.programs.', 'footer.services.'],
|
||||
['programs.', 'services.'],
|
||||
['program.', 'service.'],
|
||||
];
|
||||
const renameUpd = db.prepare('UPDATE OR IGNORE content_blocks SET tag = ? || substr(tag, ?) WHERE tag LIKE ?');
|
||||
const renameDel = db.prepare('DELETE FROM content_blocks WHERE tag LIKE ?');
|
||||
const renameTx = db.transaction(() => {
|
||||
for (const [oldP, newP] of TAG_RENAMES) {
|
||||
renameUpd.run(newP, oldP.length + 1, `${oldP}%`);
|
||||
renameDel.run(`${oldP}%`);
|
||||
}
|
||||
// Tag singolo, non coperto dai prefissi: uguaglianza esatta.
|
||||
db.prepare("UPDATE OR IGNORE content_blocks SET tag = 'footer.col.services-title' WHERE tag = 'footer.col.programs-title'").run();
|
||||
db.prepare("DELETE FROM content_blocks WHERE tag = 'footer.col.programs-title'").run();
|
||||
|
||||
// I valori di default che nominavano la sezione cambiano con la rinomina. Il seed è
|
||||
// INSERT OR IGNORE e non tocca le righe esistenti, quindi si aggiornano qui — ma solo
|
||||
// le righe rimaste identiche al valore originale del seed (mai modificate dal pannello):
|
||||
// il nuovo valore è quello del seed corrente per lo stesso tag.
|
||||
const OLD_SECTION_DEFAULTS: [string, string][] = [
|
||||
['global.nav.3.label', 'Programmi'],
|
||||
['footer.col.services-title', 'Programmi'],
|
||||
['home.services.title', 'Programmi'],
|
||||
['home.services.body', 'Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un’esperienza di allenamento completa e mirata.'],
|
||||
['services.meta.title', 'Programmi'],
|
||||
['services.meta.description', 'I programmi InsanityLab: Personal Training, Performance Class, Coaching, Pilates Flow, Rehab e Nutrizione.'],
|
||||
['services.hero.title', 'Programmi'],
|
||||
['services.intro.title', 'Programmi'],
|
||||
["services.intro.body", "Questi sono i programmi delle classi di Insanity Lab selezionati per voi. Abbiamo scelto i migliori percorsi per offrirvi un'esperienza di allenamento completa e mirata."],
|
||||
['service.side.others-label', 'Altri programmi'],
|
||||
['service.pricing.empty-note', 'Contattaci per i dettagli di questo programma. <a href="/contact">Scrivici →</a>'],
|
||||
];
|
||||
const seedValue = new Map(contentSeed.map((e) => [e.tag, e.value]));
|
||||
const updValue = db.prepare('UPDATE content_blocks SET value = ? WHERE tag = ? AND value = ?');
|
||||
for (const [tag, oldValue] of OLD_SECTION_DEFAULTS) {
|
||||
const next = seedValue.get(tag);
|
||||
if (next !== undefined) updValue.run(next, tag, oldValue);
|
||||
}
|
||||
});
|
||||
renameTx();
|
||||
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)`);
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
+14
-5
@@ -2,21 +2,21 @@ import type Database from 'better-sqlite3';
|
||||
|
||||
export interface Post {
|
||||
id: number; title: string; slug: string; category: string; excerpt: string;
|
||||
body_html: string; cover: string | null; draft: number;
|
||||
body_html: string; cover: string | null; draft: number; author_id: number | null;
|
||||
published_at: string | null; created_at: string; updated_at: string;
|
||||
}
|
||||
|
||||
export interface PostInput {
|
||||
title: string; slug: string; category: string; excerpt: string;
|
||||
body_html: string; cover?: string | null; draft: boolean;
|
||||
body_html: string; cover?: string | null; draft: boolean; author_id?: number | null;
|
||||
}
|
||||
|
||||
export function createPost(db: Database.Database, input: PostInput): number {
|
||||
const res = db.prepare(
|
||||
`INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, published_at)
|
||||
VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft,
|
||||
`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, draft: input.draft ? 1 : 0 });
|
||||
).run({ ...input, cover: input.cover ?? null, author_id: input.author_id ?? null, draft: input.draft ? 1 : 0 });
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
@@ -72,3 +72,12 @@ export function listArchiveMonths(db: Database.Database): { month: string; count
|
||||
FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC`
|
||||
).all() as { month: string; count: number }[];
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// 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.includes('\\')) return '/';
|
||||
if (!next || !next.startsWith('/') || next.startsWith('//')) return '/';
|
||||
if (next.startsWith('/api/')) return '/';
|
||||
return next;
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { getDb } from './lib/db';
|
||||
import { getSessionUser, SESSION_COOKIE, canAccessAdminPath } from './lib/auth';
|
||||
import { getSessionUser, SESSION_COOKIE, canAccessAdminPath, landingFor } from './lib/auth';
|
||||
|
||||
export const onRequest = defineMiddleware((context, next) => {
|
||||
const { pathname } = context.url;
|
||||
@@ -26,7 +26,7 @@ export const onRequest = defineMiddleware((context, next) => {
|
||||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return context.redirect('/admin/content');
|
||||
return context.redirect(landingFor(user.role));
|
||||
}
|
||||
|
||||
context.locals.user = user;
|
||||
|
||||
@@ -37,7 +37,7 @@ export const prerender = false;
|
||||
<div>
|
||||
<T tag="about.direzione.title" as="h2" />
|
||||
<T tag="about.direzione.body" as="p" />
|
||||
<a class="btn" href="/programs">{t('about.direzione.cta')}</a>
|
||||
<a class="btn" href="/services">{t('about.direzione.cta')}</a>
|
||||
</div>
|
||||
<div>
|
||||
<T tag="about.approccio.title" as="h2" />
|
||||
|
||||
@@ -9,13 +9,13 @@ export const prerender = false;
|
||||
const GROUP_BY_PREFIX: Record<string, string> = {
|
||||
home: 'Home',
|
||||
about: 'About', team: 'About', method: 'About',
|
||||
program: 'Programmi', programs: 'Programmi', pricing: 'Programmi',
|
||||
service: 'Servizi', services: 'Servizi', pricing: 'Servizi',
|
||||
training: 'Training',
|
||||
contact: 'Contatti', form: 'Contatti',
|
||||
global: 'Globali', header: 'Globali', footer: 'Globali',
|
||||
agenda: 'Globali', partner: 'Globali', notfound: 'Globali',
|
||||
};
|
||||
const GROUP_ORDER = ['Home', 'About', 'Programmi', 'Training', 'Contatti', 'Globali', 'Altro'];
|
||||
const GROUP_ORDER = ['Home', 'About', 'Servizi', 'Training', 'Contatti', 'Globali', 'Altro'];
|
||||
|
||||
interface Row { tag: string; type: string; value: string; styles: string; updated_at: string; updated_by: string | null }
|
||||
const rows = getDb().prepare(
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import Admin from '../../../layouts/Admin.astro';
|
||||
import PostForm from '../../../components/admin/PostForm.astro';
|
||||
import { getDb } from '../../../lib/db';
|
||||
import { getPostById } from '../../../lib/posts';
|
||||
import { getPostById, canModifyPost } from '../../../lib/posts';
|
||||
export const prerender = false;
|
||||
const post = getPostById(getDb(), Number(Astro.params.id));
|
||||
if (!post) return Astro.redirect('/admin');
|
||||
const u = Astro.locals.user!;
|
||||
if (!canModifyPost(u.role, u.id, post)) return Astro.redirect('/admin');
|
||||
---
|
||||
<Admin title={`Modifica: ${post.title}`}>
|
||||
<h1>Modifica articolo</h1>
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { listAllPosts } from '../../lib/posts';
|
||||
import { listAllPosts, listByAuthor } from '../../lib/posts';
|
||||
import { listUsers } from '../../lib/auth';
|
||||
export const prerender = false;
|
||||
const posts = listAllPosts(getDb());
|
||||
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' });
|
||||
---
|
||||
<Admin title="Articoli">
|
||||
<h1>Articoli</h1>
|
||||
<p><a class="abtn" href="/admin/new">+ Nuovo articolo</a></p>
|
||||
<table>
|
||||
<thead><tr><th>Titolo</th><th>Categoria</th><th>Stato</th><th>Aggiornato</th><th></th></tr></thead>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
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>
|
||||
@@ -2,11 +2,26 @@ import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../../../lib/db';
|
||||
import { applyContentUpdate } from '../../../../lib/content-update';
|
||||
import { invalidateContentCache } from '../../../../lib/content';
|
||||
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 ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||||
let data: Record<string, unknown>;
|
||||
try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../../../../lib/db';
|
||||
import { getPostById, updatePost, deletePost } from '../../../../lib/posts';
|
||||
import { getPostById, updatePost, deletePost, canModifyPost } from '../../../../lib/posts';
|
||||
import { parsePostBody } from '../../../../lib/post-body';
|
||||
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 }) => {
|
||||
export const PUT: APIRoute = async ({ params, request, locals }) => {
|
||||
const id = Number(params.id);
|
||||
if (!getPostById(getDb(), id)) return json(404, { error: 'Articolo non trovato.' });
|
||||
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);
|
||||
@@ -23,7 +25,11 @@ export const PUT: APIRoute = async ({ params, request }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = ({ params }) => {
|
||||
deletePost(getDb(), Number(params.id));
|
||||
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 });
|
||||
};
|
||||
|
||||
@@ -7,13 +7,13 @@ 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 }) => {
|
||||
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);
|
||||
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.' });
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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) });
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
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 });
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
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 });
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
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 });
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -3,18 +3,21 @@ import Base from '../../layouts/Base.astro';
|
||||
import Sidebar from '../../components/blog/Sidebar.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { getPublishedBySlug } from '../../lib/posts';
|
||||
import { getUsernameById } from '../../lib/auth';
|
||||
export const prerender = false;
|
||||
|
||||
const post = getPublishedBySlug(getDb(), Astro.params.slug!);
|
||||
if (!post) return new Response(null, { status: 404 });
|
||||
const fmt = new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' });
|
||||
const author = post.author_id ? getUsernameById(getDb(), post.author_id) : null;
|
||||
const byline = author ?? 'Staff InsanityLab';
|
||||
---
|
||||
<Base title={post.title} description={post.excerpt}>
|
||||
<section class="section">
|
||||
<div class="container art">
|
||||
<article>
|
||||
{post.cover && <img class="art__cover" src={post.cover} alt="" />}
|
||||
<p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category}</p>
|
||||
<p class="art__meta">{post.published_at && fmt.format(new Date(post.published_at))} · {post.category} · di {byline}</p>
|
||||
<h1>{post.title}</h1>
|
||||
<div class="art__body" set:html={post.body_html} />
|
||||
</article>
|
||||
|
||||
@@ -5,7 +5,7 @@ import InfoColumns from '../components/home/InfoColumns.astro';
|
||||
import FeatureBlocks from '../components/home/FeatureBlocks.astro';
|
||||
import QuoteBanner from '../components/home/QuoteBanner.astro';
|
||||
import TrainingCards from '../components/home/TrainingCards.astro';
|
||||
import ProgramsGrid from '../components/home/ProgramsGrid.astro';
|
||||
import ServicesGrid from '../components/home/ServicesGrid.astro';
|
||||
import Agenda from '../components/Agenda.astro';
|
||||
import TeamSection from '../components/home/TeamSection.astro';
|
||||
import MethodSteps from '../components/home/MethodSteps.astro';
|
||||
@@ -26,7 +26,7 @@ export const prerender = false;
|
||||
</div>
|
||||
</section>
|
||||
<TrainingCards />
|
||||
<ProgramsGrid />
|
||||
<ServicesGrid />
|
||||
<Agenda />
|
||||
<TeamSection />
|
||||
<MethodSteps />
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,66 +2,66 @@
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import PageHero from '../../components/PageHero.astro';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { programs, getProgram } from '../../data/programs';
|
||||
import { services, getService } from '../../data/services';
|
||||
import { performanceClassPricing } from '../../data/pricing';
|
||||
import { programIcons } from '../../data/program-icons';
|
||||
import { serviceIcons } from '../../data/service-icons';
|
||||
import T from '../../components/content/T.astro';
|
||||
import TImg from '../../components/content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
export const prerender = false;
|
||||
|
||||
const program = getProgram(Astro.params.slug!);
|
||||
if (!program) return new Response(null, { status: 404 });
|
||||
const slug = program.slug;
|
||||
const others = programs.filter((p) => p.slug !== program.slug);
|
||||
const service = getService(Astro.params.slug!);
|
||||
if (!service) return new Response(null, { status: 404 });
|
||||
const slug = service.slug;
|
||||
const others = services.filter((p) => p.slug !== service.slug);
|
||||
const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/program-*.jpg', { eager: true });
|
||||
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
|
||||
const heroImg = imgOf(program.slug);
|
||||
// Categorie come da Figma (Performance class.pdf), non quelle del blog → tag program.side.category.1-4
|
||||
const heroImg = imgOf(service.slug);
|
||||
// Categorie come da Figma (Performance class.pdf), non quelle del blog → tag service.side.category.1-4
|
||||
const categoryIndexes = [1, 2, 3, 4];
|
||||
// Ordine card pricing come da Figma: tan, taupe, dark
|
||||
const tones = ['tan', 'taupe', 'dark'];
|
||||
---
|
||||
<Base title={t(`program.${slug}.title`)} description={t(`program.${slug}.excerpt`)}>
|
||||
<PageHero title={t(`program.${slug}.title`).toUpperCase()} bgImage={heroImg} />
|
||||
<Base title={t(`service.${slug}.title`)} description={t(`service.${slug}.excerpt`)}>
|
||||
<PageHero title={t(`service.${slug}.title`).toUpperCase()} bgImage={heroImg} />
|
||||
|
||||
<section class="section">
|
||||
<div class="container prog">
|
||||
<div>
|
||||
<h2>{t(`program.${slug}.title`).toUpperCase()}</h2>
|
||||
<T tag={`program.${slug}.long-description`} as="p" />
|
||||
<h2>{t(`service.${slug}.title`).toUpperCase()}</h2>
|
||||
<T tag={`service.${slug}.long-description`} as="p" />
|
||||
<ul class="prog__features">
|
||||
{program.features.map((_, i) => <T tag={`program.${slug}.feature.${i + 1}`} as="li" />)}
|
||||
{service.features.map((_, i) => <T tag={`service.${slug}.feature.${i + 1}`} as="li" />)}
|
||||
</ul>
|
||||
{heroImg && (
|
||||
<TImg tag={`program.${slug}.image`} src={heroImg} alt={t(`program.${slug}.title`)} class="prog__img" widths={[600, 1000]} sizes="(max-width:991px) 100vw, 60vw" data-reveal="fade" />
|
||||
<TImg tag={`service.${slug}.image`} src={heroImg} alt={t(`service.${slug}.title`)} class="prog__img" widths={[600, 1000]} sizes="(max-width:991px) 100vw, 60vw" data-reveal="fade" />
|
||||
)}
|
||||
<div class="prog__faq">
|
||||
{program.faq.map((_, i) => (
|
||||
{service.faq.map((_, i) => (
|
||||
<details open={i === 0}>
|
||||
<summary><T tag={`program.${slug}.faq.${i + 1}.q`} /></summary>
|
||||
<T tag={`program.${slug}.faq.${i + 1}.a`} as="p" />
|
||||
<summary><T tag={`service.${slug}.faq.${i + 1}.q`} /></summary>
|
||||
<T tag={`service.${slug}.faq.${i + 1}.a`} as="p" />
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<aside class="prog__side">
|
||||
<T tag="program.side.categories-label" as="h3" />
|
||||
{categoryIndexes.map((n) => <T tag={`program.side.category.${n}`} as="p" class="prog__cat" />)}
|
||||
<T tag="program.side.others-label" as="h3" />
|
||||
<T tag="service.side.categories-label" as="h3" />
|
||||
{categoryIndexes.map((n) => <T tag={`service.side.category.${n}`} as="p" class="prog__cat" />)}
|
||||
<T tag="service.side.others-label" as="h3" />
|
||||
{others.map((o) => (
|
||||
<a class="prog__other" href={`/programs/${o.slug}`}>
|
||||
<a class="prog__other" href={`/services/${o.slug}`}>
|
||||
<span class="prog__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" width="26" height="26" aria-hidden="true"><path d={programIcons[o.slug]} /></svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" width="26" height="26" aria-hidden="true"><path d={serviceIcons[o.slug]} /></svg>
|
||||
</span>
|
||||
<T tag={`program.${o.slug}.title`} />
|
||||
<T tag={`service.${o.slug}.title`} />
|
||||
</a>
|
||||
))}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{program.hasPricing && (
|
||||
{service.hasPricing && (
|
||||
<section class="section section--alt">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
@@ -72,18 +72,18 @@ const tones = ['tan', 'taupe', 'dark'];
|
||||
<details class="price__group" open={g.open}>
|
||||
<summary>{g.open ? '+' : '–'} <T tag={`pricing.group.${gi + 1}.label`} /></summary>
|
||||
{g.plans.length === 0 ? (
|
||||
<T tag="program.pricing.empty-note" as="p" class="price__empty" />
|
||||
<T tag="service.pricing.empty-note" as="p" class="price__empty" />
|
||||
) : (
|
||||
<div class="price__row">
|
||||
{g.plans.map((p, i) => (
|
||||
<div class:list={['price__card', `price__card--${tones[i % 3]}`]}>
|
||||
<p class="price__amount">{p.monthly}€</p>
|
||||
<T tag="program.pricing.per" as="p" class="price__per" />
|
||||
<p class="price__entries">{p.entries} {p.entries === 1 ? t('program.pricing.entries-singular') : t('program.pricing.entries-plural')}</p>
|
||||
<T tag="service.pricing.per" as="p" class="price__per" />
|
||||
<p class="price__entries">{p.entries} {p.entries === 1 ? t('service.pricing.entries-singular') : t('service.pricing.entries-plural')}</p>
|
||||
<T tag={`pricing.group.${gi + 1}.plan.${i + 1}.note`} as="p" />
|
||||
<T tag={`pricing.group.${gi + 1}.plan.${i + 1}.installments`} as="p" />
|
||||
<T tag={`pricing.group.${gi + 1}.plan.${i + 1}.best`} as="p" class="price__best" />
|
||||
<a class="btn btn--light" href="/contact">{t('program.pricing.buy-cta')}</a>
|
||||
<a class="btn btn--light" href="/contact">{t('service.pricing.buy-cta')}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -2,7 +2,7 @@
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import PageHero from '../../components/PageHero.astro';
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import { programs } from '../../data/programs';
|
||||
import { services } from '../../data/services';
|
||||
import T from '../../components/content/T.astro';
|
||||
import TImg from '../../components/content/TImg.astro';
|
||||
import { t } from '../../lib/content';
|
||||
@@ -10,27 +10,27 @@ const images = import.meta.glob<{ default: ImageMetadata }>('../../assets/img/pr
|
||||
const imgOf = (slug: string) => Object.entries(images).find(([p]) => p.includes(`program-${slug}.`))?.[1]?.default;
|
||||
export const prerender = false;
|
||||
---
|
||||
<Base title={t('programs.meta.title')} description={t('programs.meta.description')}>
|
||||
<PageHero title={t('programs.hero.title')} />
|
||||
<Base title={t('services.meta.title')} description={t('services.meta.description')}>
|
||||
<PageHero title={t('services.hero.title')} />
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-heading">
|
||||
<T tag="programs.intro.title" as="h2" />
|
||||
<T tag="programs.intro.body" as="p" />
|
||||
<T tag="services.intro.title" as="h2" />
|
||||
<T tag="services.intro.body" as="p" />
|
||||
</div>
|
||||
<div class="plist">
|
||||
{programs.map((p, i) => (
|
||||
{services.map((p, i) => (
|
||||
<article class="plist__card" data-reveal style={`--reveal-delay:${((i % 3) * 0.15).toFixed(2)}s`}>
|
||||
{imgOf(p.slug) && (
|
||||
<a class="plist__photo" href={`/programs/${p.slug}`}>
|
||||
<TImg tag={`program.${p.slug}.image`} src={imgOf(p.slug)!} alt={t(`program.${p.slug}.title`)} widths={[400, 700]} sizes="(max-width:767px) 100vw, 33vw" />
|
||||
<a class="plist__photo" href={`/services/${p.slug}`}>
|
||||
<TImg tag={`service.${p.slug}.image`} src={imgOf(p.slug)!} alt={t(`service.${p.slug}.title`)} widths={[400, 700]} sizes="(max-width:767px) 100vw, 33vw" />
|
||||
</a>
|
||||
)}
|
||||
<div class="plist__body">
|
||||
<T tag={`program.${p.slug}.title`} as="h3" />
|
||||
<T tag={`program.${p.slug}.subtitle`} as="p" class="plist__sub" />
|
||||
<T tag={`program.${p.slug}.excerpt`} as="p" />
|
||||
<a class="plist__more" href={`/programs/${p.slug}`}>{t('programs.card.more-label')}</a>
|
||||
<T tag={`service.${p.slug}.title`} as="h3" />
|
||||
<T tag={`service.${p.slug}.subtitle`} as="p" class="plist__sub" />
|
||||
<T tag={`service.${p.slug}.excerpt`} as="p" />
|
||||
<a class="plist__more" href={`/services/${p.slug}`}>{t('services.card.more-label')}</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDb } from '../lib/db';
|
||||
import { programs } from '../data/programs';
|
||||
import { services } from '../data/services';
|
||||
export const prerender = false;
|
||||
|
||||
const STATIC = ['/', '/about', '/contact', '/training', '/programs', '/blog'];
|
||||
const STATIC = ['/', '/about', '/contact', '/training', '/services', '/blog', '/pacchetti'];
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const base = (site ?? new URL('https://insanitylab.tielogic.xyz')).toString().replace(/\/$/, '');
|
||||
const posts = getDb().prepare('SELECT slug FROM posts WHERE draft = 0').all() as { slug: string }[];
|
||||
const urls = [
|
||||
...STATIC,
|
||||
...programs.map((p) => `/programs/${p.slug}`),
|
||||
...services.map((p) => `/services/${p.slug}`),
|
||||
...posts.map((p) => `/blog/${p.slug}`),
|
||||
];
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map((u) => ` <url><loc>${base}${u}</loc></url>`).join('\n')}\n</urlset>`;
|
||||
|
||||
@@ -35,3 +35,16 @@ describe('applyContentUpdate', () => {
|
||||
expect(applyContentUpdate(db, imgTag, { value: 'x' }, 'a')).toMatchObject({ ok: false, status: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/admin/content/[tag]', () => {
|
||||
it('GET /api/admin/content/[tag] ritorna tag, guid, type, value, styleOptions', async () => {
|
||||
const { GET } = await import('../src/pages/api/admin/content/[tag]');
|
||||
const res = await GET({ params: { tag: 'home.hero.cta' } } as any);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.tag).toBe('home.hero.cta');
|
||||
expect(typeof body.guid).toBe('string');
|
||||
expect(body.type).toBeDefined();
|
||||
expect(Array.isArray(body.styleOptions)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import { createUser } from '../src/lib/auth';
|
||||
|
||||
describe('schema content_blocks e ruoli', () => {
|
||||
it('crea la tabella content_blocks', () => {
|
||||
@@ -24,6 +28,60 @@ describe('schema content_blocks e ruoli', () => {
|
||||
expect(row.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('migra i tag programmi → servizi preservando i valori modificati dal pannello', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'il-db-test-'));
|
||||
const path = join(dir, 'test.db');
|
||||
try {
|
||||
// Simula un DB di produzione pre-rinomina: tag vecchi con valori modificati dal pannello.
|
||||
const db1 = createDb(path);
|
||||
const old = db1.transaction(() => {
|
||||
db1.prepare("DELETE FROM content_blocks WHERE tag IN ('home.services.title', 'service.rehab.title', 'services.hero.title', 'services.meta.title', 'footer.services.1.label', 'footer.col.services-title')").run();
|
||||
const ins = db1.prepare("INSERT INTO content_blocks (tag, type, value, updated_by) VALUES (?, 'text', ?, 'editor')");
|
||||
ins.run('home.programs.title', 'Titolo modificato');
|
||||
ins.run('program.rehab.title', 'Rehab modificato');
|
||||
ins.run('programs.hero.title', 'Hero modificato');
|
||||
ins.run('footer.programs.1.label', 'Label modificata');
|
||||
ins.run('footer.col.programs-title', 'Colonna modificata');
|
||||
// Riga mai toccata dal pannello: valore identico al vecchio seed → deve prendere il nuovo default.
|
||||
db1.prepare("INSERT INTO content_blocks (tag, type, value) VALUES ('programs.meta.title', 'text', 'Programmi')").run();
|
||||
db1.prepare("UPDATE content_blocks SET value = 'Programmi' WHERE tag = 'global.nav.3.label'").run();
|
||||
});
|
||||
old();
|
||||
db1.close();
|
||||
|
||||
// Riapertura: la migrazione rinomina i tag preservando valore e updated_by.
|
||||
const db2 = createDb(path);
|
||||
const val = (tag: string) =>
|
||||
db2.prepare('SELECT value, updated_by FROM content_blocks WHERE tag = ?').get(tag) as { value: string; updated_by: string | null } | undefined;
|
||||
expect(val('home.services.title')).toMatchObject({ value: 'Titolo modificato', updated_by: 'editor' });
|
||||
expect(val('service.rehab.title')).toMatchObject({ value: 'Rehab modificato', updated_by: 'editor' });
|
||||
expect(val('services.hero.title')).toMatchObject({ value: 'Hero modificato', updated_by: 'editor' });
|
||||
expect(val('footer.services.1.label')).toMatchObject({ value: 'Label modificata', updated_by: 'editor' });
|
||||
expect(val('footer.col.services-title')).toMatchObject({ value: 'Colonna modificata', updated_by: 'editor' });
|
||||
|
||||
// Le righe rimaste al vecchio valore di default prendono il nuovo ("Servizi").
|
||||
expect(val('services.meta.title')).toMatchObject({ value: 'Servizi' });
|
||||
expect(val('global.nav.3.label')).toMatchObject({ value: 'Servizi' });
|
||||
// La riga rinominata ma modificata dal pannello resta invariata (già verificata sopra):
|
||||
// home.services.title = 'Titolo modificato'.
|
||||
|
||||
// Nessun tag vecchio residuo.
|
||||
const residui = db2.prepare(
|
||||
"SELECT count(*) AS c FROM content_blocks WHERE tag LIKE 'program%' OR tag LIKE 'home.programs%' OR tag LIKE 'footer.programs%' OR tag = 'footer.col.programs-title'"
|
||||
).get() as { c: number };
|
||||
expect(residui.c).toBe(0);
|
||||
|
||||
// Idempotente: una terza apertura non altera i valori.
|
||||
db2.close();
|
||||
const db3 = createDb(path);
|
||||
const again = db3.prepare('SELECT value FROM content_blocks WHERE tag = ?').get('home.services.title') as { value: string };
|
||||
expect(again.value).toBe('Titolo modificato');
|
||||
db3.close();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('migra un DB esistente senza colonna role', () => {
|
||||
// simula DB vecchio: createDb due volte sullo stesso path in-memory non è possibile,
|
||||
// quindi si verifica che la migrazione sia idempotente (doppia esecuzione non lancia)
|
||||
@@ -32,3 +90,31 @@ describe('schema content_blocks e ruoli', () => {
|
||||
expect(db.prepare(`PRAGMA table_info(users)`).all().length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import { contentSeed } from '../src/data/content-seed';
|
||||
import { makeContentStore, syncSeed } from '../src/lib/content';
|
||||
import { makeContentStore, syncSeed, resolveContent } from '../src/lib/content';
|
||||
|
||||
let db: Database.Database;
|
||||
beforeEach(() => { db = createDb(':memory:'); });
|
||||
@@ -58,4 +58,10 @@ describe('content store', () => {
|
||||
store.invalidate();
|
||||
expect(store.resolve(contentSeed[0].tag).classes).toBe('');
|
||||
});
|
||||
|
||||
it('resolveContent restituisce il guid dal DB', () => {
|
||||
const r = resolveContent('home.hero.cta');
|
||||
expect(typeof r.guid).toBe('string');
|
||||
expect(r.guid.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
+26
-1
@@ -3,8 +3,9 @@ import type Database from 'better-sqlite3';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import {
|
||||
createPost, updatePost, deletePost, getPostById, getPublishedBySlug,
|
||||
listPublished, listAllPosts, listRecent, listArchiveMonths, type PostInput,
|
||||
listPublished, listAllPosts, listRecent, listArchiveMonths, listByAuthor, type PostInput,
|
||||
} from '../src/lib/posts';
|
||||
import { createUser, getUsernameById, listUsers } from '../src/lib/auth';
|
||||
|
||||
let db: Database.Database;
|
||||
beforeEach(() => { db = createDb(':memory:'); });
|
||||
@@ -72,3 +73,27 @@ describe('posts repository', () => {
|
||||
expect(months[0].month).toMatch(/^\d{4}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+49
-23
@@ -1,37 +1,63 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createDb } from '../src/lib/db';
|
||||
import { createUser, login, getSessionUser, canAccessAdminPath } from '../src/lib/auth';
|
||||
import { createUser, login, getSessionUser, canAccessAdminPath, landingFor } from '../src/lib/auth';
|
||||
|
||||
let db: Database.Database;
|
||||
beforeEach(() => { db = createDb(':memory:'); });
|
||||
|
||||
describe('ruoli', () => {
|
||||
it('createUser accetta ruolo editor e getSessionUser lo ritorna', () => {
|
||||
createUser(db, 'cliente', 'segreta123', 'editor');
|
||||
const token = login(db, 'cliente', 'segreta123')!;
|
||||
expect(getSessionUser(db, token)).toMatchObject({ username: 'cliente', role: 'editor' });
|
||||
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');
|
||||
const token = login(db, 'adriano', 'segreta123')!;
|
||||
expect(getSessionUser(db, token)).toMatchObject({ role: 'admin' });
|
||||
});
|
||||
|
||||
it('admin accede a tutto, editor solo a contenuti e logout', () => {
|
||||
expect(canAccessAdminPath('admin', '/admin')).toBe(true);
|
||||
expect(canAccessAdminPath('admin', '/api/admin/posts')).toBe(true);
|
||||
expect(canAccessAdminPath('editor', '/admin/content')).toBe(true);
|
||||
expect(canAccessAdminPath('editor', '/api/admin/content/home.hero.title')).toBe(true);
|
||||
expect(canAccessAdminPath('editor', '/admin/logout')).toBe(true);
|
||||
expect(canAccessAdminPath('editor', '/admin')).toBe(false);
|
||||
expect(canAccessAdminPath('editor', '/admin/new')).toBe(false);
|
||||
expect(canAccessAdminPath('editor', '/api/admin/posts')).toBe(false);
|
||||
expect(canAccessAdminPath('editor', '/api/admin/upload')).toBe(false);
|
||||
expect(canAccessAdminPath('editor', '/admin/contentious')).toBe(false);
|
||||
expect(canAccessAdminPath('editor', '/admin/content-x')).toBe(false);
|
||||
expect(canAccessAdminPath('editor', '/admin/content')).toBe(true);
|
||||
expect(canAccessAdminPath('editor', '/admin/content/')).toBe(true);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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('/');
|
||||
expect(safeNext('/\\evil.com')).toBe('/');
|
||||
expect(safeNext('/\\/evil.com')).toBe('/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user