From fcdd094e26c4ecb226bf15270d7f8dc8413c5ca7 Mon Sep 17 00:00:00 2001 From: Alpinux Date: Mon, 4 May 2026 00:43:38 +0200 Subject: [PATCH] =?UTF-8?q?Portail=20membres=20complet=20:=20profil,=20adh?= =?UTF-8?q?=C3=A9sion,=20historique,=20OTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 41 +++ .gitignore | 2 + scripts/deploy.sh | 105 +++++++ scripts/portail.alpinux.org.vhost.conf | 9 + web/.htaccess | 6 + web/admin/members.php | 110 +++++++ web/admin/revoke.php | 22 ++ web/admin/services.php | 100 +++++++ web/admin/validate.php | 22 ++ web/assets/style.css | 349 ++++++++++++++++++++++ web/auth/callback.php | 52 ++++ web/auth/login.php | 14 + web/dashboard.php | 117 ++++++++ web/favicon.ico | Bin 0 -> 22382 bytes web/helloasso/callback.php | 69 +++++ web/helloasso/renew.php | 92 ++++++ web/history.php | 202 +++++++++++++ web/inc/.htaccess | 1 + web/inc/auth.php | 58 ++++ web/inc/config.php | 54 ++++ web/inc/dolibarr.php | 94 ++++++ web/inc/helloasso.php | 95 ++++++ web/inc/keycloak.php | 200 +++++++++++++ web/inc/oidc.php | 62 ++++ web/inc/services.php | 26 ++ web/index.php | 98 +++++++ web/logout.php | 11 + web/password.php | 66 +++++ web/profile.php | 385 +++++++++++++++++++++++++ web/register-success.php | 45 +++ web/register.php | 135 +++++++++ web/robots.txt | 5 + web/views/.htaccess | 1 + web/views/403.php | 7 + web/views/layout.php | 38 +++ web/views/layout_end.php | 11 + 36 files changed, 2704 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100755 scripts/deploy.sh create mode 100644 scripts/portail.alpinux.org.vhost.conf create mode 100644 web/.htaccess create mode 100644 web/admin/members.php create mode 100644 web/admin/revoke.php create mode 100644 web/admin/services.php create mode 100644 web/admin/validate.php create mode 100644 web/assets/style.css create mode 100644 web/auth/callback.php create mode 100644 web/auth/login.php create mode 100644 web/dashboard.php create mode 100644 web/favicon.ico create mode 100644 web/helloasso/callback.php create mode 100644 web/helloasso/renew.php create mode 100644 web/history.php create mode 100644 web/inc/.htaccess create mode 100644 web/inc/auth.php create mode 100644 web/inc/config.php create mode 100644 web/inc/dolibarr.php create mode 100644 web/inc/helloasso.php create mode 100644 web/inc/keycloak.php create mode 100644 web/inc/oidc.php create mode 100644 web/inc/services.php create mode 100644 web/index.php create mode 100644 web/logout.php create mode 100644 web/password.php create mode 100644 web/profile.php create mode 100644 web/register-success.php create mode 100644 web/register.php create mode 100644 web/robots.txt create mode 100644 web/views/.htaccess create mode 100644 web/views/403.php create mode 100644 web/views/layout.php create mode 100644 web/views/layout_end.php diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..601bb42 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..453a623 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +web/inc/config.local.php diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..4a78924 --- /dev/null +++ b/scripts/deploy.sh @@ -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" =~ ^\ 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). diff --git a/web/.htaccess b/web/.htaccess new file mode 100644 index 0000000..69277bb --- /dev/null +++ b/web/.htaccess @@ -0,0 +1,6 @@ +Options -Indexes + +# Fichiers sensibles + + Require all denied + diff --git a/web/admin/members.php b/web/admin/members.php new file mode 100644 index 0000000..b47bad3 --- /dev/null +++ b/web/admin/members.php @@ -0,0 +1,110 @@ +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'; +?> + +
+ + + +
Erreur Keycloak :
+ + + +
+

En attente de validation ()

+ +

Aucun inscrit en attente.

+ + + + + + + + + + + + + + + + + + +
NomIdentifiantEmailGroupesActions
+
+ + +
+
+ +
+ + +
+

Adhérents ()

+ +

Aucun adhérent.

+ + + + + + + + + + + + + + + + + + +
NomIdentifiantEmailGroupesActions
+
+ + +
+
+ +
+
+ + diff --git a/web/admin/revoke.php b/web/admin/revoke.php new file mode 100644 index 0000000..35b07a4 --- /dev/null +++ b/web/admin/revoke.php @@ -0,0 +1,22 @@ +getMessage()); +} + +header('Location: /admin/members.php'); +exit; diff --git a/web/admin/services.php b/web/admin/services.php new file mode 100644 index 0000000..e7828f9 --- /dev/null +++ b/web/admin/services.php @@ -0,0 +1,100 @@ + $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'; +?> + +
+ + +
+

+ Définissez quels services sont accessibles aux simples inscrits + et lesquels nécessitent une adhésion validée. +

+ +
+ + + + + + + + + + + + $s): ?> + + + + + + + + + + + + + + + + + +
NomURLDescriptionAdhérent requisVisible
+ > + + > +
+ + +
+
+
+ + diff --git a/web/admin/validate.php b/web/admin/validate.php new file mode 100644 index 0000000..a7cf5a1 --- /dev/null +++ b/web/admin/validate.php @@ -0,0 +1,22 @@ +getMessage()); +} + +header('Location: /admin/members.php'); +exit; diff --git a/web/assets/style.css b/web/assets/style.css new file mode 100644 index 0000000..ca565d7 --- /dev/null +++ b/web/assets/style.css @@ -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; } +} diff --git a/web/auth/callback.php b/web/auth/callback.php new file mode 100644 index 0000000..2ee16bc --- /dev/null +++ b/web/auth/callback.php @@ -0,0 +1,52 @@ + $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())); +} diff --git a/web/auth/login.php b/web/auth/login.php new file mode 100644 index 0000000..5275c9b --- /dev/null +++ b/web/auth/login.php @@ -0,0 +1,14 @@ +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'; +?> + +
+
+

Bonjour,

+
+ Inscrit + + Adhérent + + + Admin + +
+
+ +
+ +
+

Mon compte

+
+
Identifiant
+
Email
+ +
Groupes
+ +
+ Changer mon mot de passe +
+ + +
+

Mon adhésion

+ +

Impossible de contacter Dolibarr.

+ +
+
Statut
+
Actif' + : 'Inactif' ?>
+ +
Cotisation jusqu\'au
+
+ + +
Type
+ +
+ + Renouveler mon adhésion + + +

Aucune adhésion trouvée.

+

Adhérez pour accéder aux services réservés aux membres.

+ + Adhérer via HelloAsso + +

Un administrateur validera votre adhésion sous 48h.

+ +
+
+ + +
+

Mes services

+
+ +
+
+ + + + +
+

+ + Accéder + + Adhésion requise + +
+ +
+
+
+ + diff --git a/web/favicon.ico b/web/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..3de6046768877652ddb2e53020d7de4ee6889e44 GIT binary patch literal 22382 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 + 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) ?? [] : []; +} diff --git a/web/helloasso/renew.php b/web/helloasso/renew.php new file mode 100644 index 0000000..931e3f8 --- /dev/null +++ b/web/helloasso/renew.php @@ -0,0 +1,92 @@ +getMessage()); + header('Location: /dashboard.php'); + exit; + } +} + +$title = 'Renouveler mon adhésion'; +require __DIR__ . '/../views/layout.php'; +?> + +
+

Renouveler mon adhésion

+

Votre adresse postale est nécessaire pour finaliser l'adhésion.

