feat: rôles admin/user, page Utilisateurs, lien Vision dans navbar, filtre courriers par compte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jules
2026-05-06 00:01:39 +02:00
parent d11e6aec5e
commit 3f73421fd3
8 changed files with 305 additions and 28 deletions

View File

@@ -51,10 +51,12 @@ CREATE TABLE IF NOT EXISTS files (
); );
CREATE TABLE IF NOT EXISTS admin_users ( CREATE TABLE IF NOT EXISTS admin_users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE, username VARCHAR(64) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL, role ENUM('admin','user') NOT NULL DEFAULT 'admin',
created_at DATETIME DEFAULT NOW() account_id INT NULL,
password VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT NOW()
); );
-- Mot de passe par défaut: admin / copymail2024 -- Mot de passe par défaut: admin / copymail2024

View File

@@ -3,7 +3,7 @@ require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/layout.php'; require_once __DIR__ . '/../includes/layout.php';
auth_check(); admin_check();
$error = ''; $error = '';

View File

@@ -3,7 +3,7 @@ require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/layout.php'; require_once __DIR__ . '/../includes/layout.php';
auth_check(); admin_check();
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$fields = ['domain', 'hostname', 'smtp_port', 'submission_port', 'imap_port', 'imaps_port', 'smb_workgroup']; $fields = ['domain', 'hostname', 'smtp_port', 'submission_port', 'imap_port', 'imaps_port', 'smb_workgroup'];

View File

