Portail membres complet : profil, adhésion, historique, OTP

- Inscription sans redirection HelloAsso (acte volontaire séparé)
- Génération automatique d'identifiant AlpID (prenom.code mnémotechnique)
- Profil en tuiles : identité, compte, mot de passe, OTP, adhésion, adresse, connexions
- Double authentification : activation/suppression OTP via Keycloak
- Page d'accueil contextuelle (bienvenue si connecté, CTA adhésion si non adhérent)
- Historique des connexions avec statistiques et graphiques Chart.js
- Géocodage Nominatim + lien OpenStreetMap pour l'adresse
- HelloAsso : checkout intent, validation paiement, mise à jour Dolibarr

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alpinux 2026-05-04 00:43:38 +02:00
parent 0eaf9232fa
commit fcdd094e26
36 changed files with 2704 additions and 0 deletions

41
.env.example Normal file
View file

@ -0,0 +1,41 @@
# ── Clé secrète PHP sessions ──────────────────────────────────────────
SECRET_KEY=changez-moi-valeur-aleatoire-longue
# ── AlpID OIDC (client portail dans le realm alpinux) ─────────────────
ALPID_BASE=https://alpid.alpinux.org
ALPID_REALM=alpinux
ALPID_CLIENT_ID=portail
ALPID_CLIENT_SECRET=PortailAlpinux8281a984405f7c88d9b0
# ── Keycloak Admin REST API — compte de service (realm master) ────────
KC_SERVICE_CLIENT_ID=portail-service
KC_SERVICE_CLIENT_SECRET=
# ── Dolibarr REST API ──────────────────────────────────────────────────
DOLIBARR_URL=https://dolibarr.alpinux.org
DOLIBARR_API_KEY=
# ── Application ───────────────────────────────────────────────────────
APP_URL=https://portail.alpinux.org
HELLOASSO_URL=https://www.helloasso.com/associations/alpinux-le-lug-de-savoie/adhesions/adhesions-a-l-annee
# ── HelloAsso API ──────────────────────────────────────────────────────
HA_CLIENT_ID=
HA_CLIENT_SECRET=
HA_ORG_SLUG=alpinux-le-lug-de-savoie
HA_FORM_SLUG=adhesions-a-l-annee
HA_AMOUNT=1500
HA_ITEM_NAME=Adhésion Alpinux
# ── Groupes Keycloak ──────────────────────────────────────────────────
ADMIN_GROUPS=admins
ADHERENT_GROUP=adherents
# ── Fichier JSON de config des services ───────────────────────────────
# Par défaut : /var/www/clients/client1/web16/../services.json (hors web root)
SERVICES_FILE=/var/www/clients/client1/web16/private/services.json
# ── Déploiement (scripts/deploy.sh) ───────────────────────────────────
PORTAIL_HOST=alpinux.org
PORTAIL_PATH=/var/www/clients/client1/web16/web
# PORTAIL_USER=abonnelc # laisser vide pour utiliser l'alias SSH ~/.ssh/config

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.env
web/inc/config.local.php

105
scripts/deploy.sh Executable file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env bash
# deploy.sh — synchronise le portail PHP vers portail.alpinux.org
#
# Usage :
# ./deploy.sh # aperçu + confirmation
# ./deploy.sh -y # pousse sans confirmation
# ./deploy.sh -n # dry-run seulement
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/../.env"
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
if [ ! -f "$ENV_FILE" ]; then
echo -e "${RED}Erreur : fichier .env introuvable.${RESET}"
echo "Copier .env.example en .env et renseigner les valeurs."
exit 1
fi
# shellcheck source=/dev/null
source "$ENV_FILE"
LOCAL_DIR="$SCRIPT_DIR/../web"
REMOTE_HOST="${PORTAIL_HOST:-alpinux.org}"
REMOTE_PATH="${PORTAIL_PATH:-/var/www/clients/client1/web16/web}"
if [ -n "${PORTAIL_USER:-}" ]; then
REMOTE="${PORTAIL_USER}@${REMOTE_HOST}:${REMOTE_PATH}/"
else
REMOTE="${REMOTE_HOST}:${REMOTE_PATH}/"
fi
DRY_RUN=false; AUTO_YES=false
for arg in "$@"; do
case "$arg" in
-n|--dry-run) DRY_RUN=true ;;
-y|--yes) AUTO_YES=true ;;
esac
done
EXCLUDES=(
--exclude='.git/'
--exclude='.env'
--exclude='*.log'
# Fichiers gérés par ISPConfig — ne pas toucher
--exclude='error/'
--exclude='stats/'
--exclude='standard_index.html'
)
echo -e "${BOLD}Analyse des changements…${RESET}"
echo -e " Source : ${CYAN}$LOCAL_DIR/${RESET}"
echo -e " Cible : ${CYAN}$REMOTE${RESET}"
echo ""
DIFF=$(rsync -rlcz --dry-run --itemize-changes --delete \
--rsync-path="sudo rsync" \
"${EXCLUDES[@]}" \
"$LOCAL_DIR/" "$REMOTE" 2>&1)
NEW=0; CHANGED=0; DELETED=0
while IFS= read -r line; do
item="${line:0:11}"; file="${line:12}"
[ -z "$file" ] && continue
if [[ "$item" == *"deleting"* ]]; then echo -e " ${RED}supprimé ${RESET}$file"; DELETED=$((DELETED+1))
elif [[ "$item" =~ ^\<f\+{6,} ]]; then echo -e " ${GREEN}nouveau ${RESET}$file"; NEW=$((NEW+1))
elif [[ "$item" =~ ^\<f ]]; then echo -e " ${YELLOW}modifié ${RESET}$file"; CHANGED=$((CHANGED+1))
fi
done <<< "$DIFF"
TOTAL=$((NEW+CHANGED+DELETED))
if [ "$TOTAL" -eq 0 ]; then
echo -e "${GREEN}Tout est à jour.${RESET}"; exit 0
fi
echo ""
echo -e " ${GREEN}+$NEW nouveau(x)${RESET} ${YELLOW}~$CHANGED modifié(s)${RESET} ${RED}-$DELETED supprimé(s)${RESET}"
echo ""
if $DRY_RUN; then echo -e "${CYAN}Mode dry-run — aucune modification.${RESET}"; exit 0; fi
if ! $AUTO_YES; then
read -rp "Pousser vers $REMOTE_HOST ? [o/N] " confirm
[[ "$confirm" =~ ^[oOyY]$ ]] || { echo "Annulé."; exit 0; }
fi
DELETE_FLAG=()
if [ "$DELETED" -gt 0 ]; then
echo -e "${RED}${BOLD}$DELETED fichier(s) seront supprimés du serveur.${RESET}"
read -rp "Confirmer les suppressions ? [o/N] " confirm_del
[[ "$confirm_del" =~ ^[oOyY]$ ]] && DELETE_FLAG=(--delete)
fi
echo ""
echo -e "${BOLD}Synchronisation…${RESET}"
rsync -rlcz --human-readable --progress \
--rsync-path="sudo rsync" \
"${DELETE_FLAG[@]}" \
"${EXCLUDES[@]}" \
"$LOCAL_DIR/" "$REMOTE"
echo ""
echo -e "${GREEN}✓ Déploiement terminé.${RESET}"

View file

@ -0,0 +1,9 @@
# Directives Apache personnalisées pour portail.alpinux.org
# À coller dans ISPConfig : Sites > portail.alpinux.org > onglet "Options" > "Apache Directives"
# (valables pour les blocs HTTP et HTTPS)
# Supprime la config admin Flask obsolète (n'est plus sur ce vhost)
# ProxyPass /admin/ ... — à retirer d'ISPConfig si encore présent
# Le site PHP est servi directement par PHP-FPM — aucun ProxyPass nécessaire.
# S'assurer que ISPConfig est configuré avec PHP 8.1 pour ce site (web16).

6
web/.htaccess Normal file
View file

@ -0,0 +1,6 @@
Options -Indexes
# Fichiers sensibles
<FilesMatch "^\.env">
Require all denied
</FilesMatch>

110
web/admin/members.php Normal file
View file

@ -0,0 +1,110 @@
<?php
require_once __DIR__ . '/../inc/config.php';
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/keycloak.php';
session_start_safe();
require_admin();
$users = [];
$kc_error = null;
try {
$users = kc_list_users();
} catch (Exception $e) {
$kc_error = $e->getMessage();
}
// Sépare adhérents et inscrits en attente
$adherents = array_filter($users, fn($u) => in_array(ADHERENT_GROUP, $u['groupNames'], true));
$pending = array_filter($users, fn($u) => !in_array(ADHERENT_GROUP, $u['groupNames'], true));
$title = 'Gestion des membres';
require __DIR__ . '/../views/layout.php';
?>
<div class="admin-page">
<div class="page-header">
<h1>Gestion des membres</h1>
<div class="admin-nav">
<a href="/admin/members.php" class="active">Membres</a>
<a href="/admin/services.php">Services</a>
</div>
</div>
<?php if ($kc_error): ?>
<div class="alert alert-error">Erreur Keycloak : <?= htmlspecialchars($kc_error) ?></div>
<?php endif; ?>
<!-- Inscrits en attente de validation -->
<section class="card">
<h2>En attente de validation (<?= count($pending) ?>)</h2>
<?php if (!$pending): ?>
<p class="text-muted">Aucun inscrit en attente.</p>
<?php else: ?>
<table class="members-table">
<thead>
<tr>
<th>Nom</th><th>Identifiant</th><th>Email</th><th>Groupes</th><th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($pending as $u): ?>
<tr>
<td><?= htmlspecialchars(trim(($u['firstName'] ?? '') . ' ' . ($u['lastName'] ?? ''))) ?></td>
<td><?= htmlspecialchars($u['username'] ?? '') ?></td>
<td><?= htmlspecialchars($u['email'] ?? '') ?></td>
<td><?= htmlspecialchars(implode(', ', $u['groupNames']) ?: '—') ?></td>
<td>
<form method="post" action="/admin/validate.php" style="display:inline">
<input type="hidden" name="user_id" value="<?= htmlspecialchars($u['id']) ?>">
<button type="submit" class="btn-primary btn-sm"
onclick="return confirm('Valider l\'adhésion de <?= htmlspecialchars(addslashes($u['username'] ?? '')) ?> ?')">
Valider adhésion
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
<!-- Adhérents actifs -->
<section class="card">
<h2>Adhérents (<?= count($adherents) ?>)</h2>
<?php if (!$adherents): ?>
<p class="text-muted">Aucun adhérent.</p>
<?php else: ?>
<table class="members-table">
<thead>
<tr>
<th>Nom</th><th>Identifiant</th><th>Email</th><th>Groupes</th><th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($adherents as $u): ?>
<tr>
<td><?= htmlspecialchars(trim(($u['firstName'] ?? '') . ' ' . ($u['lastName'] ?? ''))) ?></td>
<td><?= htmlspecialchars($u['username'] ?? '') ?></td>
<td><?= htmlspecialchars($u['email'] ?? '') ?></td>
<td><?= htmlspecialchars(implode(', ', $u['groupNames'])) ?></td>
<td>
<form method="post" action="/admin/revoke.php" style="display:inline">
<input type="hidden" name="user_id" value="<?= htmlspecialchars($u['id']) ?>">
<button type="submit" class="btn-danger btn-sm"
onclick="return confirm('Révoquer l\'adhésion de <?= htmlspecialchars(addslashes($u['username'] ?? '')) ?> ?')">
Révoquer
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
</div>
<?php require __DIR__ . '/../views/layout_end.php'; ?>

22
web/admin/revoke.php Normal file
View file

@ -0,0 +1,22 @@
<?php
require_once __DIR__ . '/../inc/config.php';
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/keycloak.php';
session_start_safe();
require_admin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || empty($_POST['user_id'])) {
header('Location: /admin/members.php');
exit;
}
try {
kc_remove_from_group($_POST['user_id'], ADHERENT_GROUP);
set_flash('success', 'Adhésion révoquée.');
} catch (Exception $e) {
set_flash('error', 'Erreur : ' . $e->getMessage());
}
header('Location: /admin/members.php');
exit;

100
web/admin/services.php Normal file
View file

@ -0,0 +1,100 @@
<?php
require_once __DIR__ . '/../inc/config.php';
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/services.php';
session_start_safe();
require_admin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$updated = [];
foreach ($_POST as $key => $val) {
if (!str_starts_with($key, 'name_')) continue;
$idx = substr($key, 5);
$updated[] = [
'name' => trim($val),
'url' => trim($_POST["url_$idx"] ?? ''),
'description' => trim($_POST["description_$idx"] ?? ''),
'requires_adherent' => isset($_POST["requires_$idx"]),
'visible' => isset($_POST["visible_$idx"]),
];
}
// Ajout d'un nouveau service si rempli
$new_name = trim($_POST['new_name'] ?? '');
if ($new_name) {
$updated[] = [
'name' => $new_name,
'url' => trim($_POST['new_url'] ?? ''),
'description' => trim($_POST['new_description'] ?? ''),
'requires_adherent' => isset($_POST['new_requires']),
'visible' => isset($_POST['new_visible']),
];
}
services_save($updated);
set_flash('success', 'Configuration des services sauvegardée.');
header('Location: /admin/services.php');
exit;
}
$services = services_list();
$title = 'Paramétrage des services';
require __DIR__ . '/../views/layout.php';
?>
<div class="admin-page">
<div class="page-header">
<h1>Paramétrage des services</h1>
<div class="admin-nav">
<a href="/admin/members.php">Membres</a>
<a href="/admin/services.php" class="active">Services</a>
</div>
</div>
<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">
<table class="services-table">
<thead>
<tr>
<th>Nom</th>
<th>URL</th>
<th>Description</th>
<th>Adhérent requis</th>
<th>Visible</th>
</tr>
</thead>
<tbody>
<?php foreach ($services as $i => $s): ?>
<tr>
<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="text" name="description_<?= $i ?>" value="<?= htmlspecialchars($s['description']) ?>"></td>
<td class="center">
<input type="checkbox" name="requires_<?= $i ?>" <?= $s['requires_adherent'] ? 'checked' : '' ?>>
</td>
<td class="center">
<input type="checkbox" name="visible_<?= $i ?>" <?= $s['visible'] ? 'checked' : '' ?>>
</td>
</tr>
<?php endforeach; ?>
<!-- Ligne ajout nouveau service -->
<tr class="new-row">
<td><input type="text" name="new_name" placeholder="Nouveau service"></td>
<td><input type="url" name="new_url" placeholder="https://..."></td>
<td><input type="text" name="new_description" placeholder="Description"></td>
<td class="center"><input type="checkbox" name="new_requires"></td>
<td class="center"><input type="checkbox" name="new_visible" checked></td>
</tr>
</tbody>
</table>
<button type="submit" class="btn-primary">Enregistrer</button>
</form>
</div>
</div>
<?php require __DIR__ . '/../views/layout_end.php'; ?>

22
web/admin/validate.php Normal file
View file

@ -0,0 +1,22 @@
<?php
require_once __DIR__ . '/../inc/config.php';
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/keycloak.php';
session_start_safe();
require_admin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || empty($_POST['user_id'])) {
header('Location: /admin/members.php');
exit;
}
try {
kc_add_to_group($_POST['user_id'], ADHERENT_GROUP);
set_flash('success', 'Membre validé — groupe « ' . ADHERENT_GROUP . ' » assigné.');
} catch (Exception $e) {
set_flash('error', 'Erreur : ' . $e->getMessage());
}
header('Location: /admin/members.php');
exit;

349
web/assets/style.css Normal file
View file

@ -0,0 +1,349 @@
/* ── Reset & base ─────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f7fa;
--bg2: #ffffff;
--bg3: #eef0f5;
--border: #d5d9e8;
--primary: #3b5bdb;
--primary-h: #2f4ac4;
--success: #2b9e5e;
--warning: #d08a00;
--danger: #c0392b;
--text: #1a1d2e;
--text-muted:#6b7289;
--radius: 8px;
--shadow: 0 2px 16px rgba(0,0,0,.08);
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
line-height: 1.6;
}
a { color: var(--primary); text-decoration: none; }
a:hover { color: var(--primary-h); }
/* ── Header / Nav ─────────────────────────────────────────────────── */
header {
background: var(--bg2);
border-bottom: 1px solid var(--border);
padding: 0 2rem;
position: sticky; top: 0; z-index: 100;
}
nav {
max-width: 1100px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
}
.brand {
display: flex; align-items: center; gap: .6rem;
font-weight: 600; font-size: 1rem; color: var(--text);
}
.brand img { height: 28px; }
.nav-links { display: flex; align-items: center; gap: 1rem; }
.nav-links a { color: var(--text-muted); font-size: .9rem; }
.nav-links a:hover { color: var(--text); }
/* ── Buttons ──────────────────────────────────────────────────────── */
.btn-primary, .btn-outline, .btn-danger {
display: inline-block;
padding: .5rem 1.2rem;
border-radius: var(--radius);
font-size: .9rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: opacity .15s;
}
.btn-primary { background: var(--primary); color: #fff; }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-full { width: 100%; text-align: center; padding: .7rem; }
.btn-sm { padding: .3rem .8rem; font-size: .8rem; }
.btn-primary:hover, .btn-danger:hover { opacity: .85; }
.btn-outline:hover { border-color: var(--primary); color: var(--primary); }
/* ── Main ─────────────────────────────────────────────────────────── */
main {
flex: 1;
max-width: 1100px;
width: 100%;
margin: 0 auto;
padding: 2rem;
}
/* ── Flash ────────────────────────────────────────────────────────── */
.flash {
padding: .8rem 1.2rem;
border-radius: var(--radius);
margin-bottom: 1.5rem;
font-size: .9rem;
}
.flash-success { background: #eaf7f0; border: 1px solid var(--success); color: var(--success); }
.flash-error { background: #fdecea; border: 1px solid var(--danger); color: var(--danger); }
/* ── Cards ────────────────────────────────────────────────────────── */
.card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
margin-bottom: 1.5rem;
max-width: 600px;
}
.card.center { text-align: center; }
.card h1, .card h2 { margin-bottom: 1rem; font-size: 1.3rem; }
/* ── Alerts ───────────────────────────────────────────────────────── */
.alert { padding: .8rem 1rem; border-radius: var(--radius); margin-bottom: 1rem; font-size: .9rem; }
.alert-error { background: #fdecea; border: 1px solid var(--danger); color: var(--danger); }
.alert-success { background: #eaf7f0; border: 1px solid var(--success); color: var(--success); }
.alert ul { margin: 0; padding-left: 1.2rem; }
/* ── Forms ────────────────────────────────────────────────────────── */
.form-group { margin-bottom: 1.2rem; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
label { display: block; font-size: .85rem; color: var(--text-muted); margin-bottom: .4rem; }
input[type=text], input[type=email], input[type=password], input[type=url] {
width: 100%;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: .55rem .8rem;
font-size: .95rem;
transition: border-color .15s;
}
input:focus { outline: none; border-color: var(--primary); }
.form-footer { text-align: center; margin-top: 1rem; font-size: .85rem; color: var(--text-muted); }
.username-hint {
background: #eef1ff;
border: 1px solid var(--primary);
border-radius: var(--radius);
padding: .5rem .9rem;
font-size: .85rem;
color: var(--text);
margin-bottom: 1.2rem;
}
.subtitle { color: var(--text-muted); font-size: .9rem; margin-bottom: 1.5rem; }
/* ── Hero ─────────────────────────────────────────────────────────── */
.hero {
text-align: center;
padding: 4rem 1rem 3rem;
}
.hero h1 { font-size: 2rem; margin-bottom: .8rem; }
.hero p { color: var(--text-muted); font-size: 1.1rem; margin-bottom: 2rem; }
.hero-actions { display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap; }
/* ── Home about ───────────────────────────────────────────────────── */
.home-about { margin: 0 0 2.5rem; }
.about-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
.about-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.2rem 1.4rem;
}
.about-title { font-weight: 600; font-size: .9rem; margin-bottom: .5rem; }
.about-card p { font-size: .85rem; color: var(--text-muted); line-height: 1.6; }
/* ── Services grid ────────────────────────────────────────────────── */
.services-grid h2, .services-section h2 {
font-size: 1.2rem; margin-bottom: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.service-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.2rem;
}
.service-card.locked { opacity: .5; }
.service-card.adherent-only { border-color: var(--primary); }
.service-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; }
.service-card p { font-size: .85rem; color: var(--text-muted); margin: .5rem 0; }
/* ── Badges ───────────────────────────────────────────────────────── */
.badge {
font-size: .7rem; font-weight: 600;
padding: .2rem .5rem;
border-radius: 4px;
background: #dde4ff;
color: var(--primary);
}
.badge-inscrit { background: var(--bg3); color: var(--text-muted); }
.badge-adherent { background: #d4f0e4; color: var(--success); }
.badge-admin { background: #fef3d0; color: var(--warning); }
/* ── Dashboard ────────────────────────────────────────────────────── */
.dashboard-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
.dashboard-header h1 { font-size: 1.5rem; }
.badges { display: flex; gap: .5rem; }
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem; }
.dashboard-grid .card { max-width: none; }
.services-section { margin-top: 1rem; }
dl { display: grid; grid-template-columns: auto 1fr; gap: .4rem 1rem; margin-bottom: 1rem; }
dt { color: var(--text-muted); font-size: .85rem; }
dd { font-size: .9rem; }
/* ── Steps (inscription réussie) ──────────────────────────────────── */
.success-icon {
width: 60px; height: 60px;
background: #d4f0e4;
color: var(--success);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 1.8rem;
margin: 0 auto 1.5rem;
}
.steps { text-align: left; margin: 1.5rem 0; display: flex; flex-direction: column; gap: 1.2rem; }
.step { display: flex; gap: 1rem; align-items: flex-start; }
.step-num {
width: 28px; height: 28px; flex-shrink: 0;
border-radius: 50%;
background: var(--bg3); border: 2px solid var(--border);
display: flex; align-items: center; justify-content: center;
font-size: .85rem; font-weight: 700;
}
.step.done .step-num { background: var(--success); border-color: var(--success); color: #fff; }
.step strong { display: block; margin-bottom: .2rem; }
.step p { font-size: .85rem; color: var(--text-muted); margin: .3rem 0; }
/* ── Admin ────────────────────────────────────────────────────────── */
.admin-page { max-width: 1000px; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
.page-header h1 { font-size: 1.4rem; }
.admin-nav { display: flex; gap: .5rem; }
.admin-nav a {
padding: .4rem .9rem; border-radius: var(--radius);
font-size: .85rem; color: var(--text-muted);
border: 1px solid transparent;
}
.admin-nav a.active, .admin-nav a:hover { border-color: var(--border); color: var(--text); }
.admin-page .card { max-width: none; }
.admin-page .card h2 { font-size: 1rem; margin-bottom: 1rem; color: var(--text-muted); }
table { width: 100%; border-collapse: collapse; font-size: .85rem; }
th { text-align: left; padding: .5rem .7rem; color: var(--text-muted); border-bottom: 1px solid var(--border); }
td { padding: .6rem .7rem; border-bottom: 1px solid var(--border); }
td.center { text-align: center; }
.members-table tr:last-child td { border-bottom: none; }
.services-table input[type=text], .services-table input[type=url] {
padding: .3rem .5rem; font-size: .85rem;
}
.new-row td { background: #eef1ff; }
/* ── Helpers ──────────────────────────────────────────────────────── */
.text-success { color: var(--success); }
.text-warning { color: var(--warning); }
.text-muted { color: var(--text-muted); }
.small { font-size: .8rem; }
/* ── Profile tuiles ───────────────────────────────────────────────── */
.profile-overview {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.2rem;
align-items: start;
}
.tile {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.tile-open { border-color: var(--primary); }
.tile-header {
display: flex;
align-items: center;
gap: .8rem;
padding: 1rem 1.2rem;
}
.tile-icon { font-size: 1.4rem; flex-shrink: 0; }
.tile-title { font-weight: 600; font-size: .95rem; }
.tile-sub { font-size: .8rem; color: var(--text-muted); margin-top: .1rem; }
.tile-action { margin-left: auto; flex-shrink: 0; }
.tile-body {
padding: 0 1.2rem 1.2rem;
border-top: 1px solid var(--border);
padding-top: .8rem;
}
/* ── History ──────────────────────────────────────────────────────── */
.history-page { max-width: 1100px; }
.history-page h1 { font-size: 1.4rem; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
text-align: center;
}
.stat-warning { border-color: var(--danger); }
.stat-value { font-size: 2rem; font-weight: 700; color: var(--primary); line-height: 1.1; }
.stat-warning .stat-value { color: var(--danger); }
.stat-label { font-size: .75rem; color: var(--text-muted); margin-top: .3rem; }
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
gap: 1.2rem;
margin-bottom: 1.5rem;
}
.chart-card { max-width: none; }
.chart-card h2 { font-size: .9rem; color: var(--text-muted); margin-bottom: .8rem; }
.ip-bars { display: flex; flex-direction: column; gap: .5rem; margin-top: .5rem; }
.ip-bar-row { display: flex; align-items: center; gap: .6rem; font-size: .8rem; }
.ip-label { width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); }
.ip-bar-wrap { flex: 1; background: var(--bg3); border-radius: 4px; height: 10px; }
.ip-bar { background: var(--primary); height: 10px; border-radius: 4px; min-width: 4px; }
.ip-count { width: 24px; text-align: right; font-weight: 600; }
/* ── Footer ───────────────────────────────────────────────────────── */
footer {
text-align: center;
padding: 1.5rem;
font-size: .8rem;
color: var(--text-muted);
border-top: 1px solid var(--border);
margin-top: auto;
}
footer a { color: var(--text-muted); }
footer a:hover { color: var(--text); }
/* ── Responsive ───────────────────────────────────────────────────── */
@media (max-width: 700px) {
.dashboard-grid { grid-template-columns: 1fr; }
.form-row { grid-template-columns: 1fr; }
.page-header { flex-direction: column; align-items: flex-start; gap: .8rem; }
}

52
web/auth/callback.php Normal file
View file

@ -0,0 +1,52 @@
<?php
require_once __DIR__ . '/../inc/config.php';
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/oidc.php';
session_start_safe();
// Vérification state CSRF
$state = $_GET['state'] ?? '';
if (!$state || $state !== ($_SESSION['oidc_state'] ?? '')) {
http_response_code(400);
exit('Erreur : state OIDC invalide.');
}
unset($_SESSION['oidc_state'], $_SESSION['oidc_nonce']);
$code = $_GET['code'] ?? '';
if (!$code) {
$error = $_GET['error_description'] ?? $_GET['error'] ?? 'Connexion annulée.';
header('Location: /?error=' . urlencode($error));
exit;
}
try {
$tokens = oidc_exchange_code($code);
$userinfo = oidc_userinfo($tokens['access_token']);
$groups = $userinfo['groups'] ?? [];
$is_admin = (bool)array_intersect(ADMIN_GROUPS, $groups);
$is_adherent = in_array(ADHERENT_GROUP, $groups, true) || $is_admin;
$_SESSION['user'] = [
'sub' => $userinfo['sub'],
'name' => $userinfo['name'] ?? $userinfo['preferred_username'] ?? '',
'first_name' => $userinfo['given_name'] ?? '',
'last_name' => $userinfo['family_name'] ?? '',
'email' => $userinfo['email'] ?? '',
'username' => $userinfo['preferred_username'] ?? '',
'groups' => $groups,
'is_admin' => $is_admin,
'is_adherent' => $is_adherent,
];
$_SESSION['id_token'] = $tokens['id_token'] ?? '';
$next = $_SESSION['next_url'] ?? '/profile.php';
unset($_SESSION['next_url']);
header('Location: ' . $next);
exit;
} catch (Exception $e) {
http_response_code(500);
exit('Erreur d\'authentification : ' . htmlspecialchars($e->getMessage()));
}

14
web/auth/login.php Normal file
View file

@ -0,0 +1,14 @@
<?php
require_once __DIR__ . '/../inc/config.php';
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/oidc.php';
session_start_safe();
if (current_user()) {
header('Location: /dashboard.php');
exit;
}
header('Location: ' . oidc_authorization_url());
exit;

117
web/dashboard.php Normal file
View file

@ -0,0 +1,117 @@
<?php
require_once __DIR__ . '/inc/config.php';
require_once __DIR__ . '/inc/auth.php';
require_once __DIR__ . '/inc/dolibarr.php';
require_once __DIR__ . '/inc/services.php';
session_start_safe();
require_login();
$user = current_user();
// Dolibarr est la source de vérité pour l'adhésion
$membership = null;
$doli_error = null;
try {
$membership = doli_get_membership($user['email']);
} catch (Exception $e) {
$doli_error = $e->getMessage();
}
// is_adherent = groupe KC OU cotisation active dans Dolibarr
$is_adherent = $user['is_adherent']
|| ($membership && $membership['status'] === 1 && $membership['date_fin_ts'] > time());
$services = services_list();
$title = 'Tableau de bord';
require __DIR__ . '/views/layout.php';
?>
<div class="dashboard">
<div class="dashboard-header">
<h1>Bonjour, <?= htmlspecialchars($user['name']) ?></h1>
<div class="badges">
<span class="badge badge-inscrit">Inscrit</span>
<?php if ($is_adherent): ?>
<span class="badge badge-adherent">Adhérent</span>
<?php endif; ?>
<?php if ($user['is_admin']): ?>
<span class="badge badge-admin">Admin</span>
<?php endif; ?>
</div>
</div>
<div class="dashboard-grid">
<!-- Profil -->
<div class="card">
<h2>Mon compte</h2>
<dl>
<dt>Identifiant</dt><dd><?= htmlspecialchars($user['username']) ?></dd>
<dt>Email</dt><dd><?= htmlspecialchars($user['email']) ?></dd>
<?php if ($user['groups']): ?>
<dt>Groupes</dt><dd><?= htmlspecialchars(implode(', ', $user['groups'])) ?></dd>
<?php endif; ?>
</dl>
<a href="/password.php" class="btn-outline btn-sm">Changer mon mot de passe</a>
</div>
<!-- Adhésion -->
<div class="card">
<h2>Mon adhésion</h2>
<?php if ($doli_error): ?>
<p class="text-warning">Impossible de contacter Dolibarr.</p>
<?php elseif ($membership): ?>
<dl>
<dt>Statut</dt>
<dd><?= $membership['status'] === 1
? '<span class="text-success">Actif</span>'
: '<span class="text-warning">Inactif</span>' ?></dd>
<?php if ($membership['date_fin']): ?>
<dt>Cotisation jusqu\'au</dt>
<dd><?= htmlspecialchars($membership['date_fin']) ?></dd>
<?php endif; ?>
<?php if ($membership['type_label']): ?>
<dt>Type</dt><dd><?= htmlspecialchars($membership['type_label']) ?></dd>
<?php endif; ?>
</dl>
<a href="/helloasso/renew.php" class="btn-outline btn-sm">
Renouveler mon adhésion
</a>
<?php else: ?>
<p>Aucune adhésion trouvée.</p>
<p class="text-muted small">Adhérez pour accéder aux services réservés aux membres.</p>
<a href="<?= htmlspecialchars(HELLOASSO_URL) ?>" target="_blank" rel="noopener" class="btn-primary">
Adhérer via HelloAsso
</a>
<p class="text-muted small" style="margin-top:.8rem">Un administrateur validera votre adhésion sous 48h.</p>
<?php endif; ?>
</div>
</div>
<!-- Services -->
<section class="services-section">
<h2>Mes services</h2>
<div class="grid">
<?php foreach ($services as $s): if (!$s['visible']) continue;
$accessible = !$s['requires_adherent'] || $is_adherent;
?>
<div class="service-card <?= $accessible ? '' : 'locked' ?>">
<div class="service-header">
<strong><?= htmlspecialchars($s['name']) ?></strong>
<?php if ($s['requires_adherent']): ?>
<span class="badge"><?= $accessible ? 'Adhérent' : 'Adhérent requis' ?></span>
<?php endif; ?>
</div>
<p><?= htmlspecialchars($s['description']) ?></p>
<?php if ($accessible): ?>
<a href="<?= htmlspecialchars($s['url']) ?>" target="_blank" rel="noopener">Accéder</a>
<?php else: ?>
<span class="text-muted small">Adhésion requise</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</section>
</div>
<?php require __DIR__ . '/views/layout_end.php'; ?>

BIN
web/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -0,0 +1,69 @@
<?php
require_once __DIR__ . '/../inc/config.php';
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/helloasso.php';
require_once __DIR__ . '/../inc/keycloak.php';
require_once __DIR__ . '/../inc/dolibarr.php';
session_start_safe();
$checkout_id = $_GET['checkoutIntentId'] ?? '';
$kc_uid = $_GET['uid'] ?? '';
if (!$checkout_id || !$kc_uid) {
set_flash('error', 'Retour de paiement invalide.');
header('Location: /register.php');
exit;
}
try {
$checkout = ha_get_checkout($checkout_id);
$order = $checkout['order'] ?? null;
$payments = $order['payments'] ?? [];
$paid = false;
$amount = 0;
foreach ($payments as $p) {
if (($p['state'] ?? '') === 'Authorized') {
$paid = true;
$amount = (int)(($p['amount'] ?? HA_AMOUNT) / 100); // centimes → euros
break;
}
}
if (!$paid) {
set_flash('error', 'Paiement non confirmé. Veuillez réessayer ou contacter un administrateur.');
header('Location: /helloasso/renew.php');
exit;
}
// 1. Keycloak : ajouter au groupe adherents (idempotent)
kc_add_to_group($kc_uid, ADHERENT_GROUP);
// 2. Dolibarr : créer la cotisation (+1 an)
$kc_user = _kc_get_user($kc_uid);
$email = $kc_user['email'] ?? '';
$membership = $email ? doli_get_membership($email) : null;
if ($membership) {
doli_add_subscription($membership['id'], $membership['date_fin_ts'], $amount ?: HA_AMOUNT / 100);
}
// 3. TODO : Nextcloud — URL et credentials à configurer
// nc_add_to_group($email, 'adherents');
set_flash('success', 'Paiement confirmé ! Votre adhésion est activée.');
$next = current_user() ? '/dashboard.php' : '/auth/login.php';
header('Location: ' . $next);
exit;
} catch (Exception $e) {
set_flash('error', 'Erreur lors de la validation : ' . $e->getMessage());
header('Location: /helloasso/renew.php');
exit;
}
function _kc_get_user(string $uid): array {
$resp = _kc_request('GET', "/users/$uid");
return $resp['status'] === 200 ? json_decode($resp['body'], true) ?? [] : [];
}

92
web/helloasso/renew.php Normal file
View file

@ -0,0 +1,92 @@
<?php
require_once __DIR__ . '/../inc/config.php';
require_once __DIR__ . '/../inc/auth.php';
require_once __DIR__ . '/../inc/helloasso.php';
require_once __DIR__ . '/../inc/dolibarr.php';
session_start_safe();
require_login();
$user = current_user();
$membership = doli_get_membership($user['email']);
$first_name = $membership['firstname'] ?? $user['username'];
$last_name = $membership['lastname'] ?? $user['username'];
$address = $membership['address'] ?? '';
$zip = $membership['zip'] ?? '';
$town = $membership['town'] ?? '';
$country = $membership['country_code'] ?? 'FR';
$errors = [];
// Si l'adresse est complète, on part directement vers HelloAsso
if ($address && $zip && $town && $_SERVER['REQUEST_METHOD'] !== 'POST') {
_redirect_to_helloasso($first_name, $last_name, $user['email'], $user['sub'], $address, $zip, $town, $country);
}
// Traitement du formulaire d'adresse
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$address = trim($_POST['address'] ?? '');
$zip = trim($_POST['zip'] ?? '');
$town = trim($_POST['town'] ?? '');
if (!$address) $errors[] = 'L\'adresse est obligatoire.';
if (!$zip) $errors[] = 'Le code postal est obligatoire.';
if (!$town) $errors[] = 'La ville est obligatoire.';
if (!$errors) {
_redirect_to_helloasso($first_name, $last_name, $user['email'], $user['sub'], $address, $zip, $town, $country);
}
}
function _redirect_to_helloasso(string $fn, string $ln, string $email, string $uid,
string $addr, string $zip, string $town, string $country): void {
try {
$checkout = ha_create_checkout($fn, $ln, $email, $uid, $addr, $zip, $town, $country);
header('Location: ' . $checkout['redirectUrl']);
exit;
} catch (Exception $e) {
set_flash('error', 'Impossible de créer le lien de paiement : ' . $e->getMessage());
header('Location: /dashboard.php');
exit;
}
}
$title = 'Renouveler mon adhésion';
require __DIR__ . '/../views/layout.php';
?>
<div class="card">
<h1>Renouveler mon adhésion</h1>
<p class="subtitle">Votre adresse postale est nécessaire pour finaliser l'adhésion.</p>
<?php if ($errors): ?>
<div class="alert alert-error">
<ul><?php foreach ($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?></ul>
</div>
<?php endif; ?>
<form method="post" novalidate>
<div class="form-group">
<label for="address">Adresse</label>
<input type="text" id="address" name="address" required
placeholder="1 rue de la Paix"
value="<?= htmlspecialchars($address) ?>">
</div>
<div class="form-row">
<div class="form-group">
<label for="zip">Code postal</label>
<input type="text" id="zip" name="zip" required maxlength="10"
value="<?= htmlspecialchars($zip) ?>">
</div>
<div class="form-group">
<label for="town">Ville</label>
<input type="text" id="town" name="town" required
value="<?= htmlspecialchars($town) ?>">
</div>
</div>
<button type="submit" class="btn-primary btn-full">Continuer vers le paiement</button>
</form>
</div>
<?php require __DIR__ . '/../views/layout_end.php'; ?>

202
web/history.php Normal file
View file

@ -0,0 +1,202 @@
<?php
require_once __DIR__ . '/inc/config.php';
require_once __DIR__ . '/inc/auth.php';
require_once __DIR__ . '/inc/keycloak.php';
session_start_safe();
require_login();
$user = current_user();
// Récupérer les events LOGIN depuis Keycloak Admin API
function kc_events(string $user_id, string $type = 'LOGIN', int $max = 500): array {
$resp = _kc_request('GET', '/events?user=' . urlencode($user_id) . '&type=' . $type . '&max=' . $max);
return $resp['status'] === 200 ? (json_decode($resp['body'], true) ?? []) : [];
}
$logins = kc_events($user['sub'], 'LOGIN', 500);
$errors = kc_events($user['sub'], 'LOGIN_ERROR', 200);
$logouts = kc_events($user['sub'], 'LOGOUT', 200);
// ── Calculs stats ─────────────────────────────────────────────────────
$now = time() * 1000;
$ms_30d = 30 * 86400 * 1000;
$logins_30d = array_filter($logins, fn($e) => $e['time'] >= ($now - $ms_30d));
$errors_30d = array_filter($errors, fn($e) => $e['time'] >= ($now - $ms_30d));
// Logins par jour (30 derniers jours)
$by_day = [];
for ($i = 29; $i >= 0; $i--) {
$d = date('d/m', strtotime("-$i days"));
$by_day[$d] = 0;
}
foreach ($logins_30d as $e) {
$d = date('d/m', (int)($e['time'] / 1000));
if (isset($by_day[$d])) $by_day[$d]++;
}
// Logins par heure (0-23)
$by_hour = array_fill(0, 24, 0);
foreach ($logins as $e) {
$h = (int)date('G', (int)($e['time'] / 1000));
$by_hour[$h]++;
}
// Logins par jour de semaine (lundi = 0)
$days_fr = ['Lun','Mar','Mer','Jeu','Ven','Sam','Dim'];
$by_dow = array_fill(0, 7, 0);
foreach ($logins as $e) {
$by_dow[((int)date('w', (int)($e['time'] / 1000)) + 6) % 7]++;
}
// IPs uniques
$ips = array_count_values(array_column($logins, 'ipAddress'));
arsort($ips);
// Dernière connexion
$last_login = $logins ? max(array_column($logins, 'time')) : null;
$title = 'Historique des connexions';
require __DIR__ . '/views/layout.php';
?>
<div class="history-page">
<div class="page-header">
<h1>Historique des connexions</h1>
<a href="/profile.php" class="btn-outline btn-sm">Retour au profil</a>
</div>
<!-- Chiffres clés -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value"><?= count($logins) ?></div>
<div class="stat-label">Connexions totales</div>
</div>
<div class="stat-card">
<div class="stat-value"><?= count($logins_30d) ?></div>
<div class="stat-label">30 derniers jours</div>
</div>
<div class="stat-card">
<div class="stat-value"><?= count($ips) ?></div>
<div class="stat-label">IP distinctes</div>
</div>
<div class="stat-card <?= count($errors_30d) > 0 ? 'stat-warning' : '' ?>">
<div class="stat-value"><?= count($errors_30d) ?></div>
<div class="stat-label">Échecs (30j)</div>
</div>
<div class="stat-card">
<div class="stat-value"><?= count($logouts) ?></div>
<div class="stat-label">Déconnexions</div>
</div>
<div class="stat-card">
<div class="stat-value"><?= $last_login ? date('d/m/Y', (int)($last_login/1000)) : '—' ?></div>
<div class="stat-label">Dernière connexion</div>
</div>
</div>
<!-- Graphiques -->
<div class="charts-grid">
<div class="card chart-card">
<h2>Connexions par jour (30 derniers jours)</h2>
<canvas id="chartDay"></canvas>
</div>
<div class="card chart-card">
<h2>Par heure de la journée</h2>
<canvas id="chartHour"></canvas>
</div>
<div class="card chart-card">
<h2>Par jour de la semaine</h2>
<canvas id="chartDow"></canvas>
</div>
<div class="card chart-card">
<h2>IPs les plus fréquentes</h2>
<?php if ($ips): ?>
<div class="ip-bars">
<?php $max_ip = max(array_values($ips)); foreach (array_slice($ips, 0, 8, true) as $ip => $count): ?>
<div class="ip-bar-row">
<span class="ip-label"><?= htmlspecialchars($ip) ?></span>
<div class="ip-bar-wrap">
<div class="ip-bar" style="width:<?= round($count/$max_ip*100) ?>%"></div>
</div>
<span class="ip-count"><?= $count ?></span>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="text-muted">Aucune donnée.</p>
<?php endif; ?>
</div>
</div>
<!-- Historique récent -->
<div class="card" style="max-width:none;margin-top:1.5rem">
<h2>Dernières connexions</h2>
<?php $recent = array_slice($logins, 0, 30); ?>
<?php if ($recent): ?>
<table class="members-table">
<thead>
<tr>
<th>Date</th>
<th>Heure</th>
<th>Adresse IP</th>
</tr>
</thead>
<tbody>
<?php foreach ($recent as $e): $ts = (int)($e['time']/1000); ?>
<tr>
<td><?= date('d/m/Y', $ts) ?></td>
<td><?= date('H:i:s', $ts) ?></td>
<td><code><?= htmlspecialchars($e['ipAddress'] ?? '—') ?></code></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p class="text-muted">Aucune connexion enregistrée.</p>
<?php endif; ?>
</div>
<?php if ($errors): ?>
<div class="card" style="max-width:none;margin-top:1rem">
<h2 class="text-warning">Tentatives de connexion échouées</h2>
<table class="members-table">
<thead><tr><th>Date</th><th>Heure</th><th>IP</th></tr></thead>
<tbody>
<?php foreach (array_slice($errors, 0, 20) as $e): $ts=(int)($e['time']/1000); ?>
<tr>
<td><?= date('d/m/Y', $ts) ?></td>
<td><?= date('H:i:s', $ts) ?></td>
<td><code><?= htmlspecialchars($e['ipAddress'] ?? '—') ?></code></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script>
const primary = '#3b5bdb', light = '#dde4ff', muted = '#6b7289';
const opts = { responsive: true, plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } } };
new Chart(document.getElementById('chartDay'), { type: 'bar', options: opts, data: {
labels: <?= json_encode(array_keys($by_day)) ?>,
datasets: [{ data: <?= json_encode(array_values($by_day)) ?>, backgroundColor: primary }]
}});
new Chart(document.getElementById('chartHour'), { type: 'bar', options: opts, data: {
labels: <?= json_encode(array_map(fn($h) => str_pad($h,2,'0',STR_PAD_LEFT).'h', range(0,23))) ?>,
datasets: [{ data: <?= json_encode($by_hour) ?>, backgroundColor: primary }]
}});
new Chart(document.getElementById('chartDow'), { type: 'bar', options: opts, data: {
labels: <?= json_encode(array_values($days_fr)) ?>,
datasets: [{ data: <?= json_encode(array_values($by_dow)) ?>, backgroundColor: primary }]
}});
</script>
<?php require __DIR__ . '/views/layout_end.php'; ?>

1
web/inc/.htaccess Normal file
View file

@ -0,0 +1 @@
Require all denied

58
web/inc/auth.php Normal file
View file

@ -0,0 +1,58 @@
<?php
require_once __DIR__ . '/config.php';
function session_start_safe(): void {
if (session_status() === PHP_SESSION_NONE) {
session_name('portail_sess');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
}
function current_user(): ?array {
session_start_safe();
return $_SESSION['user'] ?? null;
}
function require_login(): void {
$user = current_user();
if (!$user) {
$_SESSION['next_url'] = (isset($_SERVER['HTTPS']) ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
header('Location: /auth/login.php');
exit;
}
}
function require_admin(): void {
require_login();
$user = current_user();
if (!($user['is_admin'] ?? false)) {
http_response_code(403);
require __DIR__ . '/../views/403.php';
exit;
}
}
function is_adherent(): bool {
$user = current_user();
return $user ? (bool)($user['is_adherent'] ?? false) : false;
}
function set_flash(string $type, string $message): void {
session_start_safe();
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
function get_flash(): ?array {
session_start_safe();
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return $flash;
}

54
web/inc/config.php Normal file
View file

@ -0,0 +1,54 @@
<?php
// Charge .env depuis private/ (dans open_basedir, hors web root public)
$_env_file = dirname(__DIR__) . '/../private/.env';
if (is_file($_env_file)) {
foreach (file($_env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if ($line[0] === '#' || !str_contains($line, '=')) continue;
[$k, $v] = explode('=', $line, 2);
$_ENV[trim($k)] = trim($v);
putenv(trim($k) . '=' . trim($v));
}
}
function env(string $key, string $default = ''): string {
return $_ENV[$key] ?? getenv($key) ?: $default;
}
// ── Secrets ───────────────────────────────────────────────────────────
define('SECRET_KEY', env('SECRET_KEY'));
define('ALPID_CLIENT_ID', env('ALPID_CLIENT_ID'));
define('ALPID_CLIENT_SECRET',env('ALPID_CLIENT_SECRET'));
// ── AlpID / Keycloak OIDC (realm alpinux) ─────────────────────────────
define('ALPID_BASE', rtrim(env('ALPID_BASE', 'https://alpid.alpinux.org'), '/'));
define('ALPID_REALM', env('ALPID_REALM', 'alpinux'));
define('ALPID_AUTH_URL', ALPID_BASE . '/realms/' . ALPID_REALM . '/protocol/openid-connect/auth');
define('ALPID_TOKEN_URL', ALPID_BASE . '/realms/' . ALPID_REALM . '/protocol/openid-connect/token');
define('ALPID_USERINFO_URL', ALPID_BASE . '/realms/' . ALPID_REALM . '/protocol/openid-connect/userinfo');
define('ALPID_LOGOUT_URL', ALPID_BASE . '/realms/' . ALPID_REALM . '/protocol/openid-connect/logout');
// ── Keycloak Admin REST API — compte de service (realm master) ────────
define('KC_SERVICE_CLIENT_ID', env('KC_SERVICE_CLIENT_ID', 'portail-service'));
define('KC_SERVICE_CLIENT_SECRET', env('KC_SERVICE_CLIENT_SECRET'));
define('KC_ADMIN_BASE', ALPID_BASE . '/admin/realms/' . ALPID_REALM);
// ── Dolibarr ──────────────────────────────────────────────────────────
define('DOLIBARR_URL', rtrim(env('DOLIBARR_URL', 'https://dolibarr.alpinux.org'), '/'));
define('DOLIBARR_API_KEY', env('DOLIBARR_API_KEY'));
// ── Application ───────────────────────────────────────────────────────
define('APP_URL', rtrim(env('APP_URL', 'https://portail.alpinux.org'), '/'));
define('CALLBACK_URL', APP_URL . '/auth/callback.php');
define('HELLOASSO_URL', env('HELLOASSO_URL',
'https://www.helloasso.com/associations/alpinux-le-lug-de-savoie/adhesions'));
define('ADMIN_GROUPS', array_filter(explode(',', env('ADMIN_GROUPS', 'admins'))));
define('ADHERENT_GROUP', env('ADHERENT_GROUP', 'adherents'));
define('SERVICES_FILE', env('SERVICES_FILE', dirname(__DIR__, 2) . '/services.json'));
// ── HelloAsso API ──────────────────────────────────────────────────────
define('HA_CLIENT_ID', env('HA_CLIENT_ID'));
define('HA_CLIENT_SECRET', env('HA_CLIENT_SECRET'));
define('HA_ORG_SLUG', env('HA_ORG_SLUG', 'alpinux-le-lug-de-savoie'));
define('HA_FORM_SLUG', env('HA_FORM_SLUG', 'adhesions-a-l-annee'));
define('HA_AMOUNT', (int)env('HA_AMOUNT', '1500'));
define('HA_ITEM_NAME', env('HA_ITEM_NAME', 'Adhésion Alpinux'));

94
web/inc/dolibarr.php Normal file
View file

@ -0,0 +1,94 @@
<?php
require_once __DIR__ . '/config.php';
function _doli_get(string $path, array $params = []): array {
$url = DOLIBARR_URL . '/api/index.php/' . ltrim($path, '/');
if ($params) $url .= '?' . http_build_query($params);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ['DOLAPIKEY: ' . DOLIBARR_API_KEY, 'Accept: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['status' => $status, 'data' => json_decode($body, true)];
}
function doli_update_member(string $member_id, array $fields): void {
$url = DOLIBARR_URL . '/api/index.php/members/' . $member_id;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => json_encode($fields),
CURLOPT_HTTPHEADER => ['DOLAPIKEY: ' . DOLIBARR_API_KEY, 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) throw new RuntimeException("Dolibarr update échoué ($status)");
}
function doli_add_subscription(string $member_id, int $date_fin_actuel, int $amount): void {
$now = time();
$start = ($date_fin_actuel > $now) ? $date_fin_actuel : $now;
$end = strtotime('+1 year', $start);
$resp = _doli_post("members/$member_id/subscriptions", [
'start_date' => $start,
'end_date' => $end,
'amount' => $amount,
'label' => 'Adhésion annuelle (HelloAsso)',
]);
if ($resp['status'] !== 200 && $resp['status'] !== 201) {
throw new RuntimeException("Dolibarr subscription échouée ({$resp['status']}): {$resp['body']}");
}
}
function _doli_post(string $path, array $data): array {
$url = DOLIBARR_URL . '/api/index.php/' . ltrim($path, '/');
$body = json_encode($data);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => ['DOLAPIKEY: ' . DOLIBARR_API_KEY, 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$resp = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['status' => $status, 'body' => $resp];
}
function doli_get_membership(string $email): ?array {
$resp = _doli_get('members', ['sqlfilters' => "(t.email:=:'$email')"]);
if ($resp['status'] !== 200 || empty($resp['data'])) return null;
$m = $resp['data'][0];
$date_fin = $m['datefin'] ?? null;
return [
'id' => $m['id'] ?? null,
'firstname' => $m['firstname'] ?? '',
'lastname' => $m['lastname'] ?? '',
'email' => $m['email'] ?? '',
'status' => (int)($m['status'] ?? 0),
'date_fin' => $date_fin ? date('d/m/Y', (int)$date_fin) : null,
'date_fin_ts' => $date_fin ? (int)$date_fin : 0,
'type_label' => $m['type'] ?? '',
'address' => $m['address'] ?? null,
'zip' => $m['zip'] ?? null,
'town' => $m['town'] ?? null,
'country_code' => $m['country_code'] ?? 'FR',
];
}

95
web/inc/helloasso.php Normal file
View file

@ -0,0 +1,95 @@
<?php
require_once __DIR__ . '/config.php';
function _ha_token(): string {
static $cache = null;
if ($cache && $cache['expires'] > time()) return $cache['token'];
$ch = curl_init('https://api.helloasso.com/oauth2/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'client_credentials',
'client_id' => HA_CLIENT_ID,
'client_secret' => HA_CLIENT_SECRET,
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("HelloAsso auth échouée ($status): $body");
}
$data = json_decode($body, true);
$cache = ['token' => $data['access_token'], 'expires' => time() + $data['expires_in'] - 30];
return $cache['token'];
}
// ISO 3166-1 alpha-2 → alpha-3 (HelloAsso exige alpha-3)
function _ha_country(string $alpha2): string {
$map = ['FR'=>'FRA','BE'=>'BEL','CH'=>'CHE','LU'=>'LUX','DE'=>'DEU',
'ES'=>'ESP','IT'=>'ITA','GB'=>'GBR','NL'=>'NLD','PT'=>'PRT'];
return $map[strtoupper($alpha2)] ?? 'FRA';
}
function ha_create_checkout(string $first_name, string $last_name, string $email, string $kc_uid,
string $address = '', string $zip = '', string $town = '', string $country = 'FR'): array {
$token = _ha_token();
$return_url = APP_URL . '/helloasso/callback.php?uid=' . urlencode($kc_uid);
$back_url = APP_URL . '/helloasso/renew.php';
$payer = ['firstName' => $first_name, 'lastName' => $last_name, 'email' => $email];
if ($address) $payer['address'] = $address;
if ($zip) $payer['zipCode'] = $zip;
if ($town) $payer['city'] = $town;
if ($country) $payer['country'] = _ha_country($country);
$ch = curl_init('https://api.helloasso.com/v5/organizations/' . HA_ORG_SLUG . '/checkout-intents');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token", 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'totalAmount' => HA_AMOUNT,
'initialAmount' => HA_AMOUNT,
'itemName' => HA_ITEM_NAME,
'backUrl' => $back_url,
'errorUrl' => $back_url,
'returnUrl' => $return_url,
'containsDonation' => false,
'payer' => $payer,
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200 && $status !== 201) {
throw new RuntimeException("HelloAsso checkout échoué ($status): $body");
}
return json_decode($body, true);
}
function ha_get_checkout(string $checkout_intent_id): array {
$token = _ha_token();
$ch = curl_init("https://api.helloasso.com/v5/checkout-intents/$checkout_intent_id");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("HelloAsso get checkout échoué ($status): $body");
}
return json_decode($body, true);
}

200
web/inc/keycloak.php Normal file
View file

@ -0,0 +1,200 @@
<?php
require_once __DIR__ . '/config.php';
class KcUserExistsException extends RuntimeException {}
function _kc_admin_token(): string {
static $cache = null;
if ($cache && $cache['expires'] > time()) return $cache['token'];
$ch = curl_init(ALPID_BASE . '/realms/master/protocol/openid-connect/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'client_credentials',
'client_id' => KC_SERVICE_CLIENT_ID,
'client_secret' => KC_SERVICE_CLIENT_SECRET,
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("Authentification Keycloak Admin échouée ($status)");
}
$data = json_decode($body, true);
$cache = ['token' => $data['access_token'], 'expires' => time() + $data['expires_in'] - 10];
return $cache['token'];
}
function _kc_request(string $method, string $path, mixed $body = null): array {
$url = KC_ADMIN_BASE . $path;
$token = _kc_admin_token();
$headers = ["Authorization: Bearer $token", "Content-Type: application/json"];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HEADER => true,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
$resp_headers = substr($response, 0, $header_size);
$resp_body = substr($response, $header_size);
return ['status' => $status, 'headers' => $resp_headers, 'body' => $resp_body];
}
function _kc_encode_username(string $lastname): string {
$name = strtolower(iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $lastname));
$name = preg_replace('/[^a-z]/', '', $name);
$len = strlen($name);
if ($len <= 2) return $name;
return $name[0] . ($len - 2) . $name[$len - 1];
}
function kc_username_exists(string $username): bool {
$resp = _kc_request('GET', '/users?username=' . urlencode($username) . '&exact=true');
if ($resp['status'] !== 200) return false;
return count(json_decode($resp['body'], true) ?? []) > 0;
}
function kc_generate_username(string $lastname, string $firstname): string {
$fn = strtolower(preg_replace('/[^a-z]/i', '', iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $firstname)));
$code = _kc_encode_username($lastname);
$base = $fn . '.' . $code;
if (!kc_username_exists($base)) return $base;
for ($i = 2; $i <= 99; $i++) {
$try = $base . $i;
if (!kc_username_exists($try)) return $try;
}
return $base . uniqid('', false);
}
function kc_create_user(string $username, string $email, string $first_name, string $last_name, string $password): string {
$resp = _kc_request('POST', '/users', [
'username' => $username,
'email' => $email,
'firstName' => $first_name,
'lastName' => $last_name,
'enabled' => true,
'emailVerified' => false,
'credentials' => [['type' => 'password', 'value' => $password, 'temporary' => false]],
]);
if ($resp['status'] === 409) {
$detail = json_decode($resp['body'], true)['errorMessage'] ?? '';
$msg = str_contains(strtolower($detail), 'email')
? 'Cet email est déjà associé à un compte existant.'
: 'Ce nom d\'utilisateur est déjà utilisé.';
throw new KcUserExistsException($msg);
}
if ($resp['status'] !== 201) {
throw new RuntimeException("Erreur création compte ({$resp['status']}) : {$resp['body']}");
}
// L'ID est dans le header Location
preg_match('#/users/([a-f0-9-]+)#', $resp['headers'], $m);
return $m[1] ?? '';
}
function kc_get_otp_credential(string $user_id): ?array {
$resp = _kc_request('GET', "/users/$user_id/credentials");
if ($resp['status'] !== 200) return null;
foreach (json_decode($resp['body'], true) ?? [] as $c) {
if ($c['type'] === 'otp') return $c;
}
return null;
}
function kc_delete_credential(string $user_id, string $credential_id): void {
$resp = _kc_request('DELETE', "/users/$user_id/credentials/$credential_id");
if ($resp['status'] >= 300) {
throw new RuntimeException("Erreur suppression credential ({$resp['status']})");
}
}
function kc_update_name(string $user_id, string $first_name, string $last_name): void {
$resp = _kc_request('PUT', "/users/$user_id", [
'firstName' => $first_name,
'lastName' => $last_name,
]);
if ($resp['status'] >= 300) {
throw new RuntimeException("Erreur mise à jour nom ({$resp['status']})");
}
}
function kc_update_email(string $user_id, string $email): void {
$resp = _kc_request('PUT', "/users/$user_id", [
'email' => $email,
'emailVerified' => false,
]);
if ($resp['status'] >= 300) {
throw new RuntimeException("Erreur mise à jour email ({$resp['status']})");
}
}
function kc_set_password(string $user_id, string $password): void {
$resp = _kc_request('PUT', "/users/$user_id/reset-password", [
'type' => 'password',
'value' => $password,
'temporary' => false,
]);
if ($resp['status'] >= 300) {
throw new RuntimeException("Erreur changement mot de passe ({$resp['status']})");
}
}
function kc_list_users(int $max = 200): array {
$resp = _kc_request('GET', '/users?max=' . $max);
if ($resp['status'] !== 200) return [];
$users = json_decode($resp['body'], true) ?? [];
foreach ($users as &$user) {
$gr = _kc_request('GET', "/users/{$user['id']}/groups");
$user['groupNames'] = $gr['status'] === 200
? array_column(json_decode($gr['body'], true) ?? [], 'name')
: [];
}
return $users;
}
function _kc_group_id(string $group_name): string {
$resp = _kc_request('GET', '/groups?search=' . urlencode($group_name));
$groups = json_decode($resp['body'], true) ?? [];
foreach ($groups as $g) {
if ($g['name'] === $group_name) return $g['id'];
}
throw new RuntimeException("Groupe « $group_name » introuvable dans Keycloak.");
}
function kc_add_to_group(string $user_id, string $group_name): void {
$group_id = _kc_group_id($group_name);
$resp = _kc_request('PUT', "/users/$user_id/groups/$group_id");
if ($resp['status'] >= 300) {
throw new RuntimeException("Erreur ajout groupe ({$resp['status']})");
}
}
function kc_remove_from_group(string $user_id, string $group_name): void {
$group_id = _kc_group_id($group_name);
$resp = _kc_request('DELETE', "/users/$user_id/groups/$group_id");
if ($resp['status'] >= 300) {
throw new RuntimeException("Erreur suppression groupe ({$resp['status']})");
}
}

62
web/inc/oidc.php Normal file
View file

@ -0,0 +1,62 @@
<?php
require_once __DIR__ . '/config.php';
function oidc_authorization_url(): string {
$state = bin2hex(random_bytes(16));
$nonce = bin2hex(random_bytes(16));
$_SESSION['oidc_state'] = $state;
$_SESSION['oidc_nonce'] = $nonce;
return ALPID_AUTH_URL . '?' . http_build_query([
'client_id' => ALPID_CLIENT_ID,
'response_type' => 'code',
'scope' => 'openid profile email',
'redirect_uri' => CALLBACK_URL,
'state' => $state,
'nonce' => $nonce,
]);
}
function oidc_exchange_code(string $code): array {
$ch = curl_init(ALPID_TOKEN_URL);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => CALLBACK_URL,
'client_id' => ALPID_CLIENT_ID,
'client_secret' => ALPID_CLIENT_SECRET,
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("Erreur token OIDC ($status) : $body");
}
return json_decode($body, true);
}
function oidc_userinfo(string $access_token): array {
$ch = curl_init(ALPID_USERINFO_URL);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer $access_token"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
curl_close($ch);
return json_decode($body, true) ?? [];
}
function oidc_logout_url(string $id_token = ''): string {
$params = ['post_logout_redirect_uri' => APP_URL . '/'];
if ($id_token) {
$params['id_token_hint'] = $id_token;
}
return ALPID_LOGOUT_URL . '?' . http_build_query($params);
}

26
web/inc/services.php Normal file
View file

@ -0,0 +1,26 @@
<?php
require_once __DIR__ . '/config.php';
const DEFAULT_SERVICES = [
['name' => 'Wiki', 'url' => 'https://wiki.alpinux.org', 'description' => 'Documentation et guides', 'requires_adherent' => false, 'visible' => true],
['name' => 'Gitea', 'url' => 'https://gitea.alpinux.org', 'description' => 'Forge de code', 'requires_adherent' => false, 'visible' => true],
['name' => 'Quiz interactifs','url' => 'https://dynamic.alpinux.org', 'description' => 'Jeux et quiz', 'requires_adherent' => false, 'visible' => true],
['name' => 'Install Party', 'url' => 'https://installparty.alpinux.org', 'description' => 'Événements et ateliers', 'requires_adherent' => false, 'visible' => true],
['name' => 'Nextcloud', 'url' => 'https://cloud.alpinux.org', 'description' => 'Stockage et collaboration', 'requires_adherent' => true, 'visible' => true],
['name' => 'Dolibarr', 'url' => 'https://dolibarr.alpinux.org', 'description' => 'Gestion de l\'association', 'requires_adherent' => true, 'visible' => true],
];
function services_list(): array {
$file = SERVICES_FILE;
if (is_file($file)) {
$data = json_decode(file_get_contents($file), true);
if (is_array($data)) return $data;
}
return DEFAULT_SERVICES;
}
function services_save(array $services): void {
$dir = dirname(SERVICES_FILE);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents(SERVICES_FILE, json_encode($services, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}

98
web/index.php Normal file
View file

@ -0,0 +1,98 @@
<?php
require_once __DIR__ . '/inc/config.php';
require_once __DIR__ . '/inc/auth.php';
require_once __DIR__ . '/inc/services.php';
session_start_safe();
$user = current_user();
$title = 'Portail Alpinux';
$services = services_list();
$is_adherent = $user && (
$user['is_adherent'] ||
($user['is_admin'] ?? false)
);
require __DIR__ . '/views/layout.php';
?>
<?php if ($user): ?>
<div class="hero">
<h1>Bienvenue, <?= htmlspecialchars($user['name'] ?: $user['username']) ?></h1>
<?php if (!$is_adherent): ?>
<p>Vous n'avez pas encore adhéré à Alpinux. Adhérer, c'est soutenir nos actions pour le logiciel libre en Savoie.</p>
<div class="hero-actions">
<a href="/helloasso/renew.php" class="btn-primary">Adhérer maintenant</a>
<a href="/profile.php" class="btn-outline">Mon profil</a>
</div>
<?php else: ?>
<p>Merci pour votre soutien. Retrouvez vos services et votre profil ci-dessous.</p>
<div class="hero-actions">
<a href="/profile.php" class="btn-outline">Mon profil</a>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<div class="hero">
<h1>Portail membres Alpinux</h1>
<p>Accédez à vos services, gérez votre adhésion et votre compte AlpID.</p>
<div class="hero-actions">
<a href="/register.php" class="btn-primary">Créer mon compte</a>
<a href="/auth/login.php" class="btn-outline">Se connecter</a>
</div>
</div>
<section class="home-about">
<div class="about-grid">
<div class="about-card">
<div class="about-title">Qui sommes-nous ?</div>
<p>Alpinux est le LUG (Linux User Group) de Savoie. Nous promouvons le logiciel libre, organisons des ateliers et des événements autour de Linux et des technologies ouvertes.</p>
</div>
<div class="about-card">
<div class="about-title">Pourquoi adhérer ?</div>
<p>Adhérer, c'est soutenir nos actions pour le logiciel libre en Savoie. C'est aussi accéder à l'ensemble des services membres et participer aux décisions lors de l'assemblée générale.</p>
</div>
<div class="about-card">
<div class="about-title">AlpID</div>
<p>Un seul compte pour tous les services Alpinux. Gérez votre profil, votre adresse et votre adhésion depuis ce portail.</p>
</div>
</div>
</section>
<?php endif; ?>
<section class="services-section">
<h2>Nos services</h2>
<div class="grid">
<?php foreach ($services as $s): if (!$s['visible']) continue;
$can_access = !$s['requires_adherent'] || $is_adherent; ?>
<div class="service-card <?= $s['requires_adherent'] ? 'adherent-only' : '' ?> <?= (!$can_access) ? 'locked' : '' ?>">
<div class="service-header">
<strong><?= htmlspecialchars($s['name']) ?></strong>
<?php if ($s['requires_adherent']): ?>
<span class="badge">Adhérent</span>
<?php endif; ?>
</div>
<?php if ($s['description']): ?>
<p><?= htmlspecialchars($s['description']) ?></p>
<?php endif; ?>
<div style="margin-top:.8rem">
<?php if ($can_access): ?>
<a href="<?= htmlspecialchars($s['url']) ?>" target="_blank" rel="noopener" style="font-size:.85rem">Accéder </a>
<?php elseif ($user): ?>
<span class="text-muted small">Adhésion requise</span>
<?php else: ?>
<span class="text-muted small">Réservé aux adhérents </span>
<a href="/register.php" style="font-size:.85rem">Adhérer</a>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</section>
<?php require __DIR__ . '/views/layout_end.php'; ?>

11
web/logout.php Normal file
View file

@ -0,0 +1,11 @@
<?php
require_once __DIR__ . '/inc/config.php';
require_once __DIR__ . '/inc/auth.php';
require_once __DIR__ . '/inc/oidc.php';
session_start_safe();
$id_token = $_SESSION['id_token'] ?? '';
session_destroy();
header('Location: ' . oidc_logout_url($id_token));
exit;

66
web/password.php Normal file
View file

@ -0,0 +1,66 @@
<?php
require_once __DIR__ . '/inc/config.php';
require_once __DIR__ . '/inc/auth.php';
require_once __DIR__ . '/inc/keycloak.php';
session_start_safe();
require_login();
$user = current_user();
$errors = [];
$success = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$pw = $_POST['password'] ?? '';
$pw2 = $_POST['password2'] ?? '';
if (!$pw) {
$errors[] = 'Le mot de passe ne peut pas être vide.';
} elseif ($pw !== $pw2) {
$errors[] = 'Les mots de passe ne correspondent pas.';
} elseif (strlen($pw) < 8) {
$errors[] = 'Le mot de passe doit contenir au moins 8 caractères.';
}
if (!$errors) {
try {
kc_set_password($user['sub'], $pw);
$success = true;
} catch (Exception $e) {
$errors[] = 'Erreur : ' . $e->getMessage();
}
}
}
$title = 'Changer mon mot de passe';
require __DIR__ . '/views/layout.php';
?>
<div class="card">
<h1>Changer mon mot de passe</h1>
<?php if ($success): ?>
<div class="alert alert-success">Mot de passe modifié avec succès.</div>
<?php endif; ?>
<?php if ($errors): ?>
<div class="alert alert-error">
<ul><?php foreach ($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?></ul>
</div>
<?php endif; ?>
<form method="post" novalidate>
<div class="form-group">
<label for="password">Nouveau mot de passe</label>
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password">
</div>
<div class="form-group">
<label for="password2">Confirmer le nouveau mot de passe</label>
<input type="password" id="password2" name="password2" required autocomplete="new-password">
</div>
<button type="submit" class="btn-primary">Enregistrer</button>
<a href="/dashboard.php" class="btn-outline">Annuler</a>
</form>
</div>
<?php require __DIR__ . '/views/layout_end.php'; ?>

385
web/profile.php Normal file
View file

@ -0,0 +1,385 @@
<?php
require_once __DIR__ . '/inc/config.php';
require_once __DIR__ . '/inc/auth.php';
require_once __DIR__ . '/inc/dolibarr.php';
require_once __DIR__ . '/inc/keycloak.php';
session_start_safe();
require_login();
$user = current_user();
$membership = doli_get_membership($user['email']);
$errors = [];
$edit = $_GET['edit'] ?? '';
$section = $_POST['section'] ?? '';
function geocode(string $address, string $zip, string $town, string $country = 'FR'): ?array {
$q = urlencode("$address, $zip $town, $country");
$ch = curl_init("https://nominatim.openstreetmap.org/search?q=$q&format=json&limit=1");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5,
CURLOPT_HTTPHEADER=>['User-Agent: portail.alpinux.org']]);
$r = json_decode(curl_exec($ch), true) ?? [];
curl_close($ch);
return $r ? ['lat' => (float)$r[0]['lat'], 'lon' => (float)$r[0]['lon']] : null;
}
function save_gps(string $member_id, ?array $gps): void {
$file = preg_replace('/[^\/]+$/', 'member_gps.json', SERVICES_FILE);
$data = is_file($file) ? (json_decode(file_get_contents($file), true) ?? []) : [];
if ($gps) $data[$member_id] = $gps + ['updated' => date('Y-m-d')];
else unset($data[$member_id]);
file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT));
}
// ── Traitement adresse ────────────────────────────────────────────────
if ($section === 'address' && $membership) {
$address = trim($_POST['address'] ?? '');
$zip = trim($_POST['zip'] ?? '');
$town = trim($_POST['town'] ?? '');
if (!$address) $errors[] = 'L\'adresse est obligatoire.';
if (!$zip) $errors[] = 'Le code postal est obligatoire.';
if (!$town) $errors[] = 'La ville est obligatoire.';
if (!$errors) {
try {
doli_update_member($membership['id'], ['address'=>$address,'zip'=>$zip,'town'=>$town]);
$gps = geocode($address, $zip, $town, $membership['country_code'] ?? 'FR');
save_gps($membership['id'], $gps);
$membership['address'] = $address;
$membership['zip'] = $zip;
$membership['town'] = $town;
set_flash('success', 'Adresse mise à jour' . ($gps ? ' (GPS enregistré).' : '.'));
header('Location: /profile.php'); exit;
} catch (Exception $e) { $errors[] = 'Erreur : ' . $e->getMessage(); $edit = 'address'; }
} else { $edit = 'address'; }
}
// ── Traitement identité ───────────────────────────────────────────────
if ($section === 'identity') {
$new_first = trim($_POST['first_name'] ?? '');
$new_last = trim($_POST['last_name'] ?? '');
if (!$new_first) $errors[] = 'Le prénom est obligatoire.';
if (!$new_last) $errors[] = 'Le nom est obligatoire.';
if (!$errors) {
try {
kc_update_name($user['sub'], $new_first, $new_last);
if ($membership) doli_update_member($membership['id'], ['firstname' => $new_first, 'lastname' => $new_last]);
$_SESSION['user']['first_name'] = $new_first;
$_SESSION['user']['last_name'] = $new_last;
$_SESSION['user']['name'] = $new_first . ' ' . $new_last;
$user['first_name'] = $new_first;
$user['last_name'] = $new_last;
$user['name'] = $new_first . ' ' . $new_last;
set_flash('success', 'Identité mise à jour.');
header('Location: /profile.php'); exit;
} catch (Exception $e) { $errors[] = 'Erreur : ' . $e->getMessage(); $edit = 'identity'; }
} else { $edit = 'identity'; }
}
// ── Traitement email ──────────────────────────────────────────────────
if ($section === 'email') {
$new_email = trim($_POST['email'] ?? '');
$new_email2 = trim($_POST['email_confirm'] ?? '');
if (!filter_var($new_email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Email invalide.';
elseif ($new_email !== $new_email2) $errors[] = 'Les deux emails ne correspondent pas.';
elseif ($new_email === $user['email']) $errors[] = 'C\'est déjà votre email actuel.';
if (!$errors) {
try {
kc_update_email($user['sub'], $new_email);
if ($membership) doli_update_member($membership['id'], ['email' => $new_email]);
$_SESSION['user']['email'] = $new_email;
$user['email'] = $new_email;
set_flash('success', 'Email mis à jour.');
header('Location: /profile.php'); exit;
} catch (Exception $e) { $errors[] = 'Erreur : ' . $e->getMessage(); $edit = 'email'; }
} else { $edit = 'email'; }
}
// ── Traitement mot de passe ───────────────────────────────────────────
if ($section === 'password') {
$pw = $_POST['password'] ?? '';
$pw2 = $_POST['password2'] ?? '';
if (!$pw) $errors[] = 'Le mot de passe ne peut pas être vide.';
elseif ($pw !== $pw2) $errors[] = 'Les mots de passe ne correspondent pas.';
elseif (strlen($pw) < 8) $errors[] = 'Le mot de passe doit contenir au moins 8 caractères.';
if (!$errors) {
try {
kc_set_password($user['sub'], $pw);
set_flash('success', 'Mot de passe modifié.');
header('Location: /profile.php'); exit;
} catch (Exception $e) { $errors[] = 'Erreur : ' . $e->getMessage(); $edit = 'password'; }
} else { $edit = 'password'; }
}
// ── Traitement OTP ────────────────────────────────────────────────────
if ($section === 'otp_delete') {
$cred_id = $_POST['credential_id'] ?? '';
if ($cred_id) {
try {
kc_delete_credential($user['sub'], $cred_id);
set_flash('success', 'OTP supprimé.');
header('Location: /profile.php'); exit;
} catch (Exception $e) { $errors[] = 'Erreur : ' . $e->getMessage(); }
}
}
// ── Données annexes ───────────────────────────────────────────────────
$gps_file = preg_replace('/[^\/]+$/', 'member_gps.json', SERVICES_FILE);
$gps = ($membership && is_file($gps_file))
? (json_decode(file_get_contents($gps_file), true)[$membership['id']] ?? null)
: null;
$otp = kc_get_otp_credential($user['sub']);
$otp_setup_url = ALPID_BASE . '/realms/' . ALPID_REALM . '/account/#/security/signingin';
$is_adherent = $user['is_adherent']
|| ($membership && $membership['status']===1 && $membership['date_fin_ts']>time());
$title = 'Mon profil';
require __DIR__ . '/views/layout.php';
?>
<div class="profile-overview">
<!-- Identité -->
<div class="tile <?= $edit==='identity' ? 'tile-open' : '' ?>">
<div class="tile-header">
<div class="tile-label">
<div class="tile-title">Identité</div>
<div class="tile-sub"><?= htmlspecialchars(trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''))) ?: 'Non renseignée' ?></div>
</div>
<?php if ($edit !== 'identity'): ?>
<a href="?edit=identity" class="btn-outline btn-sm tile-action">Modifier</a>
<?php endif; ?>
</div>
<div class="tile-body">
<?php if ($edit === 'identity'): ?>
<?php if ($errors): ?><div class="alert alert-error"><ul><?php foreach($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?></ul></div><?php endif; ?>
<form method="post" novalidate>
<input type="hidden" name="section" value="identity">
<div class="form-row">
<div class="form-group">
<label>Prénom</label>
<input type="text" name="first_name" required autofocus
value="<?= htmlspecialchars($_POST['first_name'] ?? $user['first_name'] ?? '') ?>">
</div>
<div class="form-group">
<label>Nom</label>
<input type="text" name="last_name" required
value="<?= htmlspecialchars($_POST['last_name'] ?? $user['last_name'] ?? '') ?>">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Enregistrer</button>
<a href="/profile.php" class="btn-outline">Annuler</a>
</div>
</form>
<?php else: ?>
<dl>
<dt>Prénom</dt><dd><?= htmlspecialchars($user['first_name'] ?? '—') ?></dd>
<dt>Nom</dt><dd><?= htmlspecialchars($user['last_name'] ?? '—') ?></dd>
</dl>
<?php endif; ?>
</div>
</div>
<!-- Compte -->
<div class="tile <?= $edit==='email' ? 'tile-open' : '' ?>">
<div class="tile-header">
<div class="tile-label">
<div class="tile-title">Mon compte</div>
<div class="tile-sub"><?= htmlspecialchars($user['username']) ?></div>
</div>
<?php if ($edit !== 'email'): ?>
<a href="?edit=email" class="btn-outline btn-sm tile-action">Modifier</a>
<?php endif; ?>
</div>
<div class="tile-body">
<?php if ($edit === 'email'): ?>
<?php if ($errors): ?><div class="alert alert-error"><ul><?php foreach($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?></ul></div><?php endif; ?>
<form method="post" novalidate>
<input type="hidden" name="section" value="email">
<div class="form-group">
<label>Email actuel</label>
<input type="email" disabled value="<?= htmlspecialchars($user['email']) ?>">
</div>
<div class="form-group">
<label>Nouvel email</label>
<input type="email" name="email" required autofocus value="<?= htmlspecialchars($_POST['email'] ?? '') ?>">
</div>
<div class="form-group">
<label>Confirmer</label>
<input type="email" name="email_confirm" required value="<?= htmlspecialchars($_POST['email_confirm'] ?? '') ?>">
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Changer l'email</button>
<a href="/profile.php" class="btn-outline">Annuler</a>
</div>
</form>
<?php else: ?>
<dl>
<dt>Email</dt><dd><?= htmlspecialchars($user['email']) ?></dd>
</dl>
<?php endif; ?>
</div>
</div>
<!-- Adhésion -->
<div class="tile">
<div class="tile-header">
<div class="tile-label">
<div class="tile-title">Adhésion</div>
<div class="tile-sub <?= $is_adherent ? 'text-success' : 'text-warning' ?>">
<?= $is_adherent ? 'Active' : 'Inactive' ?>
</div>
</div>
<a href="/helloasso/renew.php" class="btn-primary btn-sm tile-action">Renouveler</a>
</div>
<div class="tile-body">
<?php if ($membership): ?>
<dl>
<?php if ($membership['date_fin']): ?>
<dt>Valide jusqu'au</dt><dd><?= htmlspecialchars($membership['date_fin']) ?></dd>
<?php endif; ?>
<?php if ($membership['type_label']): ?>
<dt>Type</dt><dd><?= htmlspecialchars($membership['type_label']) ?></dd>
<?php endif; ?>
</dl>
<?php else: ?>
<p class="text-muted small">Aucune fiche Dolibarr trouvée.</p>
<?php endif; ?>
</div>
</div>
<!-- Adresse -->
<div class="tile <?= $edit==='address' ? 'tile-open' : '' ?>">
<div class="tile-header">
<div class="tile-label">
<div class="tile-title">Adresse postale</div>
<div class="tile-sub">
<?php if ($membership && $membership['address']): ?>
<?= htmlspecialchars($membership['zip'].' '.$membership['town']) ?>
<?php else: ?>
<span class="text-warning">Non renseignée</span>
<?php endif; ?>
</div>
</div>
<?php if ($membership && $edit !== 'address'): ?>
<a href="?edit=address" class="btn-outline btn-sm tile-action">Modifier</a>
<?php endif; ?>
</div>
<div class="tile-body">
<?php if ($edit === 'address' && $membership): ?>
<?php if ($errors): ?><div class="alert alert-error"><ul><?php foreach($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?></ul></div><?php endif; ?>
<form method="post" novalidate>
<input type="hidden" name="section" value="address">
<div class="form-group">
<label>Rue</label>
<input type="text" name="address" required autofocus placeholder="1 rue de la Paix"
value="<?= htmlspecialchars($_POST['address'] ?? $membership['address'] ?? '') ?>">
</div>
<div class="form-row">
<div class="form-group">
<label>Code postal</label>
<input type="text" name="zip" required maxlength="10"
value="<?= htmlspecialchars($_POST['zip'] ?? $membership['zip'] ?? '') ?>">
</div>
<div class="form-group">
<label>Ville</label>
<input type="text" name="town" required
value="<?= htmlspecialchars($_POST['town'] ?? $membership['town'] ?? '') ?>">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Enregistrer</button>
<a href="/profile.php" class="btn-outline">Annuler</a>
</div>
</form>
<?php elseif ($membership && $membership['address']): ?>
<p><?= htmlspecialchars($membership['address']) ?><br>
<?= htmlspecialchars($membership['zip'].' '.$membership['town']) ?></p>
<?php if ($gps): ?>
<p style="margin-top:.5rem">
<a href="https://www.openstreetmap.org/?mlat=<?= $gps['lat'] ?>&amp;mlon=<?= $gps['lon'] ?>#map=14/<?= $gps['lat'] ?>/<?= $gps['lon'] ?>" target="_blank" rel="noopener" class="small">Voir sur OpenStreetMap</a>
</p>
<?php endif; ?>
<?php elseif ($membership): ?>
<p class="text-muted small">Cliquez sur Modifier pour renseigner votre adresse.</p>
<?php endif; ?>
</div>
</div>
<!-- Mot de passe -->
<div class="tile <?= $edit==='password' ? 'tile-open' : '' ?>">
<div class="tile-header">
<div class="tile-label">
<div class="tile-title">Mot de passe</div>
<div class="tile-sub">AlpID</div>
</div>
<?php if ($edit !== 'password'): ?>
<a href="?edit=password" class="btn-outline btn-sm tile-action">Modifier</a>
<?php endif; ?>
</div>
<?php if ($edit === 'password'): ?>
<div class="tile-body">
<?php if ($errors): ?><div class="alert alert-error"><ul><?php foreach($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?></ul></div><?php endif; ?>
<form method="post" novalidate>
<input type="hidden" name="section" value="password">
<div class="form-group">
<label>Nouveau mot de passe</label>
<input type="password" name="password" required autofocus minlength="8" autocomplete="new-password">
</div>
<div class="form-group">
<label>Confirmer</label>
<input type="password" name="password2" required autocomplete="new-password">
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Enregistrer</button>
<a href="/profile.php" class="btn-outline">Annuler</a>
</div>
</form>
</div>
<?php endif; ?>
</div>
<!-- OTP -->
<div class="tile">
<div class="tile-header">
<div class="tile-label">
<div class="tile-title">Double authentification (OTP)</div>
<div class="tile-sub <?= $otp ? 'text-success' : 'text-warning' ?>">
<?= $otp ? 'Activée' : 'Désactivée' ?>
</div>
</div>
<a href="<?= htmlspecialchars($otp_setup_url) ?>" target="_blank" rel="noopener"
class="btn-outline btn-sm tile-action">
<?= $otp ? 'Reconfigurer' : 'Activer' ?>
</a>
</div>
<?php if ($otp): ?>
<div class="tile-body">
<p class="small text-muted" style="margin-bottom:.8rem">
Configurée le <?= date('d/m/Y', (int)($otp['createdDate'] / 1000)) ?>
</p>
<form method="post" onsubmit="return confirm('Supprimer l\'OTP ? Votre compte sera moins sécurisé.')">
<input type="hidden" name="section" value="otp_delete">
<input type="hidden" name="credential_id" value="<?= htmlspecialchars($otp['id']) ?>">
<button type="submit" class="btn-danger btn-sm">Supprimer l'OTP</button>
</form>
</div>
<?php endif; ?>
</div>
<!-- Connexions -->
<div class="tile">
<div class="tile-header">
<div class="tile-label">
<div class="tile-title">Connexions</div>
<div class="tile-sub">Historique et statistiques</div>
</div>
<a href="/history.php" class="btn-outline btn-sm tile-action">Voir</a>
</div>
</div>
</div>
<?php require __DIR__ . '/views/layout_end.php'; ?>

45
web/register-success.php Normal file
View file

@ -0,0 +1,45 @@
<?php
require_once __DIR__ . '/inc/config.php';
require_once __DIR__ . '/inc/auth.php';
session_start_safe();
$title = 'Inscription réussie';
require __DIR__ . '/views/layout.php';
?>
<div class="card center">
<div class="success-icon"></div>
<h1>Compte créé avec succès !</h1>
<p>Votre compte AlpID est prêt. Vous pouvez maintenant vous connecter.</p>
<div class="steps">
<div class="step done">
<span class="step-num">1</span>
<div>
<strong>Compte AlpID créé</strong>
<p>Votre identifiant pour tous les services Alpinux.</p>
</div>
</div>
<div class="step">
<span class="step-num">2</span>
<div>
<strong>Adhérer à l'association</strong>
<p>Payez votre cotisation sur HelloAsso pour accéder à tous les services.</p>
<a href="<?= htmlspecialchars(HELLOASSO_URL) ?>" target="_blank" rel="noopener" class="btn-primary">
Adhérer sur HelloAsso
</a>
</div>
</div>
<div class="step">
<span class="step-num">3</span>
<div>
<strong>Validation par l'équipe</strong>
<p>Un administrateur validera votre adhésion sous 48h.</p>
</div>
</div>
</div>
<a href="/auth/login.php" class="btn-outline">Se connecter maintenant</a>
</div>
<?php require __DIR__ . '/views/layout_end.php'; ?>

135
web/register.php Normal file
View file

@ -0,0 +1,135 @@
<?php
require_once __DIR__ . '/inc/config.php';
require_once __DIR__ . '/inc/auth.php';
require_once __DIR__ . '/inc/keycloak.php';
session_start_safe();
if (current_user()) {
header('Location: /profile.php');
exit;
}
$errors = [];
$form = ['email' => '', 'first_name' => '', 'last_name' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$form = [
'email' => trim($_POST['email'] ?? ''),
'first_name' => trim($_POST['first_name'] ?? ''),
'last_name' => trim($_POST['last_name'] ?? ''),
];
$password = $_POST['password'] ?? '';
$password2 = $_POST['password2'] ?? '';
if (!$form['email'] || !$form['first_name'] || !$form['last_name'] || !$password) {
$errors[] = 'Tous les champs sont obligatoires.';
}
if ($password !== $password2) {
$errors[] = 'Les mots de passe ne correspondent pas.';
}
if (strlen($password) < 8) {
$errors[] = 'Le mot de passe doit contenir au moins 8 caractères.';
}
if (!filter_var($form['email'], FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Adresse email invalide.';
}
if (!$errors) {
try {
$username = kc_generate_username($form['last_name'], $form['first_name']);
kc_create_user($username, $form['email'], $form['first_name'], $form['last_name'], $password);
set_flash('success', "Compte créé. Votre identifiant AlpID est : $username");
header('Location: /auth/login.php');
exit;
} catch (KcUserExistsException $e) {
$errors[] = $e->getMessage();
} catch (Exception $e) {
$errors[] = 'Erreur : ' . $e->getMessage();
}
}
}
$title = 'Inscription';
require __DIR__ . '/views/layout.php';
?>
<div class="card">
<h1>Créer mon compte AlpID</h1>
<p class="subtitle">Créez votre compte pour accéder aux services Alpinux. L'adhésion se fait ensuite volontairement depuis votre profil.</p>
<?php if ($errors): ?>
<div class="alert alert-error">
<ul><?php foreach ($errors as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?></ul>
</div>
<?php endif; ?>
<form method="post" novalidate>
<div class="form-row">
<div class="form-group">
<label for="first_name">Prénom</label>
<input type="text" id="first_name" name="first_name" required autofocus
value="<?= htmlspecialchars($form['first_name']) ?>">
</div>
<div class="form-group">
<label for="last_name">Nom</label>
<input type="text" id="last_name" name="last_name" required
value="<?= htmlspecialchars($form['last_name']) ?>">
</div>
</div>
<div id="username-hint" class="username-hint" style="display:none">
Votre identifiant AlpID sera : <strong id="username-preview"></strong>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email"
value="<?= htmlspecialchars($form['email']) ?>">
</div>
<div class="form-row">
<div class="form-group">
<label for="password">Mot de passe</label>
<input type="password" id="password" name="password" required autocomplete="new-password" minlength="8">
</div>
<div class="form-group">
<label for="password2">Confirmer</label>
<input type="password" id="password2" name="password2" required autocomplete="new-password">
</div>
</div>
<button type="submit" class="btn-primary btn-full">Créer mon compte</button>
</form>
<p class="form-footer">Déjà inscrit ? <a href="/auth/login.php">Se connecter</a></p>
</div>
<script>
function normalizeStr(s) {
return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-zA-Z]/g, '');
}
function encodeLastname(ln) {
const n = normalizeStr(ln).toLowerCase();
if (n.length <= 2) return n;
return n[0] + (n.length - 2) + n[n.length - 1];
}
function buildUsername(fn, ln) {
const first = normalizeStr(fn).toLowerCase();
const code = encodeLastname(ln);
if (!first && !code) return '';
if (!first) return code;
if (!code) return first;
return first + '.' + code;
}
function updatePreview() {
const u = buildUsername(
document.getElementById('first_name').value.trim(),
document.getElementById('last_name').value.trim()
);
const hint = document.getElementById('username-hint');
if (!u) { hint.style.display = 'none'; return; }
document.getElementById('username-preview').textContent = u;
hint.style.display = 'block';
}
document.getElementById('last_name').addEventListener('input', updatePreview);
document.getElementById('first_name').addEventListener('input', updatePreview);
</script>
<?php require __DIR__ . '/views/layout_end.php'; ?>

5
web/robots.txt Normal file
View file

@ -0,0 +1,5 @@
User-agent: *
Disallow: /admin/
Disallow: /auth/
Disallow: /inc/
Disallow: /views/

1
web/views/.htaccess Normal file
View file

@ -0,0 +1 @@
Require all denied

7
web/views/403.php Normal file
View file

@ -0,0 +1,7 @@
<?php $title = 'Accès refusé'; require __DIR__ . '/layout.php'; ?>
<div class="card center">
<h1>403 Accès refusé</h1>
<p>Vous n'avez pas les droits nécessaires pour accéder à cette page.</p>
<a href="/" class="btn-primary">Retour à l'accueil</a>
</div>
<?php require __DIR__ . '/layout_end.php'; ?>

38
web/views/layout.php Normal file
View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($title ?? 'Portail Alpinux') ?></title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<header>
<nav>
<a href="/" class="brand">
<img src="https://static.alpinux.org/logo/alpinux-logo.svg" alt="Alpinux" height="32">
<span>Portail membres</span>
</a>
<div class="nav-links">
<?php if ($user = current_user()): ?>
<a href="/profile.php">Mon profil</a>
<?php if ($user['is_admin']): ?>
<a href="/admin/members.php">Admin</a>
<?php endif; ?>
<a href="/logout.php" class="btn-outline">Déconnexion</a>
<?php else: ?>
<a href="/register.php">S'inscrire</a>
<a href="/auth/login.php" class="btn-primary">Connexion</a>
<?php endif; ?>
</div>
</nav>
</header>
<main>
<?php
$flash = get_flash();
if ($flash): ?>
<div class="flash flash-<?= htmlspecialchars($flash['type']) ?>">
<?= htmlspecialchars($flash['message']) ?>
</div>
<?php endif; ?>

11
web/views/layout_end.php Normal file
View file

@ -0,0 +1,11 @@
</main>
<footer>
<p>
<a href="https://alpinux.org">alpinux.org</a> &nbsp;·&nbsp;
<a href="https://wiki.alpinux.org">Wiki</a> &nbsp;·&nbsp;
<a href="<?= htmlspecialchars(HELLOASSO_URL) ?>">Adhérer via HelloAsso</a>
</p>
</footer>
</body>
</html>