+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + diff --git a/web/history.php b/web/history.php new file mode 100644 index 0000000..b97bf97 --- /dev/null +++ b/web/history.php @@ -0,0 +1,202 @@ + $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'; +?> + +
+ + + +
+
+
+
Connexions totales
+
+
+
+
30 derniers jours
+
+
+
+
IP distinctes
+
+
+
+
Échecs (30j)
+
+
+
+
Déconnexions
+
+
+
+
Dernière connexion
+
+
+ + +
+
+

Connexions par jour (30 derniers jours)

+ +
+
+

Par heure de la journée

+ +
+
+

Par jour de la semaine

+ +
+
+

IPs les plus fréquentes

+ +
+ $count): ?> +
+ +
+
+
+ +
+ +
+ +

Aucune donnée.

+ +
+
+ + +
+

Dernières connexions

+ + + + + + + + + + + + + + + + + + + +
DateHeureAdresse IP
+ +

Aucune connexion enregistrée.

+ +
+ + +
+

Tentatives de connexion échouées

+ + + + + + + + + + + +
DateHeureIP
+
+ + +
+ + + + + diff --git a/web/inc/.htaccess b/web/inc/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/web/inc/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/web/inc/auth.php b/web/inc/auth.php new file mode 100644 index 0000000..e6c3f98 --- /dev/null +++ b/web/inc/auth.php @@ -0,0 +1,58 @@ + 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; +} diff --git a/web/inc/config.php b/web/inc/config.php new file mode 100644 index 0000000..090e211 --- /dev/null +++ b/web/inc/config.php @@ -0,0 +1,54 @@ + ['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', + ]; +} diff --git a/web/inc/helloasso.php b/web/inc/helloasso.php new file mode 100644 index 0000000..f8073e5 --- /dev/null +++ b/web/inc/helloasso.php @@ -0,0 +1,95 @@ + 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); +} diff --git a/web/inc/keycloak.php b/web/inc/keycloak.php new file mode 100644 index 0000000..ab229d0 --- /dev/null +++ b/web/inc/keycloak.php @@ -0,0 +1,200 @@ + 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']})"); + } +} diff --git a/web/inc/oidc.php b/web/inc/oidc.php new file mode 100644 index 0000000..5f239e5 --- /dev/null +++ b/web/inc/oidc.php @@ -0,0 +1,62 @@ + 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); +} diff --git a/web/inc/services.php b/web/inc/services.php new file mode 100644 index 0000000..8604554 --- /dev/null +++ b/web/inc/services.php @@ -0,0 +1,26 @@ + '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)); +} diff --git a/web/index.php b/web/index.php new file mode 100644 index 0000000..a1b5cf6 --- /dev/null +++ b/web/index.php @@ -0,0 +1,98 @@ + + + + +
+

Bienvenue,

+ +

Vous n'avez pas encore adhéré à Alpinux. Adhérer, c'est soutenir nos actions pour le logiciel libre en Savoie.

+ + +

Merci pour votre soutien. Retrouvez vos services et votre profil ci-dessous.

+ + +
+ + + +
+

Portail membres Alpinux

+

Accédez à vos services, gérez votre adhésion et votre compte AlpID.

+ +
+ +
+
+
+
Qui sommes-nous ?
+

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.

+
+
+
Pourquoi adhérer ?
+

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.

+
+
+
AlpID
+

Un seul compte pour tous les services Alpinux. Gérez votre profil, votre adresse et votre adhésion depuis ce portail.

+
+
+
+ + + +
+

Nos services

+
+ +
+
+ + + Adhérent + +
+ +

+ +
+ + Accéder → + + Adhésion requise + + Réservé aux adhérents — + Adhérer + +
+
+ +
+
+ + diff --git a/web/logout.php b/web/logout.php new file mode 100644 index 0000000..7204325 --- /dev/null +++ b/web/logout.php @@ -0,0 +1,11 @@ +getMessage(); + } + } +} + +$title = 'Changer mon mot de passe'; +require __DIR__ . '/views/layout.php'; +?> + +
+

Changer mon mot de passe

+ + +
Mot de passe modifié avec succès.
+ + + +
+
+
+ + +
+
+ + +
+
+ + +
+ + Annuler +
+
+ + diff --git a/web/profile.php b/web/profile.php new file mode 100644 index 0000000..445fe96 --- /dev/null +++ b/web/profile.php @@ -0,0 +1,385 @@ +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'; +?> + +
+ + +
+
+
+
Identité
+
+
+ + Modifier + +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+
+ + Annuler +
+
+ +
+
Prénom
+
Nom
+
+ +
+
+ + +
+
+
+
Mon compte
+
+
+ + Modifier + +
+
+ +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + Annuler +
+
+ +
+
Email
+
+ +
+
+ + +
+
+
+
Adhésion
+
+ +
+
+ Renouveler +
+
+ +
+ +
Valide jusqu'au
+ + +
Type
+ +
+ +

Aucune fiche Dolibarr trouvée.

+ +
+
+ + +
+
+
+
Adresse postale
+
+ + + + Non renseignée + +
+
+ + Modifier + +
+
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + Annuler +
+
+ +


+

+ +

+ Voir sur OpenStreetMap +

+ + +

Cliquez sur Modifier pour renseigner votre adresse.

+ +
+
+ + +
+
+
+
Mot de passe
+
AlpID
+
+ + Modifier + +
+ +
+
+
+ +
+ + +
+
+ + +
+
+ + Annuler +
+
+
+ +
+ + +
+
+
+
Double authentification (OTP)
+
+ +
+
+ + + +
+ +
+

+ Configurée le +

+
+ + + +
+
+ +
+ + +
+
+
+
Connexions
+
Historique et statistiques
+
+ Voir +
+
+ +
+ + diff --git a/web/register-success.php b/web/register-success.php new file mode 100644 index 0000000..ea77bbe --- /dev/null +++ b/web/register-success.php @@ -0,0 +1,45 @@ + + +
+
+

Compte créé avec succès !

+

Votre compte AlpID est prêt. Vous pouvez maintenant vous connecter.

+ +
+
+ 1 +
+ Compte AlpID créé +

Votre identifiant pour tous les services Alpinux.

+
+
+
+ 2 +
+ Adhérer à l'association +

Payez votre cotisation sur HelloAsso pour accéder à tous les services.

+ + Adhérer sur HelloAsso → + +
+
+
+ 3 +
+ Validation par l'équipe +

Un administrateur validera votre adhésion sous 48h.

+
+
+
+ + Se connecter maintenant +
+ + diff --git a/web/register.php b/web/register.php new file mode 100644 index 0000000..ed3ef82 --- /dev/null +++ b/web/register.php @@ -0,0 +1,135 @@ + '', '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'; +?> + +
+

Créer mon compte AlpID

+

Créez votre compte pour accéder aux services Alpinux. L'adhésion se fait ensuite volontairement depuis votre profil.

+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + + diff --git a/web/robots.txt b/web/robots.txt new file mode 100644 index 0000000..c2ff36d --- /dev/null +++ b/web/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Disallow: /admin/ +Disallow: /auth/ +Disallow: /inc/ +Disallow: /views/ diff --git a/web/views/.htaccess b/web/views/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/web/views/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/web/views/403.php b/web/views/403.php new file mode 100644 index 0000000..5ea68e0 --- /dev/null +++ b/web/views/403.php @@ -0,0 +1,7 @@ + +
+

403 — Accès refusé

+

Vous n'avez pas les droits nécessaires pour accéder à cette page.

+ Retour à l'accueil +
+ diff --git a/web/views/layout.php b/web/views/layout.php new file mode 100644 index 0000000..cda7ab5 --- /dev/null +++ b/web/views/layout.php @@ -0,0 +1,38 @@ + + + + + + <?= htmlspecialchars($title ?? 'Portail Alpinux') ?> + + + +
+ +
+ +
+ +
+ +
+ diff --git a/web/views/layout_end.php b/web/views/layout_end.php new file mode 100644 index 0000000..44ce1b4 --- /dev/null +++ b/web/views/layout_end.php @@ -0,0 +1,11 @@ +
+ + + +