added user manager page
This commit is contained in:
+87
-13
@@ -11,6 +11,7 @@ type AdminRecord = {
|
|||||||
username: string;
|
username: string;
|
||||||
salt: string;
|
salt: string;
|
||||||
hash: string;
|
hash: string;
|
||||||
|
createdAt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SessionRecord = {
|
type SessionRecord = {
|
||||||
@@ -23,40 +24,110 @@ function hashPassword(password: string, salt: string): string {
|
|||||||
return scryptSync(password, salt, 64).toString('hex');
|
return scryptSync(password, salt, 64).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadAdmins(): AdminRecord[] {
|
||||||
|
const raw = readJson<AdminRecord | AdminRecord[] | null>(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 } {
|
export function seedDefaultAdminIfMissing(): { username: string; tempPassword?: string } {
|
||||||
const existing = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
const admins = loadAdmins();
|
||||||
if (existing) return { username: existing.username };
|
if (admins.length > 0) {
|
||||||
|
// Ensure migration is persisted if legacy format was detected.
|
||||||
|
const raw = readJson<AdminRecord | AdminRecord[] | null>(ADMIN_FILE, null);
|
||||||
|
if (raw && !Array.isArray(raw)) saveAdmins(admins);
|
||||||
|
return { username: admins[0].username };
|
||||||
|
}
|
||||||
const username = process.env.ADMIN_USERNAME || 'admin';
|
const username = process.env.ADMIN_USERNAME || 'admin';
|
||||||
const password = process.env.ADMIN_PASSWORD || 'changeme';
|
const password = process.env.ADMIN_PASSWORD || 'changeme';
|
||||||
const salt = randomBytes(16).toString('hex');
|
const salt = randomBytes(16).toString('hex');
|
||||||
const record: AdminRecord = { username, salt, hash: hashPassword(password, salt) };
|
const record: AdminRecord = {
|
||||||
writeJson(ADMIN_FILE, record);
|
username,
|
||||||
|
salt,
|
||||||
|
hash: hashPassword(password, salt),
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
saveAdmins([record]);
|
||||||
return { username, tempPassword: password };
|
return { username, tempPassword: password };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyCredentials(username: string, password: string): boolean {
|
export function verifyCredentials(username: string, password: string): boolean {
|
||||||
const record = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
const record = loadAdmins().find((a) => a.username === username);
|
||||||
if (!record) return false;
|
if (!record) return false;
|
||||||
if (record.username !== username) return false;
|
|
||||||
const candidate = Buffer.from(hashPassword(password, record.salt), 'hex');
|
const candidate = Buffer.from(hashPassword(password, record.salt), 'hex');
|
||||||
const stored = Buffer.from(record.hash, 'hex');
|
const stored = Buffer.from(record.hash, 'hex');
|
||||||
if (candidate.length !== stored.length) return false;
|
if (candidate.length !== stored.length) return false;
|
||||||
return timingSafeEqual(candidate, stored);
|
return timingSafeEqual(candidate, stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changePassword(currentPassword: string, newPassword: string): boolean {
|
export function changePassword(
|
||||||
const record = readJson<AdminRecord | null>(ADMIN_FILE, null);
|
username: string,
|
||||||
if (!record) return false;
|
currentPassword: string,
|
||||||
if (!verifyCredentials(record.username, currentPassword)) return false;
|
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');
|
const salt = randomBytes(16).toString('hex');
|
||||||
writeJson(ADMIN_FILE, {
|
admins[idx] = {
|
||||||
username: record.username,
|
...admins[idx],
|
||||||
salt,
|
salt,
|
||||||
hash: hashPassword(newPassword, salt)
|
hash: hashPassword(newPassword, salt)
|
||||||
});
|
};
|
||||||
|
saveAdmins(admins);
|
||||||
return true;
|
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<SessionRecord[]>(SESSION_FILE, []).filter((s) => s.username !== username);
|
||||||
|
writeJson(SESSION_FILE, sessions);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
function loadSessions(): SessionRecord[] {
|
function loadSessions(): SessionRecord[] {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const all = readJson<SessionRecord[]>(SESSION_FILE, []);
|
const all = readJson<SessionRecord[]>(SESSION_FILE, []);
|
||||||
@@ -95,5 +166,8 @@ export function getSessionUser(cookies: Cookies): { username: string } | null {
|
|||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
const session = loadSessions().find((s) => s.token === token);
|
const session = loadSessions().find((s) => s.token === token);
|
||||||
if (!session) return null;
|
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 };
|
return { username: session.username };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
{ href: '/admin/testimonials', icon: 'reviews', label: 'Testimonianze' },
|
{ href: '/admin/testimonials', icon: 'reviews', label: 'Testimonianze' },
|
||||||
{ href: '/admin/submissions', icon: 'inbox', label: 'Richieste' },
|
{ href: '/admin/submissions', icon: 'inbox', label: 'Richieste' },
|
||||||
{ href: '/admin/notifications', icon: 'notifications', label: 'Notifiche' },
|
{ 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 {
|
function active(href: string): boolean {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { PageData, ActionData } from './$types';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
let password = $state('');
|
||||||
|
let confirm = $state('');
|
||||||
|
|
||||||
|
const mismatch = $derived(confirm.length > 0 && password !== confirm);
|
||||||
|
|
||||||
|
function formatDate(ts?: number): string {
|
||||||
|
if (!ts) return '—';
|
||||||
|
return new Date(ts).toLocaleDateString('it-IT', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Amministratori — Admin</title></svelte:head>
|
||||||
|
|
||||||
|
<header class="page-head">
|
||||||
|
<p class="kicker">Accessi</p>
|
||||||
|
<h1>Amministratori</h1>
|
||||||
|
<p>Crea nuovi account amministratore o rimuovi quelli non più necessari. Gli account rimossi vengono immediatamente disconnessi.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if form?.success}
|
||||||
|
<div class="alert-row">
|
||||||
|
{form.created ? `Amministratore "${form.created}" creato.` : `Amministratore "${form.deleted}" eliminato.`}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="alert-row err">{form.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Nuovo amministratore</h2>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/create"
|
||||||
|
use:enhance={() => async ({ update, result }) => {
|
||||||
|
await update();
|
||||||
|
if (result.type === 'success') {
|
||||||
|
password = '';
|
||||||
|
confirm = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="field">
|
||||||
|
<label for="new-username">Username</label>
|
||||||
|
<input
|
||||||
|
id="new-username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
pattern="[a-zA-Z0-9_.\-]+"
|
||||||
|
value={form?.username ?? ''}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<small>Solo lettere, numeri, . _ -</small>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="new-password">Password</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
bind:value={password}
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<small>Minimo 8 caratteri.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="new-confirm">Conferma password</label>
|
||||||
|
<input
|
||||||
|
id="new-confirm"
|
||||||
|
name="confirm"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
bind:value={confirm}
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
{#if mismatch}
|
||||||
|
<small style="color:#d64545;">Le password non coincidono.</small>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn" disabled={mismatch}>
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">person_add</span>
|
||||||
|
Crea amministratore
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-divider">
|
||||||
|
<h2>Amministratori esistenti</h2>
|
||||||
|
<span class="count">{data.admins.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<table class="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Creato il</th>
|
||||||
|
<th aria-label="Azioni"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.admins as a (a.username)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{a.username}</strong>
|
||||||
|
{#if data.admins.length > 0 && a.username === data.admins[0].username}
|
||||||
|
<span class="badge">primo admin</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(a.createdAt)}</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
use:enhance
|
||||||
|
onsubmit={(e) => {
|
||||||
|
if (!window.confirm(`Eliminare l'amministratore "${a.username}"?`)) e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="username" value={a.username} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-danger btn-sm"
|
||||||
|
disabled={data.admins.length <= 1}
|
||||||
|
title={data.admins.length <= 1 ? 'Serve almeno un amministratore' : 'Elimina'}
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">delete</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.users-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.users-table th,
|
||||||
|
.users-table td {
|
||||||
|
padding: 12px 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.users-table th {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.users-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
margin-left: 8px;
|
||||||
|
background: var(--panel-muted);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user