@@ -3,7 +3,7 @@ require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/layout.php'; require_once __DIR__ . '/../includes/layout.php';
auth_check(); admin_check();
$services = [ $services = [
'postfix' => ['Postfix (SMTP)', 'bi-envelope-at'], 'postfix' => ['Postfix (SMTP)', 'bi-envelope-at'],

232
web/admin/users.php Normal file
View File

@@ -0,0 +1,232 @@
<?php
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/layout.php';
admin_check();
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($_POST['action'] === 'create') {
$username = trim($_POST['username'] ?? '');
$password = trim($_POST['password'] ?? '');
$role = $_POST['role'] === 'admin' ? 'admin' : 'user';
$account_id = ($role === 'user' && !empty($_POST['account_id'])) ? (int)$_POST['account_id'] : null;
if (strlen($username) < 2) {
$error = 'Nom d\'utilisateur trop court.';
} elseif (strlen($password) < 6) {
$error = 'Mot de passe trop court (min 6 caractères).';
} else {
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
try {
db()->prepare('INSERT INTO admin_users (username, role, account_id, password) VALUES (?, ?, ?, ?)')
->execute([$username, $role, $account_id, $hash]);
flash('success', "Utilisateur <strong>{$username}</strong> créé.");
} catch (Exception $e) {
$error = "L'utilisateur « {$username} » existe déjà.";
}
}
if (!$error) { header('Location: /admin/users.php'); exit; }
}
if ($_POST['action'] === 'delete') {
$id = (int)$_POST['user_id'];
if ($id === (int)$_SESSION['admin_id']) {
flash('danger', 'Impossible de supprimer votre propre compte.');
} else {
db()->prepare('DELETE FROM admin_users WHERE id = ?')->execute([$id]);
flash('success', 'Utilisateur supprimé.');
}
header('Location: /admin/users.php'); exit;
}
if ($_POST['action'] === 'edit') {
$id = (int)$_POST['user_id'];
$role = $_POST['role'] === 'admin' ? 'admin' : 'user';
$account_id = ($role === 'user' && !empty($_POST['account_id'])) ? (int)$_POST['account_id'] : null;
$newPassword = trim($_POST['new_password'] ?? '');
db()->prepare('UPDATE admin_users SET role = ?, account_id = ? WHERE id = ?')
->execute([$role, $account_id, $id]);
if (strlen($newPassword) >= 6) {
$hash = password_hash($newPassword, PASSWORD_BCRYPT, ['cost' => 12]);
db()->prepare('UPDATE admin_users SET password = ? WHERE id = ?')->execute([$hash, $id]);
}
flash('success', 'Utilisateur mis à jour.');
header('Location: /admin/users.php'); exit;
}
}
$users = db()->query('SELECT u.*, a.username as account_name FROM admin_users u LEFT JOIN accounts a ON u.account_id = a.id ORDER BY u.role, u.username')->fetchAll();
$accounts = db()->query('SELECT id, username, email FROM accounts WHERE active = 1 ORDER BY username')->fetchAll();
html_head('Utilisateurs');
html_navbar('users');
?>
<div class="container-fluid">
<?php flash_render(); ?>
<div class="row g-4">
<!-- Formulaire création -->
<div class="col-lg-4">
<div class="card shadow-sm">
<div class="card-header fw-semibold"><i class="bi bi-person-plus-fill me-2"></i>Nouvel utilisateur</div>
<div class="card-body">
<?php if ($error): ?>
<div class="alert alert-danger py-2 small"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="post" id="create-form">
<input type="hidden" name="action" value="create">
<div class="mb-3">
<label class="form-label">Nom d'utilisateur</label>
<input type="text" name="username" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Mot de passe</label>
<input type="password" name="password" class="form-control" required minlength="6">
</div>
<div class="mb-3">
<label class="form-label">Rôle</label>
<select name="role" class="form-select" onchange="toggleAccount(this,'create-account')">
<option value="user">Utilisateur — Courriers uniquement</option>
<option value="admin">Administrateur — Accès complet</option>
</select>
</div>
<div class="mb-3" id="create-account">
<label class="form-label">Compte mail associé <span class="text-muted">(optionnel)</span></label>
<select name="account_id" class="form-select">
<option value="">— Tous les courriers —</option>
<?php foreach ($accounts as $acc): ?>
<option value="<?= $acc['id'] ?>"><?= htmlspecialchars($acc['username']) ?> — <?= htmlspecialchars($acc['email']) ?></option>
<?php endforeach; ?>
</select>
<div class="form-text">Si défini, l'utilisateur ne voit que les courriers de ce compte.</div>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-plus-circle me-1"></i>Créer
</button>
</form>
</div>
</div>
</div>
<!-- Liste -->
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-people-fill me-2"></i>Utilisateurs</span>
<span class="badge bg-secondary"><?= count($users) ?></span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Utilisateur</th>
<th>Rôle</th>
<th>Compte associé</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $u): ?>
<tr>
<td><i class="bi bi-person-circle me-1 text-muted"></i><?= htmlspecialchars($u['username']) ?></td>
<td>
<?php if ($u['role'] === 'admin'): ?>
<span class="badge bg-warning text-dark">admin</span>
<?php else: ?>
<span class="badge bg-secondary">user</span>
<?php endif; ?>
</td>
<td class="small text-muted">
<?= $u['account_name'] ? htmlspecialchars($u['account_name']) : '<span class="text-muted">tous</span>' ?>
</td>
<td class="text-center">
<div class="d-flex gap-1 justify-content-center">
<button class="btn btn-sm btn-outline-secondary" title="Modifier"
onclick="openEditUser(<?= $u['id'] ?>,'<?= addslashes($u['role']) ?>',<?= $u['account_id'] ?? 'null' ?>)">
<i class="bi bi-pencil"></i>
</button>
<?php if ($u['id'] != $_SESSION['admin_id']): ?>
<form method="post" onsubmit="return confirm('Supprimer cet utilisateur ?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="user_id" value="<?= $u['id'] ?>">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Modal édition -->
<div class="modal fade" id="editUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post">
<input type="hidden" name="action" value="edit">
<input type="hidden" name="user_id" id="eu-id">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-pencil me-2"></i>Modifier l'utilisateur</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Rôle</label>
<select name="role" id="eu-role" class="form-select" onchange="toggleAccount(this,'eu-account')">
<option value="user">Utilisateur — Courriers uniquement</option>
<option value="admin">Administrateur — Accès complet</option>
</select>
</div>
<div class="mb-3" id="eu-account">
<label class="form-label">Compte mail associé</label>
<select name="account_id" id="eu-account-sel" class="form-select">
<option value="">— Tous les courriers —</option>
<?php foreach ($accounts as $acc): ?>
<option value="<?= $acc['id'] ?>"><?= htmlspecialchars($acc['username']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">Nouveau mot de passe <span class="text-muted">(laisser vide)</span></label>
<input type="password" name="new_password" class="form-control" minlength="6">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-floppy-fill me-1"></i>Enregistrer</button>
</div>
</form>
</div>
</div>
</div>
<script>
function toggleAccount(sel, containerId) {
document.getElementById(containerId).style.display = sel.value === 'user' ? '' : 'none';
}
// Init au chargement
toggleAccount(document.querySelector('#create-form [name=role]'), 'create-account');
function openEditUser(id, role, accountId) {
document.getElementById('eu-id').value = id;
const roleSel = document.getElementById('eu-role');
roleSel.value = role;
toggleAccount(roleSel, 'eu-account');
const accSel = document.getElementById('eu-account-sel');
accSel.value = accountId || '';
new bootstrap.Modal(document.getElementById('editUserModal')).show();
}
</script>
<?php html_foot(); ?>

View File

@@ -5,15 +5,30 @@ require_once __DIR__ . '/../includes/auth.php';
auth_check(); auth_check();
header('Content-Type: application/json'); header('Content-Type: application/json');
$rows = db()->query( // Filtre par compte si user non-admin avec compte associé
'SELECT sf.id, sf.title, sf.sender, sf.date_processing, a.username, $accountFilter = current_account_id();
GROUP_CONCAT(f.id, ":", f.file_name, ":", f.path ORDER BY f.id SEPARATOR "|") as files if ($accountFilter) {
FROM source_file sf $st = db()->prepare(
LEFT JOIN accounts a ON sf.account_id = a.id 'SELECT sf.id, sf.title, sf.sender, sf.date_processing, a.username,
LEFT JOIN files f ON sf.id = f.source_file_id GROUP_CONCAT(f.id, ":", f.file_name, ":", f.path ORDER BY f.id SEPARATOR "|") as files
GROUP BY sf.id FROM source_file sf
ORDER BY sf.date_processing DESC' LEFT JOIN accounts a ON sf.account_id = a.id
)->fetchAll(); LEFT JOIN files f ON sf.id = f.source_file_id
WHERE sf.account_id = ?
GROUP BY sf.id ORDER BY sf.date_processing DESC'
);
$st->execute([$accountFilter]);
$rows = $st->fetchAll();
} else {
$rows = db()->query(
'SELECT sf.id, sf.title, sf.sender, sf.date_processing, a.username,
GROUP_CONCAT(f.id, ":", f.file_name, ":", f.path ORDER BY f.id SEPARATOR "|") as files
FROM source_file sf
LEFT JOIN accounts a ON sf.account_id = a.id
LEFT JOIN files f ON sf.id = f.source_file_id
GROUP BY sf.id ORDER BY sf.date_processing DESC'
)->fetchAll();
}
$toDelete = []; // source_file ids à supprimer $toDelete = []; // source_file ids à supprimer

View File

@@ -15,13 +15,23 @@ function auth_check(): void {
} }
} }
function admin_check(): void {
auth_check();
if ($_SESSION['admin_role'] !== 'admin') {
header('Location: /');
exit;
}
}
function auth_login(string $username, string $password): bool { function auth_login(string $username, string $password): bool {
$st = db()->prepare('SELECT id, password FROM admin_users WHERE username = ? LIMIT 1'); $st = db()->prepare('SELECT id, role, account_id, password FROM admin_users WHERE username = ? LIMIT 1');
$st->execute([$username]); $st->execute([$username]);
$row = $st->fetch(); $row = $st->fetch();
if ($row && password_verify($password, $row['password'])) { if ($row && password_verify($password, $row['password'])) {
$_SESSION['admin_id'] = $row['id']; $_SESSION['admin_id'] = $row['id'];
$_SESSION['admin_user'] = $username; $_SESSION['admin_user'] = $username;
$_SESSION['admin_role'] = $row['role'];
$_SESSION['admin_account_id'] = $row['account_id'];
return true; return true;
} }
return false; return false;
@@ -34,6 +44,9 @@ function auth_logout(): void {
exit; exit;
} }
function current_user(): string { function current_user(): string { return $_SESSION['admin_user'] ?? ''; }
return $_SESSION['admin_user'] ?? ''; function is_admin(): bool { return ($_SESSION['admin_role'] ?? '') === 'admin'; }
function current_account_id(): ?int {
$id = $_SESSION['admin_account_id'] ?? null;
return $id ? (int)$id : null;
} }

View File

@@ -1,5 +1,7 @@
<?php <?php
define('VISION_URL', 'http://192.168.111.212');
function html_head(string $title): void { function html_head(string $title): void {
$domain = cfg('domain', 'copymail'); $domain = cfg('domain', 'copymail');
echo <<<HTML echo <<<HTML
@@ -18,13 +20,23 @@ HTML;
} }
function html_navbar(string $active = ''): void { function html_navbar(string $active = ''): void {
$user = current_user(); $user = current_user();
$isAdmin = is_admin();
$pages = [ $pages = [
'mail' => ['/', 'bi-envelope-fill', 'Courriers'], 'mail' => ['/', 'bi-envelope-fill', 'Courriers'],
'accounts' => ['/admin/accounts.php', 'bi-people-fill', 'Comptes'],
'domain' => ['/admin/domain.php', 'bi-gear-fill', 'Configuration'],
'status' => ['/admin/index.php', 'bi-activity', 'Tableau de bord'],
]; ];
if ($isAdmin) {
$pages['accounts'] = ['/admin/accounts.php', 'bi-people-fill', 'Comptes'];
$pages['domain'] = ['/admin/domain.php', 'bi-gear-fill', 'Configuration'];
$pages['status'] = ['/admin/index.php', 'bi-activity', 'Tableau de bord'];
$pages['users'] = ['/admin/users.php', 'bi-person-lock', 'Utilisateurs'];
}
$roleBadge = $isAdmin
? '<span class="badge bg-warning text-dark me-2">admin</span>'
: '<span class="badge bg-secondary me-2">user</span>';
echo '<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">'; echo '<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">';
echo '<div class="container-fluid">'; echo '<div class="container-fluid">';
echo '<a class="navbar-brand fw-bold" href="/"><i class="bi bi-envelope-arrow-down-fill me-2"></i>CopyMail</a>'; echo '<a class="navbar-brand fw-bold" href="/"><i class="bi bi-envelope-arrow-down-fill me-2"></i>CopyMail</a>';
@@ -35,9 +47,12 @@ function html_navbar(string $active = ''): void {
echo "<li class=\"nav-item\"><a class=\"{$cls}\" href=\"{$href}\"><i class=\"bi {$icon} me-1\"></i>{$label}</a></li>"; echo "<li class=\"nav-item\"><a class=\"{$cls}\" href=\"{$href}\"><i class=\"bi {$icon} me-1\"></i>{$label}</a></li>";
} }
echo '</ul>'; echo '</ul>';
echo "<span class=\"navbar-text me-3\"><i class=\"bi bi-person-circle me-1\"></i>{$user}</span>"; echo '<div class="d-flex align-items-center gap-2">';
// Lien Vision
echo '<a class="btn btn-outline-light btn-sm" href="' . VISION_URL . '" target="_blank" title="Ouvrir Vision"><i class="bi bi-grid-fill me-1"></i>Vision</a>';
echo "{$roleBadge}<span class=\"navbar-text me-2\"><i class=\"bi bi-person-circle me-1\"></i>{$user}</span>";
echo '<a class="btn btn-outline-light btn-sm" href="/logout.php"><i class="bi bi-box-arrow-right me-1"></i>Déconnexion</a>'; echo '<a class="btn btn-outline-light btn-sm" href="/logout.php"><i class="bi bi-box-arrow-right me-1"></i>Déconnexion</a>';
echo '</div></div></nav>'; echo '</div></div></div></nav>';
} }
function html_foot(): void { function html_foot(): void {