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:
parent
0eaf9232fa
commit
fcdd094e26
36 changed files with 2704 additions and 0 deletions
41
.env.example
Normal file
41
.env.example
Normal 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
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
.env
|
||||||
|
web/inc/config.local.php
|
||||||
105
scripts/deploy.sh
Executable file
105
scripts/deploy.sh
Executable 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}"
|
||||||
9
scripts/portail.alpinux.org.vhost.conf
Normal file
9
scripts/portail.alpinux.org.vhost.conf
Normal 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
6
web/.htaccess
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
# Fichiers sensibles
|
||||||
|
<FilesMatch "^\.env">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
110
web/admin/members.php
Normal file
110
web/admin/members.php
Normal 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
22
web/admin/revoke.php
Normal 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
100
web/admin/services.php
Normal 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
22
web/admin/validate.php
Normal 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
349
web/assets/style.css
Normal 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
52
web/auth/callback.php
Normal 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
14
web/auth/login.php
Normal 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
117
web/dashboard.php
Normal 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
BIN
web/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
69
web/helloasso/callback.php
Normal file
69
web/helloasso/callback.php
Normal 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
92
web/helloasso/renew.php
Normal 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
202
web/history.php
Normal 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
1
web/inc/.htaccess
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Require all denied
|
||||||
58
web/inc/auth.php
Normal file
58
web/inc/auth.php
Normal 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
54
web/inc/config.php
Normal 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
94
web/inc/dolibarr.php
Normal 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
95
web/inc/helloasso.php
Normal 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
200
web/inc/keycloak.php
Normal 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
62
web/inc/oidc.php
Normal 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
26
web/inc/services.php
Normal 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
98
web/index.php
Normal 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
11
web/logout.php
Normal 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
66
web/password.php
Normal 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
385
web/profile.php
Normal 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'] ?>&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
45
web/register-success.php
Normal 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
135
web/register.php
Normal 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
5
web/robots.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /admin/
|
||||||
|
Disallow: /auth/
|
||||||
|
Disallow: /inc/
|
||||||
|
Disallow: /views/
|
||||||
1
web/views/.htaccess
Normal file
1
web/views/.htaccess
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Require all denied
|
||||||
7
web/views/403.php
Normal file
7
web/views/403.php
Normal 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
38
web/views/layout.php
Normal 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
11
web/views/layout_end.php
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>
|
||||||
|
<a href="https://alpinux.org">alpinux.org</a> ·
|
||||||
|
<a href="https://wiki.alpinux.org">Wiki</a> ·
|
||||||
|
<a href="<?= htmlspecialchars(HELLOASSO_URL) ?>">Adhérer via HelloAsso</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue