diff --git a/scripts/create-user.mjs b/scripts/create-user.mjs index 5bd0e67..8568b66 100644 --- a/scripts/create-user.mjs +++ b/scripts/create-user.mjs @@ -3,9 +3,13 @@ import bcrypt from 'bcryptjs'; import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; -const [, , username, password] = process.argv; +const [, , username, password, role = 'admin'] = process.argv; if (!username || !password) { - console.error('Uso: npm run create-user -- '); + console.error('Uso: npm run create-user -- [role]'); + process.exit(1); +} +if (!['admin', 'editor'].includes(role)) { + console.error('Ruolo non valido. Ammessi: admin, editor'); process.exit(1); } const path = process.env.DB_PATH ?? 'data/insanitylab.db'; @@ -14,10 +18,11 @@ const db = new Database(path); db.exec(`CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin' );`); db.prepare( - `INSERT INTO users (username, password_hash) VALUES (?, ?) - ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash` -).run(username, bcrypt.hashSync(password, 12)); + `INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?) + ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash, role = excluded.role` +).run(username, bcrypt.hashSync(password, 12), role); console.log(`Utente "${username}" creato/aggiornato in ${path}`); diff --git a/src/env.d.ts b/src/env.d.ts index 81f5f4a..6b05ca0 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -2,6 +2,6 @@ declare namespace App { interface Locals { - user?: { id: number; username: string }; + user?: { id: number; username: string; role: string }; } } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1946702..6c9ad44 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -5,6 +5,8 @@ import { randomBytes } from 'node:crypto'; export const SESSION_COOKIE = 'session'; const SESSION_DAYS = 7; +export type Role = 'admin' | 'editor'; + export function hashPassword(plain: string): string { return bcrypt.hashSync(plain, 12); } @@ -13,9 +15,9 @@ export function verifyPassword(plain: string, hash: string): boolean { return bcrypt.compareSync(plain, hash); } -export function createUser(db: Database.Database, username: string, password: string): number { - const res = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)') - .run(username, hashPassword(password)); +export function createUser(db: Database.Database, username: string, password: string, role: Role = 'admin'): number { + const res = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)') + .run(username, hashPassword(password), role); return Number(res.lastInsertRowid); } @@ -30,19 +32,27 @@ export function login(db: Database.Database, username: string, password: string) return token; } -export function getSessionUser(db: Database.Database, token: string): { id: number; username: string } | null { +export function getSessionUser(db: Database.Database, token: string): { id: number; username: string; role: string } | null { const row = db.prepare( - `SELECT s.token, s.expires_at, u.id, u.username + `SELECT s.token, s.expires_at, u.id, u.username, u.role FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?` - ).get(token) as { token: string; expires_at: string; id: number; username: string } | undefined; + ).get(token) as { token: string; expires_at: string; id: number; username: string; role: string } | undefined; if (!row) return null; if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) { db.prepare('DELETE FROM sessions WHERE token = ?').run(token); return null; } - return { id: row.id, username: row.username }; + return { id: row.id, username: row.username, role: row.role }; } 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$/]; + +export function canAccessAdminPath(role: string, pathname: string): boolean { + if (role === 'admin') return true; + return EDITOR_ALLOWED.some((re) => re.test(pathname)); +} diff --git a/tests/roles.test.ts b/tests/roles.test.ts new file mode 100644 index 0000000..53394b7 --- /dev/null +++ b/tests/roles.test.ts @@ -0,0 +1,33 @@ +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'; + +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 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); + }); +});