From 0f086f44f0f8e936f8541dc0218f4d4978ef2d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Le=CC=81o?= Date: Mon, 29 Jun 2026 12:48:44 +0200 Subject: [PATCH 1/4] Add admin management for stream --- .github/workflows/php-sandbox.yml | 2 +- migrations/19-stream-owner.sql | 2 + src/Controllers/AdminController.php | 77 ++++++++++++++++++++++++++--- src/Repositories/UserRepository.php | 33 +++++++++++++ src/views/stream/edit.html.twig | 29 +++++++++++ 5 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 migrations/19-stream-owner.sql diff --git a/.github/workflows/php-sandbox.yml b/.github/workflows/php-sandbox.yml index 0cc98c1..e06300c 100644 --- a/.github/workflows/php-sandbox.yml +++ b/.github/workflows/php-sandbox.yml @@ -2,7 +2,7 @@ name: Build & deploy sandbox on: push: - branches: [ "develop" ] + branches: [ "develop", "add-admin-management-on-stream" ] workflow_dispatch: permissions: diff --git a/migrations/19-stream-owner.sql b/migrations/19-stream-owner.sql new file mode 100644 index 0000000..7fbf189 --- /dev/null +++ b/migrations/19-stream-owner.sql @@ -0,0 +1,2 @@ +-- Fix: les créateurs de streams existants doivent avoir is_owner = 1 +UPDATE {prefix}user_right SET is_owner = 1 WHERE id_charity_stream IS NOT NULL; diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php index 82eba7a..de62987 100644 --- a/src/Controllers/AdminController.php +++ b/src/Controllers/AdminController.php @@ -170,6 +170,30 @@ public function deleteUser(Request $request, Response $response, array $args): R return $this->redirectToRoute($request, $response, 'app_admin_index', [], ['tab' => 'users']); } + private function sendAdminInvitationEmail(User $user, Request $request): void + { + $routeParser = RouteContext::fromRequest($request)->getRouteParser(); + $resetUrl = $_SERVER['WEBSITE_DOMAIN'] . $routeParser->urlFor('app_reset_password', ["token" => $user->reset_token]); + + try { + $result = $this->mailchimp->messages->send([ + "message" => [ + "from_email" => "contact@helloasso.io", + "from_name" => "HelloAsso", + "subject" => "Bienvenue sur HelloAsso Stream !", + "html" => $this->buildWelcomeEmail($resetUrl), + "to" => [["email" => $user->email, "type" => "to"]], + ], + ]); + + if ($result instanceof \Exception) { + throw $result; + } + } catch (\Exception $e) { + $this->messages->addMessage('error', 'Admin ajouté mais l\'email d\'invitation n\'a pas pu être envoyé.'); + } + } + /** * Génère le contenu HTML de l'email de bienvenue envoyé aux nouveaux utilisateurs. */ @@ -314,14 +338,19 @@ public function editEventPost(Request $request, Response $response, array $args) if ($isEventOwner && isset($body['add_admin'])) { $email = trim($body['admin_email'] ?? ''); if ($email) { - $newAdmin = $this->userRepository->findOrCreate($email); $existing = $this->userRepository->selectEventAdmins($event); - $alreadyIn = array_filter($existing, fn($a) => (int) $a['id'] === $newAdmin->id); - if (empty($alreadyIn)) { + $alreadyIn = array_filter($existing, fn($a) => $a['email'] === $email); + if (!empty($alreadyIn)) { + $this->messages->addMessage('info', "{$email} est déjà admin de cet évènement"); + } else { + $existingUser = $this->userRepository->select($email); + $newAdmin = $existingUser ?? $this->userRepository->insert($email); + if (!$existingUser) { + $newAdmin = $this->userRepository->insertResetToken($newAdmin); + $this->sendAdminInvitationEmail($newAdmin, $request); + } $this->userRepository->insertRight($newAdmin, null, $event, false); $this->messages->addMessage('success', "Admin {$email} ajouté"); - } else { - $this->messages->addMessage('info', "{$email} est déjà admin de cet évènement"); } } } @@ -360,7 +389,7 @@ public function newStream(Request $request, Response $response): Response } $stream = $this->streamRepository->insert($data['form_slug'], $data['organization_slug'], $data['title'], $event->id ?? null, $data['form_type'] ?? 'Donation'); - $this->userRepository->insertRight($owner, $stream, null); + $this->userRepository->insertRight($owner, $stream, null, true); if ($event !== null && $parentStyle) { $donationGoalWidget = $this->widgetRepository->selectDonationWidgetByGuid(null, $event->guid); @@ -422,6 +451,8 @@ public function editStream(Request $request, Response $response, array $args): R $donationUrl = $_SERVER['HA_URL'] . '/associations/' . $charityStream->organization_slug . '/' . $formTypeUrlSegment . '/' . $charityStream->form_slug; $routeParser = RouteContext::fromRequest($request)->getRouteParser(); + $isStreamOwner = $user->role === 'ADMIN' || $this->userRepository->isStreamOwner($user, $charityStream); + $data = [ "logged" => true, "charityStream" => $charityStream, @@ -439,6 +470,9 @@ public function editStream(Request $request, Response $response, array $args): R "widgetCardUrl" => $_SERVER['WEBSITE_DOMAIN'] . $routeParser->urlFor('app_stream_widget_card', ["id" => $guid]), "messages" => $this->messages->getMessages(), "streamGoals" => $this->goalRepository->selectAmountsByStreamGuid($guid), + "streamAdmins" => $this->userRepository->selectStreamAdmins($charityStream), + "isStreamOwner" => $isStreamOwner, + "currentUserId" => $user->id, ]; return $this->view->render($response, 'stream/edit.html.twig', $data); @@ -519,6 +553,37 @@ public function editStreamPost(Request $request, Response $response, array $args } } + // Gestion des admins (owner ou ADMIN global uniquement) + $isStreamOwner = $user->role === 'ADMIN' || $this->userRepository->isStreamOwner($user, $charityStream); + + if ($isStreamOwner && isset($body['add_admin'])) { + $email = trim($body['admin_email'] ?? ''); + if ($email) { + $existing = $this->userRepository->selectStreamAdmins($charityStream); + $alreadyIn = array_filter($existing, fn($a) => $a['email'] === $email); + if (!empty($alreadyIn)) { + $this->messages->addMessage('info', "{$email} est déjà admin de ce stream"); + } else { + $existingUser = $this->userRepository->select($email); + $newAdmin = $existingUser ?? $this->userRepository->insert($email); + if (!$existingUser) { + $newAdmin = $this->userRepository->insertResetToken($newAdmin); + $this->sendAdminInvitationEmail($newAdmin, $request); + } + $this->userRepository->insertRight($newAdmin, $charityStream, null, false); + $this->messages->addMessage('success', "Admin {$email} ajouté"); + } + } + } + + if ($isStreamOwner && isset($body['remove_admin'])) { + $removeId = (int) ($body['remove_admin'] ?? 0); + if ($removeId && $removeId !== $user->id) { + $this->userRepository->deleteStreamRight($removeId, $charityStream); + $this->messages->addMessage('success', 'Admin retiré'); + } + } + $this->handleWidgetFormSave($request, $guid, null); return $this->redirectToRoute($request, $response, 'app_stream_edit', ["id" => $guid]); diff --git a/src/Repositories/UserRepository.php b/src/Repositories/UserRepository.php index ffc2405..b07c105 100644 --- a/src/Repositories/UserRepository.php +++ b/src/Repositories/UserRepository.php @@ -107,6 +107,39 @@ public function deleteEventRight(int $userId, Event $event): void $stmt->execute([$userId, $event->id]); } + public function selectStreamAdmins(Stream $stream): array + { + $stmt = $this->pdo->prepare(' + SELECT u.id, u.email, ur.is_owner + FROM ' . $this->prefix . 'user_right ur + INNER JOIN ' . $this->prefix . 'users u ON u.id = ur.id_user + WHERE ur.id_charity_stream = ? + ORDER BY ur.is_owner DESC, u.email ASC + '); + $stmt->execute([$stream->id]); + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + public function isStreamOwner(User $user, Stream $stream): bool + { + $stmt = $this->pdo->prepare(' + SELECT 1 FROM ' . $this->prefix . 'user_right + WHERE id_user = ? AND id_charity_stream = ? AND is_owner = 1 + LIMIT 1 + '); + $stmt->execute([$user->id, $stream->id]); + return (bool) $stmt->fetch(); + } + + public function deleteStreamRight(int $userId, Stream $stream): void + { + $stmt = $this->pdo->prepare(' + DELETE FROM ' . $this->prefix . 'user_right + WHERE id_user = ? AND id_charity_stream = ? + '); + $stmt->execute([$userId, $stream->id]); + } + public function select(string $email): ?User { $stmt = $this->pdo->prepare('SELECT * FROM ' . $this->prefix . 'users WHERE email = ?'); diff --git a/src/views/stream/edit.html.twig b/src/views/stream/edit.html.twig index f5c00d8..83da4c7 100644 --- a/src/views/stream/edit.html.twig +++ b/src/views/stream/edit.html.twig @@ -126,6 +126,35 @@ + {# ── Administrateurs ─────────────────────────────────────── #} + {% if isStreamOwner %} +
+
+
👥 Administrateurs
+
+
+
+ {% for admin in streamAdmins %} + + {{ admin.email | e }} + {% if admin.is_owner %} + + {% elseif admin.id != currentUserId %} +
+ +
+ {% endif %} +
+ {% endfor %} +
+
+ + +
+
+
+ {% endif %} + {# ── Objectifs de collecte ───────────────────────────────── #}
From 9ee487fa1062b86cc095650bc83dfc0d65de23f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Le=CC=81o?= Date: Mon, 29 Jun 2026 19:04:25 +0200 Subject: [PATCH 2/4] Fix missing use statement for App\Models\User in AdminController Co-Authored-By: Claude Sonnet 4.6 --- src/Controllers/AdminController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php index de62987..8b5dae6 100644 --- a/src/Controllers/AdminController.php +++ b/src/Controllers/AdminController.php @@ -2,6 +2,7 @@ namespace App\Controllers; +use App\Models\User; use App\Repositories\AccessTokenRepository; use App\Repositories\AuthorizationCodeRepository; use App\Repositories\EventRepository; From 6f548de206d7ff787a72ce66a6e9d99e1a616ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Le=CC=81o?= Date: Mon, 29 Jun 2026 19:14:23 +0200 Subject: [PATCH 3/4] Refactor: unify admin right methods via HasRight interface - Add HasRight interface (getRightColumn/getRightId) implemented by Event and Stream - Collapse 6 entity-specific UserRepository methods into 3 generic ones: selectAdmins, isOwner, deleteRight - Extract provisionAdminUser() in AdminController to deduplicate find/create/token/invite logic shared by event and stream add-admin flows - Replace array_filter+!empty duplicate check with in_array(array_column(...)) Co-Authored-By: Claude Sonnet 4.6 --- src/Controllers/AdminController.php | 52 ++++++++++---------- src/Models/Event.php | 5 +- src/Models/HasRight.php | 9 ++++ src/Models/Stream.php | 5 +- src/Repositories/UserRepository.php | 75 +++++++++-------------------- 5 files changed, 66 insertions(+), 80 deletions(-) create mode 100644 src/Models/HasRight.php diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php index 8b5dae6..857f671 100644 --- a/src/Controllers/AdminController.php +++ b/src/Controllers/AdminController.php @@ -171,6 +171,18 @@ public function deleteUser(Request $request, Response $response, array $args): R return $this->redirectToRoute($request, $response, 'app_admin_index', [], ['tab' => 'users']); } + private function provisionAdminUser(string $email, Request $request): User + { + $existing = $this->userRepository->select($email); + if ($existing) { + return $existing; + } + $user = $this->userRepository->insert($email); + $user = $this->userRepository->insertResetToken($user); + $this->sendAdminInvitationEmail($user, $request); + return $user; + } + private function sendAdminInvitationEmail(User $user, Request $request): void { $routeParser = RouteContext::fromRequest($request)->getRouteParser(); @@ -267,7 +279,7 @@ public function editEvent(Request $request, Response $response, array $args): Re $cardWidget = $this->widgetRepository->selectCardWidgetByGuid(null, $event->guid); $streams = $this->streamRepository->selectListByEvent($event); $routeParser = RouteContext::fromRequest($request)->getRouteParser(); - $isEventOwner = $user->role === 'ADMIN' || $this->userRepository->isEventOwner($user, $event); + $isEventOwner = $user->role === 'ADMIN' || $this->userRepository->isOwner($user, $event); $data = [ "logged" => true, @@ -280,7 +292,7 @@ public function editEvent(Request $request, Response $response, array $args): Re "widgetDonationGoalUrl" => $_SERVER['WEBSITE_DOMAIN'] . $routeParser->urlFor('app_event_widget_donation', ["id" => $event->guid]), "widgetCardUrl" => $_SERVER['WEBSITE_DOMAIN'] . $routeParser->urlFor('app_event_widget_card', ["id" => $event->guid]), "eventGoals" => $this->goalRepository->selectAmountsByEventGuid($event->guid), - "eventAdmins" => $this->userRepository->selectEventAdmins($event), + "eventAdmins" => $this->userRepository->selectAdmins($event), "isEventOwner" => $isEventOwner, "currentUserId" => $user->id, ]; @@ -334,22 +346,16 @@ public function editEventPost(Request $request, Response $response, array $args) } // Gestion des admins (owner ou ADMIN global uniquement) - $isEventOwner = $user->role === 'ADMIN' || $this->userRepository->isEventOwner($user, $event); + $isEventOwner = $user->role === 'ADMIN' || $this->userRepository->isOwner($user, $event); if ($isEventOwner && isset($body['add_admin'])) { $email = trim($body['admin_email'] ?? ''); if ($email) { - $existing = $this->userRepository->selectEventAdmins($event); - $alreadyIn = array_filter($existing, fn($a) => $a['email'] === $email); - if (!empty($alreadyIn)) { + $existing = $this->userRepository->selectAdmins($event); + if (in_array($email, array_column($existing, 'email'))) { $this->messages->addMessage('info', "{$email} est déjà admin de cet évènement"); } else { - $existingUser = $this->userRepository->select($email); - $newAdmin = $existingUser ?? $this->userRepository->insert($email); - if (!$existingUser) { - $newAdmin = $this->userRepository->insertResetToken($newAdmin); - $this->sendAdminInvitationEmail($newAdmin, $request); - } + $newAdmin = $this->provisionAdminUser($email, $request); $this->userRepository->insertRight($newAdmin, null, $event, false); $this->messages->addMessage('success', "Admin {$email} ajouté"); } @@ -359,7 +365,7 @@ public function editEventPost(Request $request, Response $response, array $args) if ($isEventOwner && isset($body['remove_admin'])) { $removeId = (int) ($body['remove_admin'] ?? 0); if ($removeId && $removeId !== $user->id) { - $this->userRepository->deleteEventRight($removeId, $event); + $this->userRepository->deleteRight($removeId, $event); $this->messages->addMessage('success', 'Admin retiré'); } } @@ -452,7 +458,7 @@ public function editStream(Request $request, Response $response, array $args): R $donationUrl = $_SERVER['HA_URL'] . '/associations/' . $charityStream->organization_slug . '/' . $formTypeUrlSegment . '/' . $charityStream->form_slug; $routeParser = RouteContext::fromRequest($request)->getRouteParser(); - $isStreamOwner = $user->role === 'ADMIN' || $this->userRepository->isStreamOwner($user, $charityStream); + $isStreamOwner = $user->role === 'ADMIN' || $this->userRepository->isOwner($user, $charityStream); $data = [ "logged" => true, @@ -471,7 +477,7 @@ public function editStream(Request $request, Response $response, array $args): R "widgetCardUrl" => $_SERVER['WEBSITE_DOMAIN'] . $routeParser->urlFor('app_stream_widget_card', ["id" => $guid]), "messages" => $this->messages->getMessages(), "streamGoals" => $this->goalRepository->selectAmountsByStreamGuid($guid), - "streamAdmins" => $this->userRepository->selectStreamAdmins($charityStream), + "streamAdmins" => $this->userRepository->selectAdmins($charityStream), "isStreamOwner" => $isStreamOwner, "currentUserId" => $user->id, ]; @@ -555,22 +561,16 @@ public function editStreamPost(Request $request, Response $response, array $args } // Gestion des admins (owner ou ADMIN global uniquement) - $isStreamOwner = $user->role === 'ADMIN' || $this->userRepository->isStreamOwner($user, $charityStream); + $isStreamOwner = $user->role === 'ADMIN' || $this->userRepository->isOwner($user, $charityStream); if ($isStreamOwner && isset($body['add_admin'])) { $email = trim($body['admin_email'] ?? ''); if ($email) { - $existing = $this->userRepository->selectStreamAdmins($charityStream); - $alreadyIn = array_filter($existing, fn($a) => $a['email'] === $email); - if (!empty($alreadyIn)) { + $existing = $this->userRepository->selectAdmins($charityStream); + if (in_array($email, array_column($existing, 'email'))) { $this->messages->addMessage('info', "{$email} est déjà admin de ce stream"); } else { - $existingUser = $this->userRepository->select($email); - $newAdmin = $existingUser ?? $this->userRepository->insert($email); - if (!$existingUser) { - $newAdmin = $this->userRepository->insertResetToken($newAdmin); - $this->sendAdminInvitationEmail($newAdmin, $request); - } + $newAdmin = $this->provisionAdminUser($email, $request); $this->userRepository->insertRight($newAdmin, $charityStream, null, false); $this->messages->addMessage('success', "Admin {$email} ajouté"); } @@ -580,7 +580,7 @@ public function editStreamPost(Request $request, Response $response, array $args if ($isStreamOwner && isset($body['remove_admin'])) { $removeId = (int) ($body['remove_admin'] ?? 0); if ($removeId && $removeId !== $user->id) { - $this->userRepository->deleteStreamRight($removeId, $charityStream); + $this->userRepository->deleteRight($removeId, $charityStream); $this->messages->addMessage('success', 'Admin retiré'); } } diff --git a/src/Models/Event.php b/src/Models/Event.php index 9683723..a3ccdb4 100644 --- a/src/Models/Event.php +++ b/src/Models/Event.php @@ -2,7 +2,7 @@ namespace App\Models; -class Event +class Event implements HasRight { public $id; public $guid; @@ -12,4 +12,7 @@ class Event public $admin; public $is_test_mode = 0; public $test_amount = 0; + + public function getRightColumn(): string { return 'id_charity_event'; } + public function getRightId(): int { return (int) $this->id; } } diff --git a/src/Models/HasRight.php b/src/Models/HasRight.php new file mode 100644 index 0000000..d0bec92 --- /dev/null +++ b/src/Models/HasRight.php @@ -0,0 +1,9 @@ +id; } } diff --git a/src/Repositories/UserRepository.php b/src/Repositories/UserRepository.php index b07c105..9289862 100644 --- a/src/Repositories/UserRepository.php +++ b/src/Repositories/UserRepository.php @@ -3,6 +3,7 @@ namespace App\Repositories; use App\Models\Event; +use App\Models\HasRight; use App\Models\Stream; use App\Models\User; use DateTime; @@ -74,70 +75,40 @@ public function insertRight(User $user, ?Stream $stream, ?Event $event, bool $is ]); } - public function selectEventAdmins(Event $event): array + public function selectAdmins(HasRight $entity): array { - $stmt = $this->pdo->prepare(' - SELECT u.id, u.email, ur.is_owner - FROM ' . $this->prefix . 'user_right ur - INNER JOIN ' . $this->prefix . 'users u ON u.id = ur.id_user - WHERE ur.id_charity_event = ? - ORDER BY ur.is_owner DESC, u.email ASC - '); - $stmt->execute([$event->id]); - return $stmt->fetchAll(PDO::FETCH_ASSOC); - } - - public function isEventOwner(User $user, Event $event): bool - { - $stmt = $this->pdo->prepare(' - SELECT 1 FROM ' . $this->prefix . 'user_right - WHERE id_user = ? AND id_charity_event = ? AND is_owner = 1 - LIMIT 1 - '); - $stmt->execute([$user->id, $event->id]); - return (bool) $stmt->fetch(); - } - - public function deleteEventRight(int $userId, Event $event): void - { - $stmt = $this->pdo->prepare(' - DELETE FROM ' . $this->prefix . 'user_right - WHERE id_user = ? AND id_charity_event = ? - '); - $stmt->execute([$userId, $event->id]); - } - - public function selectStreamAdmins(Stream $stream): array - { - $stmt = $this->pdo->prepare(' + $col = $entity->getRightColumn(); + $stmt = $this->pdo->prepare(" SELECT u.id, u.email, ur.is_owner - FROM ' . $this->prefix . 'user_right ur - INNER JOIN ' . $this->prefix . 'users u ON u.id = ur.id_user - WHERE ur.id_charity_stream = ? + FROM {$this->prefix}user_right ur + INNER JOIN {$this->prefix}users u ON u.id = ur.id_user + WHERE ur.{$col} = ? ORDER BY ur.is_owner DESC, u.email ASC - '); - $stmt->execute([$stream->id]); + "); + $stmt->execute([$entity->getRightId()]); return $stmt->fetchAll(PDO::FETCH_ASSOC); } - public function isStreamOwner(User $user, Stream $stream): bool + public function isOwner(User $user, HasRight $entity): bool { - $stmt = $this->pdo->prepare(' - SELECT 1 FROM ' . $this->prefix . 'user_right - WHERE id_user = ? AND id_charity_stream = ? AND is_owner = 1 + $col = $entity->getRightColumn(); + $stmt = $this->pdo->prepare(" + SELECT 1 FROM {$this->prefix}user_right + WHERE id_user = ? AND {$col} = ? AND is_owner = 1 LIMIT 1 - '); - $stmt->execute([$user->id, $stream->id]); + "); + $stmt->execute([$user->id, $entity->getRightId()]); return (bool) $stmt->fetch(); } - public function deleteStreamRight(int $userId, Stream $stream): void + public function deleteRight(int $userId, HasRight $entity): void { - $stmt = $this->pdo->prepare(' - DELETE FROM ' . $this->prefix . 'user_right - WHERE id_user = ? AND id_charity_stream = ? - '); - $stmt->execute([$userId, $stream->id]); + $col = $entity->getRightColumn(); + $stmt = $this->pdo->prepare(" + DELETE FROM {$this->prefix}user_right + WHERE id_user = ? AND {$col} = ? + "); + $stmt->execute([$userId, $entity->getRightId()]); } public function select(string $email): ?User From c9eb366814446de42ab22a3e772cf708a8850b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Le=CC=81o?= Date: Mon, 29 Jun 2026 19:18:38 +0200 Subject: [PATCH 4/4] Add admin management for stream --- .github/workflows/php-sandbox.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/php-sandbox.yml b/.github/workflows/php-sandbox.yml index e06300c..be1a82b 100644 --- a/.github/workflows/php-sandbox.yml +++ b/.github/workflows/php-sandbox.yml @@ -2,7 +2,7 @@ name: Build & deploy sandbox on: push: - branches: [ "develop", "add-admin-management-on-stream" ] + branches: [ "develop"] workflow_dispatch: permissions: