Admin : gestion groupes, services par groupes, badge admin

- admin/groups.php : liste/création/suppression des groupes Keycloak
  avec comptage des membres et services associés par groupe
- admin/services.php : remplace requires_adherent par sélection multi-groupes
- inc/services.php : modèle groups[], migration auto depuis requires_adherent,
  helper service_accessible() pour l'accès contextuel
- inc/keycloak.php : kc_list_groups, kc_create_group, kc_delete_group, kc_group_members
- profile.php : badge Admin visible dans la tuile Mon compte
- index.php : utilise service_accessible() avec les groupes de l'utilisateur

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alpinux 2026-05-04 00:59:25 +02:00
parent f5f831dfb0
commit 4921a0691c
8 changed files with 228 additions and 37 deletions

127
web/admin/groups.php Normal file
View file

@ -0,0 +1,127 @@
<?php
require_once __DIR__ . '/../inc/config.php';
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/keycloak.php';
require_once __DIR__ . '/../inc/services.php';
session_start_safe();
require_admin();
$errors = [];
$success = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'create') {
$name = trim($_POST['name'] ?? '');
if (!$name) {
$errors[] = 'Le nom du groupe est obligatoire.';
} else {
try {
kc_create_group($name);
set_flash('success', "Groupe « $name » créé.");
header('Location: /admin/groups.php'); exit;
} catch (Exception $e) { $errors[] = $e->getMessage(); }
}
}
if ($action === 'delete') {
$group_id = $_POST['group_id'] ?? '';
if ($group_id) {
try {
kc_delete_group($group_id);
set_flash('success', 'Groupe supprimé.');
header('Location: /admin/groups.php'); exit;
} catch (Exception $e) { $errors[] = $e->getMessage(); }
}
}
}
$groups = kc_list_groups();
$services = services_list();
// Indexe les services par groupe requis
$services_by_group = [];
foreach ($services as $s) {
foreach ($s['groups'] ?? [] as $g) {
$services_by_group[$g][] = $s['name'];
}
}
$title = 'Gestion des groupes';
require __DIR__ . '/../views/layout.php';
?>
<div class="admin-page">
<div class="page-header">
<h1>Gestion des groupes</h1>
<div class="admin-nav">
<a href="/admin/members.php">Membres</a>
<a href="/admin/groups.php" class="active">Groupes</a>
<a href="/admin/services.php">Services</a>
</div>
</div>
<?php if ($errors): ?>
<div class="alert alert-error"><ul><?php foreach ($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?></ul></div>
<?php endif; ?>
<!-- Liste des groupes -->
<div class="card">
<h2>Groupes Keycloak (<?= count($groups) ?>)</h2>
<?php if (!$groups): ?>
<p class="text-muted">Aucun groupe trouvé.</p>
<?php else: ?>
<table>
<thead>
<tr><th>Nom</th><th>Membres</th><th>Services associés</th><th>Actions</th></tr>
</thead>
<tbody>
<?php foreach ($groups as $g):
$members = kc_group_members($g['id']);
$linked = $services_by_group[$g['name']] ?? [];
?>
<tr>
<td><strong><?= htmlspecialchars($g['name']) ?></strong></td>
<td><?= count($members) ?></td>
<td>
<?php if ($linked): ?>
<?php foreach ($linked as $svc): ?>
<span class="badge"><?= htmlspecialchars($svc) ?></span>
<?php endforeach; ?>
<?php else: ?>
<span class="text-muted small"></span>
<?php endif; ?>
</td>
<td>
<form method="post" style="display:inline"
onsubmit="return confirm('Supprimer le groupe « <?= htmlspecialchars(addslashes($g['name'])) ?> » ?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="group_id" value="<?= htmlspecialchars($g['id']) ?>">
<button type="submit" class="btn-danger btn-sm">Supprimer</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- Créer un groupe -->
<div class="card">
<h2>Créer un groupe</h2>
<form method="post" style="display:flex; gap:.8rem; align-items:flex-end">
<input type="hidden" name="action" value="create">
<div class="form-group" style="margin:0; flex:1">
<label>Nom du groupe</label>
<input type="text" name="name" required placeholder="ex: bureau, moderateurs…"
value="<?= htmlspecialchars($_POST['name'] ?? '') ?>">
</div>
<button type="submit" class="btn-primary">Créer</button>
</form>
</div>
</div>
<?php require __DIR__ . '/../views/layout_end.php'; ?>

View file

@ -28,6 +28,7 @@ require __DIR__ . '/../views/layout.php';
<h1>Gestion des membres</h1> <h1>Gestion des membres</h1>
<div class="admin-nav"> <div class="admin-nav">
<a href="/admin/members.php" class="active">Membres</a> <a href="/admin/members.php" class="active">Membres</a>
<a href="/admin/groups.php">Groupes</a>
<a href="/admin/services.php">Services</a> <a href="/admin/services.php">Services</a>
</div> </div>
</div> </div>

View file

@ -2,10 +2,14 @@
require_once __DIR__ . '/../inc/config.php'; require_once __DIR__ . '/../inc/config.php';
require_once __DIR__ . '/../inc/auth.php'; require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/services.php'; require_once __DIR__ . '/../inc/services.php';
require_once __DIR__ . '/../inc/keycloak.php';
session_start_safe(); session_start_safe();
require_admin(); require_admin();
$kc_groups = kc_list_groups();
$group_names = array_column($kc_groups, 'name');
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$updated = []; $updated = [];
foreach ($_POST as $key => $val) { foreach ($_POST as $key => $val) {
@ -15,23 +19,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'name' => trim($val), 'name' => trim($val),
'url' => trim($_POST["url_$idx"] ?? ''), 'url' => trim($_POST["url_$idx"] ?? ''),
'description' => trim($_POST["description_$idx"] ?? ''), 'description' => trim($_POST["description_$idx"] ?? ''),
'requires_adherent' => isset($_POST["requires_$idx"]), 'groups' => (array)($_POST["groups_$idx"] ?? []),
'visible' => isset($_POST["visible_$idx"]), 'visible' => isset($_POST["visible_$idx"]),
]; ];
} }
// Ajout d'un nouveau service si rempli
$new_name = trim($_POST['new_name'] ?? ''); $new_name = trim($_POST['new_name'] ?? '');
if ($new_name) { if ($new_name) {
$updated[] = [ $updated[] = [
'name' => $new_name, 'name' => $new_name,
'url' => trim($_POST['new_url'] ?? ''), 'url' => trim($_POST['new_url'] ?? ''),
'description' => trim($_POST['new_description'] ?? ''), 'description' => trim($_POST['new_description'] ?? ''),
'requires_adherent' => isset($_POST['new_requires']), 'groups' => (array)($_POST['new_groups'] ?? []),
'visible' => isset($_POST['new_visible']), 'visible' => isset($_POST['new_visible']),
]; ];
} }
services_save($updated); services_save($updated);
set_flash('success', 'Configuration des services sauvegardée.'); set_flash('success', 'Services sauvegardés.');
header('Location: /admin/services.php'); header('Location: /admin/services.php');
exit; exit;
} }
@ -46,16 +49,12 @@ require __DIR__ . '/../views/layout.php';
<h1>Paramétrage des services</h1> <h1>Paramétrage des services</h1>
<div class="admin-nav"> <div class="admin-nav">
<a href="/admin/members.php">Membres</a> <a href="/admin/members.php">Membres</a>
<a href="/admin/groups.php">Groupes</a>
<a href="/admin/services.php" class="active">Services</a> <a href="/admin/services.php" class="active">Services</a>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<p class="text-muted">
Définissez quels services sont accessibles aux simples inscrits
et lesquels nécessitent une adhésion validée.
</p>
<form method="post"> <form method="post">
<table class="services-table"> <table class="services-table">
<thead> <thead>
@ -63,7 +62,7 @@ require __DIR__ . '/../views/layout.php';
<th>Nom</th> <th>Nom</th>
<th>URL</th> <th>URL</th>
<th>Description</th> <th>Description</th>
<th>Adhérent requis</th> <th>Groupes requis</th>
<th>Visible</th> <th>Visible</th>
</tr> </tr>
</thead> </thead>
@ -73,26 +72,46 @@ require __DIR__ . '/../views/layout.php';
<td><input type="text" name="name_<?= $i ?>" value="<?= htmlspecialchars($s['name']) ?>" required></td> <td><input type="text" name="name_<?= $i ?>" value="<?= htmlspecialchars($s['name']) ?>" required></td>
<td><input type="url" name="url_<?= $i ?>" value="<?= htmlspecialchars($s['url']) ?>"></td> <td><input type="url" name="url_<?= $i ?>" value="<?= htmlspecialchars($s['url']) ?>"></td>
<td><input type="text" name="description_<?= $i ?>" value="<?= htmlspecialchars($s['description']) ?>"></td> <td><input type="text" name="description_<?= $i ?>" value="<?= htmlspecialchars($s['description']) ?>"></td>
<td class="center"> <td>
<input type="checkbox" name="requires_<?= $i ?>" <?= $s['requires_adherent'] ? 'checked' : '' ?>> <div class="group-checks">
<?php foreach ($group_names as $gn): ?>
<label class="group-check-label">
<input type="checkbox" name="groups_<?= $i ?>[]" value="<?= htmlspecialchars($gn) ?>"
<?= in_array($gn, $s['groups'], true) ? 'checked' : '' ?>>
<?= htmlspecialchars($gn) ?>
</label>
<?php endforeach; ?>
<?php if (!$group_names): ?>
<span class="text-muted small">Aucun groupe</span>
<?php endif; ?>
</div>
</td> </td>
<td class="center"> <td class="center">
<input type="checkbox" name="visible_<?= $i ?>" <?= $s['visible'] ? 'checked' : '' ?>> <input type="checkbox" name="visible_<?= $i ?>" <?= $s['visible'] ? 'checked' : '' ?>>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<!-- Ligne ajout nouveau service -->
<tr class="new-row"> <tr class="new-row">
<td><input type="text" name="new_name" placeholder="Nouveau service"></td> <td><input type="text" name="new_name" placeholder="Nouveau service"></td>
<td><input type="url" name="new_url" placeholder="https://..."></td> <td><input type="url" name="new_url" placeholder="https://..."></td>
<td><input type="text" name="new_description" placeholder="Description"></td> <td><input type="text" name="new_description" placeholder="Description"></td>
<td class="center"><input type="checkbox" name="new_requires"></td> <td>
<div class="group-checks">
<?php foreach ($group_names as $gn): ?>
<label class="group-check-label">
<input type="checkbox" name="new_groups[]" value="<?= htmlspecialchars($gn) ?>">
<?= htmlspecialchars($gn) ?>
</label>
<?php endforeach; ?>
</div>
</td>
<td class="center"><input type="checkbox" name="new_visible" checked></td> <td class="center"><input type="checkbox" name="new_visible" checked></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div style="margin-top:1rem">
<button type="submit" class="btn-primary">Enregistrer</button> <button type="submit" class="btn-primary">Enregistrer</button>
</div>
</form> </form>
</div> </div>
</div> </div>

View file

@ -256,6 +256,8 @@ td.center { text-align: center; }
padding: .3rem .5rem; font-size: .85rem; padding: .3rem .5rem; font-size: .85rem;
} }
.new-row td { background: #eef1ff; } .new-row td { background: #eef1ff; }
.group-checks { display: flex; flex-direction: column; gap: .2rem; }
.group-check-label { display: flex; align-items: center; gap: .4rem; font-size: .8rem; cursor: pointer; white-space: nowrap; }
/* ── Helpers ──────────────────────────────────────────────────────── */ /* ── Helpers ──────────────────────────────────────────────────────── */
.text-success { color: var(--success); } .text-success { color: var(--success); }

View file

@ -174,6 +174,29 @@ function kc_list_users(int $max = 200): array {
return $users; return $users;
} }
function kc_list_groups(): array {
$resp = _kc_request('GET', '/groups?max=200&briefRepresentation=false');
if ($resp['status'] !== 200) return [];
return json_decode($resp['body'], true) ?? [];
}
function kc_create_group(string $name): void {
$resp = _kc_request('POST', '/groups', ['name' => $name]);
if ($resp['status'] === 409) throw new RuntimeException("Le groupe « $name » existe déjà.");
if ($resp['status'] !== 201) throw new RuntimeException("Erreur création groupe ({$resp['status']})");
}
function kc_delete_group(string $group_id): void {
$resp = _kc_request('DELETE', "/groups/$group_id");
if ($resp['status'] >= 300) throw new RuntimeException("Erreur suppression groupe ({$resp['status']})");
}
function kc_group_members(string $group_id, int $max = 200): array {
$resp = _kc_request('GET', "/groups/$group_id/members?max=$max");
if ($resp['status'] !== 200) return [];
return json_decode($resp['body'], true) ?? [];
}
function _kc_group_id(string $group_name): string { function _kc_group_id(string $group_name): string {
$resp = _kc_request('GET', '/groups?search=' . urlencode($group_name)); $resp = _kc_request('GET', '/groups?search=' . urlencode($group_name));
$groups = json_decode($resp['body'], true) ?? []; $groups = json_decode($resp['body'], true) ?? [];

View file

@ -2,23 +2,38 @@
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
const DEFAULT_SERVICES = [ const DEFAULT_SERVICES = [
['name' => 'Wiki', 'url' => 'https://wiki.alpinux.org', 'description' => 'Documentation et guides', 'requires_adherent' => false, 'visible' => true], ['name' => 'Wiki', 'url' => 'https://wiki.alpinux.org', 'description' => 'Documentation et guides', 'groups' => [], 'visible' => true],
['name' => 'Gitea', 'url' => 'https://gitea.alpinux.org', 'description' => 'Forge de code', 'requires_adherent' => false, 'visible' => true], ['name' => 'Gitea', 'url' => 'https://gitea.alpinux.org', 'description' => 'Forge de code', 'groups' => [], 'visible' => true],
['name' => 'Quiz interactifs','url' => 'https://dynamic.alpinux.org', 'description' => 'Jeux et quiz', 'requires_adherent' => false, 'visible' => true], ['name' => 'Quiz interactifs','url' => 'https://dynamic.alpinux.org', 'description' => 'Jeux et quiz', 'groups' => [], 'visible' => true],
['name' => 'Install Party', 'url' => 'https://installparty.alpinux.org', 'description' => 'Événements et ateliers', 'requires_adherent' => false, 'visible' => true], ['name' => 'Install Party', 'url' => 'https://installparty.alpinux.org', 'description' => 'Événements et ateliers', 'groups' => [], 'visible' => true],
['name' => 'Nextcloud', 'url' => 'https://cloud.alpinux.org', 'description' => 'Stockage et collaboration', 'requires_adherent' => true, 'visible' => true], ['name' => 'Nextcloud', 'url' => 'https://cloud.alpinux.org', 'description' => 'Stockage et collaboration', 'groups' => ['adherents'], 'visible' => true],
['name' => 'Dolibarr', 'url' => 'https://dolibarr.alpinux.org', 'description' => 'Gestion de l\'association', 'requires_adherent' => true, 'visible' => true], ['name' => 'Dolibarr', 'url' => 'https://dolibarr.alpinux.org', 'description' => 'Gestion de l\'association', 'groups' => ['adherents'], 'visible' => true],
]; ];
function services_list(): array { function services_list(): array {
$file = SERVICES_FILE; $file = SERVICES_FILE;
if (is_file($file)) { if (is_file($file)) {
$data = json_decode(file_get_contents($file), true); $data = json_decode(file_get_contents($file), true);
if (is_array($data)) return $data; if (is_array($data)) return array_map('_service_normalize', $data);
} }
return DEFAULT_SERVICES; return DEFAULT_SERVICES;
} }
function _service_normalize(array $s): array {
// Migre l'ancien champ requires_adherent vers groups
if (!array_key_exists('groups', $s)) {
$s['groups'] = ($s['requires_adherent'] ?? false) ? [ADHERENT_GROUP] : [];
}
unset($s['requires_adherent']);
return $s;
}
function service_accessible(array $service, array $user_groups, bool $is_admin): bool {
if ($is_admin) return true;
$required = $service['groups'] ?? [];
return empty($required) || (bool) array_intersect($required, $user_groups);
}
function services_save(array $services): void { function services_save(array $services): void {
$dir = dirname(SERVICES_FILE); $dir = dirname(SERVICES_FILE);
if (!is_dir($dir)) mkdir($dir, 0755, true); if (!is_dir($dir)) mkdir($dir, 0755, true);

View file

@ -9,10 +9,9 @@ $user = current_user();
$title = 'Portail Alpinux'; $title = 'Portail Alpinux';
$services = services_list(); $services = services_list();
$is_adherent = $user && ( $is_admin = $user && ($user['is_admin'] ?? false);
$user['is_adherent'] || $is_adherent = $user && ($user['is_adherent'] || $is_admin);
($user['is_admin'] ?? false) $user_groups = $user['groups'] ?? [];
);
require __DIR__ . '/views/layout.php'; require __DIR__ . '/views/layout.php';
?> ?>
@ -69,7 +68,7 @@ require __DIR__ . '/views/layout.php';
<h2>Nos services</h2> <h2>Nos services</h2>
<div class="grid"> <div class="grid">
<?php foreach ($services as $s): if (!$s['visible']) continue; <?php foreach ($services as $s): if (!$s['visible']) continue;
$can_access = !$s['requires_adherent'] || $is_adherent; ?> $can_access = service_accessible($s, $user_groups, $is_admin); ?>
<div class="service-card <?= $s['requires_adherent'] ? 'adherent-only' : '' ?> <?= (!$can_access) ? 'locked' : '' ?>"> <div class="service-card <?= $s['requires_adherent'] ? 'adherent-only' : '' ?> <?= (!$can_access) ? 'locked' : '' ?>">
<div class="service-header"> <div class="service-header">
<strong><?= htmlspecialchars($s['name']) ?></strong> <strong><?= htmlspecialchars($s['name']) ?></strong>

View file

@ -186,7 +186,12 @@ require __DIR__ . '/views/layout.php';
<div class="tile-header"> <div class="tile-header">
<div class="tile-label"> <div class="tile-label">
<div class="tile-title">Mon compte</div> <div class="tile-title">Mon compte</div>
<div class="tile-sub"><?= htmlspecialchars($user['username']) ?></div> <div class="tile-sub">
<?= htmlspecialchars($user['username']) ?>
<?php if ($user['is_admin']): ?>
<span class="badge badge-admin" style="margin-left:.4rem">Admin</span>
<?php endif; ?>
</div>
</div> </div>
<?php if ($edit !== 'email'): ?> <?php if ($edit !== 'email'): ?>
<a href="?edit=email" class="btn-outline btn-sm tile-action">Modifier</a> <a href="?edit=email" class="btn-outline btn-sm tile-action">Modifier</a>