diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index eae5e90..294292f 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -11,6 +11,7 @@ type AdminRecord = { username: string; salt: string; hash: string; + createdAt?: number; }; type SessionRecord = { @@ -23,40 +24,110 @@ function hashPassword(password: string, salt: string): string { return scryptSync(password, salt, 64).toString('hex'); } +function loadAdmins(): AdminRecord[] { + const raw = readJson(ADMIN_FILE, null); + if (!raw) return []; + if (Array.isArray(raw)) return raw; + // Legacy single-record format — migrate in-memory (persisted on next write). + return [raw]; +} + +function saveAdmins(admins: AdminRecord[]): void { + writeJson(ADMIN_FILE, admins); +} + export function seedDefaultAdminIfMissing(): { username: string; tempPassword?: string } { - const existing = readJson(ADMIN_FILE, null); - if (existing) return { username: existing.username }; + const admins = loadAdmins(); + if (admins.length > 0) { + // Ensure migration is persisted if legacy format was detected. + const raw = readJson(ADMIN_FILE, null); + if (raw && !Array.isArray(raw)) saveAdmins(admins); + return { username: admins[0].username }; + } const username = process.env.ADMIN_USERNAME || 'admin'; const password = process.env.ADMIN_PASSWORD || 'changeme'; const salt = randomBytes(16).toString('hex'); - const record: AdminRecord = { username, salt, hash: hashPassword(password, salt) }; - writeJson(ADMIN_FILE, record); + const record: AdminRecord = { + username, + salt, + hash: hashPassword(password, salt), + createdAt: Date.now() + }; + saveAdmins([record]); return { username, tempPassword: password }; } export function verifyCredentials(username: string, password: string): boolean { - const record = readJson(ADMIN_FILE, null); + const record = loadAdmins().find((a) => a.username === username); if (!record) return false; - if (record.username !== username) return false; const candidate = Buffer.from(hashPassword(password, record.salt), 'hex'); const stored = Buffer.from(record.hash, 'hex'); if (candidate.length !== stored.length) return false; return timingSafeEqual(candidate, stored); } -export function changePassword(currentPassword: string, newPassword: string): boolean { - const record = readJson(ADMIN_FILE, null); - if (!record) return false; - if (!verifyCredentials(record.username, currentPassword)) return false; +export function changePassword( + username: string, + currentPassword: string, + newPassword: string +): boolean { + if (!verifyCredentials(username, currentPassword)) return false; + const admins = loadAdmins(); + const idx = admins.findIndex((a) => a.username === username); + if (idx === -1) return false; const salt = randomBytes(16).toString('hex'); - writeJson(ADMIN_FILE, { - username: record.username, + admins[idx] = { + ...admins[idx], salt, hash: hashPassword(newPassword, salt) - }); + }; + saveAdmins(admins); return true; } +export type AdminSummary = { username: string; createdAt?: number }; + +export function listAdmins(): AdminSummary[] { + return loadAdmins().map(({ username, createdAt }) => ({ username, createdAt })); +} + +export function createAdmin(username: string, password: string): { ok: true } | { ok: false; error: string } { + const clean = username.trim(); + if (!clean) return { ok: false, error: 'Username obbligatorio.' }; + if (clean.length < 3) return { ok: false, error: 'Username troppo corto (min 3 caratteri).' }; + if (!/^[a-zA-Z0-9_.-]+$/.test(clean)) { + return { ok: false, error: 'Username: usa solo lettere, numeri, . _ -' }; + } + if (password.length < 8) return { ok: false, error: 'Password troppo corta (min 8 caratteri).' }; + const admins = loadAdmins(); + if (admins.some((a) => a.username === clean)) { + return { ok: false, error: 'Username già in uso.' }; + } + const salt = randomBytes(16).toString('hex'); + admins.push({ + username: clean, + salt, + hash: hashPassword(password, salt), + createdAt: Date.now() + }); + saveAdmins(admins); + return { ok: true }; +} + +export function deleteAdmin(username: string): { ok: true } | { ok: false; error: string } { + const admins = loadAdmins(); + if (admins.length <= 1) { + return { ok: false, error: 'Impossibile eliminare l\'ultimo amministratore.' }; + } + const next = admins.filter((a) => a.username !== username); + if (next.length === admins.length) return { ok: false, error: 'Amministratore non trovato.' }; + saveAdmins(next); + // Revoke any active sessions for the removed user. + const sessions = readJson(SESSION_FILE, []).filter((s) => s.username !== username); + writeJson(SESSION_FILE, sessions); + return { ok: true }; +} + function loadSessions(): SessionRecord[] { const now = Date.now(); const all = readJson(SESSION_FILE, []); @@ -95,5 +166,8 @@ export function getSessionUser(cookies: Cookies): { username: string } | null { if (!token) return null; const session = loadSessions().find((s) => s.token === token); if (!session) return null; + // If the user has been removed since, invalidate. + const exists = loadAdmins().some((a) => a.username === session.username); + if (!exists) return null; return { username: session.username }; } diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index 350f3e7..078031d 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -16,7 +16,8 @@ { href: '/admin/testimonials', icon: 'reviews', label: 'Testimonianze' }, { href: '/admin/submissions', icon: 'inbox', label: 'Richieste' }, { href: '/admin/notifications', icon: 'notifications', label: 'Notifiche' }, - { href: '/admin/warranty', icon: 'verified_user', label: 'CiMa Warranty' } + { href: '/admin/warranty', icon: 'verified_user', label: 'CiMa Warranty' }, + { href: '/admin/users', icon: 'manage_accounts', label: 'Amministratori' } ]; function active(href: string): boolean { diff --git a/src/routes/admin/users/+page.server.ts b/src/routes/admin/users/+page.server.ts new file mode 100644 index 0000000..2ea7467 --- /dev/null +++ b/src/routes/admin/users/+page.server.ts @@ -0,0 +1,34 @@ +import type { Actions, PageServerLoad } from './$types'; +import { fail } from '@sveltejs/kit'; +import { createAdmin, deleteAdmin, listAdmins } from '$lib/server/auth'; + +export const load: PageServerLoad = () => { + return { admins: listAdmins() }; +}; + +export const actions: Actions = { + create: async ({ request }) => { + const form = await request.formData(); + const username = String(form.get('username') ?? ''); + const password = String(form.get('password') ?? ''); + const confirm = String(form.get('confirm') ?? ''); + if (password !== confirm) { + return fail(400, { error: 'Le password non coincidono.', username }); + } + const result = createAdmin(username, password); + if (!result.ok) return fail(400, { error: result.error, username }); + return { success: true, created: username.trim() }; + }, + + delete: async ({ request, locals }) => { + const form = await request.formData(); + const username = String(form.get('username') ?? '').trim(); + if (!username) return fail(400, { error: 'Username mancante.' }); + if (locals.admin?.username === username) { + return fail(400, { error: 'Non puoi eliminare il tuo stesso account.' }); + } + const result = deleteAdmin(username); + if (!result.ok) return fail(400, { error: result.error }); + return { success: true, deleted: username }; + } +}; diff --git a/src/routes/admin/users/+page.svelte b/src/routes/admin/users/+page.svelte new file mode 100644 index 0000000..1ef8dd2 --- /dev/null +++ b/src/routes/admin/users/+page.svelte @@ -0,0 +1,186 @@ + + +Amministratori — Admin + +
+

Accessi

+

Amministratori

+

Crea nuovi account amministratore o rimuovi quelli non più necessari. Gli account rimossi vengono immediatamente disconnessi.

+
+ +{#if form?.success} +
+ {form.created ? `Amministratore "${form.created}" creato.` : `Amministratore "${form.deleted}" eliminato.`} +
+{/if} +{#if form?.error} +
{form.error}
+{/if} + +
+

Nuovo amministratore

+
async ({ update, result }) => { + await update(); + if (result.type === 'success') { + password = ''; + confirm = ''; + } + }} + > +
+
+ + + Solo lettere, numeri, . _ - +
+
+ + + Minimo 8 caratteri. +
+
+
+ + + {#if mismatch} + Le password non coincidono. + {/if} +
+ +
+
+ +
+

Amministratori esistenti

+ {data.admins.length} +
+ +
+ + + + + + + + + + {#each data.admins as a (a.username)} + + + + + + {/each} + +
UsernameCreato il
+ {a.username} + {#if data.admins.length > 0 && a.username === data.admins[0].username} + primo admin + {/if} + {formatDate(a.createdAt)} +
{ + if (!window.confirm(`Eliminare l'amministratore "${a.username}"?`)) e.preventDefault(); + }} + > + + +
+
+
+ +