diff --git a/src/Controller/Api/ApiAuthController.php b/src/Controller/Api/ApiAuthController.php index 1f65455..5ac9618 100644 --- a/src/Controller/Api/ApiAuthController.php +++ b/src/Controller/Api/ApiAuthController.php @@ -36,12 +36,8 @@ class ApiAuthController extends AbstractController $user = $em->getRepository(User::class)->findOneBy(['email' => $email]); - if (!$user || !$passwordHasher->isPasswordValid($user, $password)) { - return $this->json(['success' => false, 'data' => null, 'error' => 'Identifiants invalides.'], 401); - } - - if (!\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) { - return $this->json(['success' => false, 'data' => null, 'error' => 'Acces reserve aux organisateurs.'], 403); + if (!$user || !$passwordHasher->isPasswordValid($user, $password) || !\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) { + return $this->json(['success' => false, 'data' => null, 'error' => !$user || !$passwordHasher->isPasswordValid($user, $password) ? 'Identifiants invalides.' : 'Acces reserve aux organisateurs.'], 401); } $token = $this->generateJwt($user, $appSecret); @@ -108,12 +104,10 @@ class ApiAuthController extends AbstractController $result = self::verifyJwt($jwt, $email, $appSecret); - if (null === $result['userId']) { - return $this->json(['success' => false, 'data' => null, 'error' => 'Token invalide.'], 401); - } + if (null === $result['userId'] || !$result['expired']) { + $error = null === $result['userId'] ? 'Token invalide.' : 'Token encore valide, pas besoin de refresh.'; - if (!$result['expired']) { - return $this->json(['success' => false, 'data' => null, 'error' => 'Token encore valide, pas besoin de refresh.'], 400); + return $this->json(['success' => false, 'data' => null, 'error' => $error], null === $result['userId'] ? 401 : 400); } $user = $em->getRepository(User::class)->find($result['userId']); diff --git a/src/Controller/ApiDocController.php b/src/Controller/ApiDocController.php index b6501ca..16ca056 100644 --- a/src/Controller/ApiDocController.php +++ b/src/Controller/ApiDocController.php @@ -9,6 +9,14 @@ use Symfony\Component\Routing\Attribute\Route; class ApiDocController extends AbstractController { + private const STATUS_401 = 'Non authentifie'; + private const STATUS_403_EVENT = 'Evenement non accessible'; + private const STATUS_403_BILLET = 'Billet non accessible'; + private const STATUS_403_CATEGORY = 'Categorie non accessible'; + private const STATUS_404_EVENT = 'Evenement introuvable'; + private const STATUS_404_BILLET = 'Billet introuvable'; + private const STATUS_404_CATEGORY = 'Categorie introuvable'; + #[Route('/api/doc', name: 'app_api_doc', methods: ['GET'])] public function index(): Response { @@ -169,249 +177,61 @@ class ApiDocController extends AbstractController private function getApiSpec(): array { return [ - [ - 'name' => 'Authentification', - 'description' => 'Route commune aux environnements sandbox et live.', - 'endpoints' => [ - [ - 'method' => 'POST', - 'path' => '/api/auth/login', - 'summary' => 'Obtenir un token JWT', - 'description' => 'Authentifie un organisateur et retourne un token JWT valable 24h.', - 'headers' => [], - 'params' => [], - 'request' => [ - 'email' => ['type' => 'string', 'required' => true, 'example' => 'orga@example.com'], - 'password' => ['type' => 'string', 'required' => true, 'example' => '********'], - ], - 'response' => [ - 'success' => ['type' => 'bool', 'example' => true], - 'data' => ['type' => 'object', 'example' => '{"token": "eyJ...", "expiresAt": "2026-03-24T12:00:00+00:00"}'], - ], - 'statuses' => [ - 200 => 'Token genere avec succes', - 401 => 'Identifiants invalides', - 429 => 'Trop de tentatives', - ], - ], - [ - 'method' => 'GET', - 'path' => '/api/auth/login/sso', - 'summary' => 'Connexion via SSO (Keycloak)', - 'description' => 'Redirige vers la page de connexion Keycloak. Apres authentification, l\'utilisateur est redirige vers /api/auth/login/sso/validate qui retourne le JWT.', - 'headers' => [], - 'params' => [], - 'request' => null, - 'response' => [ - 'redirect' => ['type' => 'string', 'example' => '"https://auth.esy-web.dev/realms/..."'], - ], - 'statuses' => [ - 302 => 'Redirection vers Keycloak', - ], - ], - [ - 'method' => 'GET', - 'path' => '/api/auth/login/sso/validate', - 'summary' => 'Callback SSO → JWT', - 'description' => 'Appele automatiquement apres la connexion Keycloak. Echange le code OAuth contre un token JWT E-Ticket. Retourne le token + email.', - 'headers' => [], - 'params' => [ - 'code' => ['type' => 'string', 'required' => true, 'description' => 'Code OAuth (ajoute par Keycloak)'], - 'state' => ['type' => 'string', 'required' => true, 'description' => 'State CSRF (ajoute par Keycloak)'], - ], - 'request' => null, - 'response' => [ - 'success' => ['type' => 'bool', 'example' => true], - 'data' => ['type' => 'object', 'example' => '{"token": "eyJ...", "expiresAt": "2026-03-24T12:00:00+00:00", "email": "orga@example.com"}'], - ], - 'statuses' => [ - 200 => 'JWT genere via SSO', - 401 => 'Authentification SSO echouee', - 403 => 'Pas de compte organisateur associe', - ], - ], - [ - 'method' => 'POST', - 'path' => '/api/auth/refresh', - 'summary' => 'Rafraichir un token expire', - 'description' => 'Genere un nouveau token JWT a partir d\'un token expire. La signature doit etre valide. Si le token est encore valide, retourne une erreur 400.', - 'headers' => $this->authHeaders(), - 'params' => [], - 'request' => null, - 'response' => [ - 'success' => ['type' => 'bool', 'example' => true], - 'data' => ['type' => 'object', 'example' => '{"token": "eyJ...", "expiresAt": "2026-03-25T12:00:00+00:00"}'], - ], - 'statuses' => [ - 200 => 'Nouveau token genere', - 400 => 'Token encore valide', - 401 => 'Token invalide ou utilisateur introuvable', - ], - ], - ], - ], - [ - 'name' => 'Evenements', - 'description' => 'Liste et detail des evenements de l\'organisateur.', - 'endpoints' => [ - [ - 'method' => 'GET', - 'path' => '/api/events', - 'summary' => 'Liste des evenements', - 'description' => 'Retourne tous les evenements de l\'organisateur authentifie.', - 'headers' => $this->authHeaders(), - 'params' => [ - 'page' => ['type' => 'int', 'required' => false, 'default' => 1, 'description' => 'Page courante'], - 'limit' => ['type' => 'int', 'required' => false, 'default' => 20, 'description' => 'Nombre par page (max 100)'], - ], - 'request' => null, - 'response' => [ - 'success' => ['type' => 'bool', 'example' => true], - 'data' => ['type' => 'array', 'example' => '[{"id": 1, "title": "Brocante 2026", "startAt": "2026-08-01T10:00:00", "endAt": "2026-08-01T18:00:00", "address": "1 rue de la Paix", "zipcode": "75001", "city": "Paris", "isOnline": true, "isSecret": false}]'], - 'meta' => ['type' => 'object', 'example' => '{"page": 1, "limit": 20, "total": 5}'], - ], - 'statuses' => [ - 200 => 'Liste retournee', - 401 => 'Non authentifie', - ], - ], - [ - 'method' => 'GET', - 'path' => '/api/events/{id}', - 'summary' => 'Detail d\'un evenement', - 'description' => 'Retourne les informations completes d\'un evenement.', - 'headers' => $this->authHeaders(), - 'params' => [ - 'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement'], - ], - 'request' => null, - 'response' => [ - 'success' => ['type' => 'bool', 'example' => true], - 'data' => ['type' => 'object', 'example' => '{"id": 1, "title": "Brocante 2026", "description": "Grande brocante annuelle", "startAt": "2026-08-01T10:00:00", "endAt": "2026-08-01T18:00:00", "address": "1 rue", "zipcode": "75001", "city": "Paris", "isOnline": true, "isSecret": false, "imageUrl": "https://ticket.e-cosplay.fr/uploads/events/brocante.jpg"}'], - ], - 'statuses' => [ - 200 => 'Evenement retourne', - 401 => 'Non authentifie', - 403 => 'Evenement non accessible', - 404 => 'Evenement introuvable', - ], - ], - ], - ], - [ - 'name' => 'Categories', - 'description' => 'Categories de billets d\'un evenement.', - 'endpoints' => [ - [ - 'method' => 'GET', - 'path' => '/api/events/{id}/categories', - 'summary' => 'Liste des categories d\'un evenement', - 'description' => 'Retourne les categories avec leurs dates de vente et visibilite.', - 'headers' => $this->authHeaders(), - 'params' => [ - 'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement'], - ], - 'request' => null, - 'response' => [ - 'success' => ['type' => 'bool', 'example' => true], - 'data' => ['type' => 'array', 'example' => '[{"id": 1, "name": "General", "position": 0, "startAt": "2026-06-01T00:00:00", "endAt": "2026-08-01T18:00:00", "isHidden": false, "isActive": true}]'], - ], - 'statuses' => [ - 200 => 'Categories retournees', - 401 => 'Non authentifie', - 403 => 'Evenement non accessible', - 404 => 'Evenement introuvable', - ], - ], - ], - ], - [ - 'name' => 'Billets', - 'description' => 'Billets d\'une categorie et detail d\'un billet.', - 'endpoints' => [ - [ - 'method' => 'GET', - 'path' => '/api/categories/{id}/billets', - 'summary' => 'Liste des billets d\'une categorie', - 'description' => 'Retourne les billets d\'une categorie avec stock et quantite vendue.', - 'headers' => $this->authHeaders(), - 'params' => [ - 'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de la categorie'], - ], - 'request' => null, - 'response' => [ - 'success' => ['type' => 'bool', 'example' => true], - 'data' => ['type' => 'array', 'example' => '[{"id": 1, "name": "Entree", "priceHT": 1500, "quantity": 100, "sold": 42, "type": "billet", "isGeneratedBillet": true, "notBuyable": false, "position": 0}]'], - ], - 'statuses' => [ - 200 => 'Billets retournes', - 401 => 'Non authentifie', - 403 => 'Categorie non accessible', - 404 => 'Categorie introuvable', - ], - ], - [ - 'method' => 'GET', - 'path' => '/api/billets/{id}', - 'summary' => 'Detail d\'un billet', - 'description' => 'Retourne toutes les informations d\'un billet : nom, prix, quantite, type, description, image, categorie, evenement.', - 'headers' => $this->authHeaders(), - 'params' => [ - 'id' => ['type' => 'int', 'required' => true, 'description' => 'ID du billet'], - ], - 'request' => null, - 'response' => [ - 'success' => ['type' => 'bool', 'example' => true], - 'data' => ['type' => 'object', 'example' => '{"id": 1, "name": "Entree VIP", "description": "Acces VIP avec boissons incluses", "priceHT": 2500, "quantity": 50, "sold": 18, "type": "billet", "isGeneratedBillet": true, "hasDefinedExit": false, "notBuyable": false, "position": 0, "imageUrl": "https://ticket.e-cosplay.fr/uploads/billets/entree-vip.jpg", "category": {"id": 3, "name": "Premium"}, "event": {"id": 1, "title": "Brocante 2026"}}'], - ], - 'statuses' => [ - 200 => 'Billet retourne', - 401 => 'Non authentifie', - 403 => 'Billet non accessible', - 404 => 'Billet introuvable', - ], - ], - ], - ], - [ - 'name' => 'Scanner', - 'description' => 'Scan de billets pour l\'application mobile.', - 'endpoints' => [ - [ - 'method' => 'POST', - 'path' => '/api/scan', - 'summary' => 'Scanner un billet', - 'description' => 'Decode le QR code, verifie la reference et l\'etat du billet, le marque comme scanne si valide. Retourne "accepted" ou "refused" avec la raison. Gere la sortie definitive si activee.', - 'headers' => $this->authHeaders(), - 'params' => [], - 'request' => [ - 'reference' => ['type' => 'string', 'required' => true, 'example' => 'ETICKET-XXXX-XXXX-XXXX'], - ], - 'response' => [ - 'success' => ['type' => 'bool', 'example' => true], - 'data' => ['type' => 'object', 'example' => '{"state": "accepted", "reason": null, "reference": "ETICKET-XXXX-XXXX", "billetName": "Entree", "buyerFirstName": "Jean", "buyerLastName": "Dupont", "isInvitation": false, "firstScannedAt": "2026-03-23T14:30:00", "hasDefinedExit": true, "details": {"...": "donnees supplementaires (si accepted)"}}'], - ], - 'statuses' => [ - 200 => 'Scan traite (state: accepted ou refused)', - 401 => 'Non authentifie', - 404 => 'Billet introuvable', - ], - 'extra' => [ - 'title' => 'Raisons de refus possibles', - 'items' => [ - 'already_scanned' => 'Billet deja scanne', - 'invalid' => 'Billet invalide (annule ou rembourse)', - 'expired' => 'Billet expire', - 'exit_definitive' => 'Sortie definitive deja effectuee', - 'wrong_event' => 'Billet n\'appartient pas a cet evenement', - ], - ], - ], - ], - ], + $this->authSection(), + $this->eventsSection(), + $this->categoriesSection(), + $this->billetsSection(), + $this->scannerSection(), ]; } + /** @return array */ + private function authSection(): array + { + $tokenResponse = ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'object', 'example' => '{"token": "eyJ...", "expiresAt": "2026-03-24T12:00:00+00:00"}']]; + + return ['name' => 'Authentification', 'description' => 'Route commune aux environnements sandbox et live.', 'endpoints' => [ + ['method' => 'POST', 'path' => '/api/auth/login', 'summary' => 'Obtenir un token JWT', 'description' => 'Authentifie un organisateur et retourne un token JWT valable 24h.', 'headers' => [], 'params' => [], 'request' => ['email' => ['type' => 'string', 'required' => true, 'example' => 'orga@example.com'], 'password' => ['type' => 'string', 'required' => true, 'example' => '********']], 'response' => $tokenResponse, 'statuses' => [200 => 'Token genere avec succes', 401 => 'Identifiants invalides', 429 => 'Trop de tentatives']], + ['method' => 'GET', 'path' => '/api/auth/login/sso', 'summary' => 'Connexion via SSO (Keycloak)', 'description' => 'Redirige vers la page de connexion Keycloak.', 'headers' => [], 'params' => [], 'request' => null, 'response' => ['redirect' => ['type' => 'string', 'example' => '"https://auth.esy-web.dev/realms/..."']], 'statuses' => [302 => 'Redirection vers Keycloak']], + ['method' => 'GET', 'path' => '/api/auth/login/sso/validate', 'summary' => 'Callback SSO → JWT', 'description' => 'Echange le code OAuth contre un token JWT E-Ticket.', 'headers' => [], 'params' => ['code' => ['type' => 'string', 'required' => true, 'description' => 'Code OAuth'], 'state' => ['type' => 'string', 'required' => true, 'description' => 'State CSRF']], 'request' => null, 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'object', 'example' => '{"token": "eyJ...", "expiresAt": "...", "email": "orga@example.com"}']], 'statuses' => [200 => 'JWT genere via SSO', 401 => 'SSO echoue', 403 => 'Pas de compte organisateur']], + ['method' => 'POST', 'path' => '/api/auth/refresh', 'summary' => 'Rafraichir un token expire', 'description' => 'Genere un nouveau JWT a partir d\'un token expire avec signature valide.', 'headers' => $this->authHeaders(), 'params' => [], 'request' => null, 'response' => $tokenResponse, 'statuses' => [200 => 'Nouveau token genere', 400 => 'Token encore valide', 401 => 'Token invalide']], + ]]; + } + + /** @return array */ + private function eventsSection(): array + { + return ['name' => 'Evenements', 'description' => 'Liste et detail des evenements de l\'organisateur.', 'endpoints' => [ + ['method' => 'GET', 'path' => '/api/events', 'summary' => 'Liste des evenements', 'description' => 'Retourne tous les evenements de l\'organisateur authentifie.', 'headers' => $this->authHeaders(), 'params' => ['page' => ['type' => 'int', 'required' => false, 'default' => 1, 'description' => 'Page courante'], 'limit' => ['type' => 'int', 'required' => false, 'default' => 20, 'description' => 'Nombre par page (max 100)']], 'request' => null, 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'array', 'example' => '[{"id": 1, "title": "Brocante 2026", "startAt": "...", "endAt": "...", "city": "Paris", "isOnline": true}]'], 'meta' => ['type' => 'object', 'example' => '{"page": 1, "limit": 20, "total": 5}']], 'statuses' => [200 => 'Liste retournee', 401 => self::STATUS_401]], + ['method' => 'GET', 'path' => '/api/events/{id}', 'summary' => 'Detail d\'un evenement', 'description' => 'Retourne les informations completes d\'un evenement.', 'headers' => $this->authHeaders(), 'params' => ['id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement']], 'request' => null, 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'object', 'example' => '{"id": 1, "title": "Brocante", "description": "...", "city": "Paris", "imageUrl": "..."}']], 'statuses' => [200 => 'Evenement retourne', 401 => self::STATUS_401, 403 => self::STATUS_403_EVENT, 404 => self::STATUS_404_EVENT]], + ]]; + } + + /** @return array */ + private function categoriesSection(): array + { + return ['name' => 'Categories', 'description' => 'Categories de billets d\'un evenement.', 'endpoints' => [ + ['method' => 'GET', 'path' => '/api/events/{id}/categories', 'summary' => 'Liste des categories', 'description' => 'Retourne les categories avec dates de vente et visibilite.', 'headers' => $this->authHeaders(), 'params' => ['id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement']], 'request' => null, 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'array', 'example' => '[{"id": 1, "name": "General", "position": 0, "startAt": "...", "endAt": "...", "isHidden": false, "isActive": true}]']], 'statuses' => [200 => 'Categories retournees', 401 => self::STATUS_401, 403 => self::STATUS_403_EVENT, 404 => self::STATUS_404_EVENT]], + ]]; + } + + /** @return array */ + private function billetsSection(): array + { + return ['name' => 'Billets', 'description' => 'Billets d\'une categorie et detail d\'un billet.', 'endpoints' => [ + ['method' => 'GET', 'path' => '/api/categories/{id}/billets', 'summary' => 'Liste des billets', 'description' => 'Retourne les billets avec stock et quantite vendue.', 'headers' => $this->authHeaders(), 'params' => ['id' => ['type' => 'int', 'required' => true, 'description' => 'ID de la categorie']], 'request' => null, 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'array', 'example' => '[{"id": 1, "name": "Entree", "priceHT": 1500, "quantity": 100, "sold": 42, "type": "billet"}]']], 'statuses' => [200 => 'Billets retournes', 401 => self::STATUS_401, 403 => self::STATUS_403_CATEGORY, 404 => self::STATUS_404_CATEGORY]], + ['method' => 'GET', 'path' => '/api/billets/{id}', 'summary' => 'Detail d\'un billet', 'description' => 'Retourne toutes les informations avec image, categorie et evenement.', 'headers' => $this->authHeaders(), 'params' => ['id' => ['type' => 'int', 'required' => true, 'description' => 'ID du billet']], 'request' => null, 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'object', 'example' => '{"id": 1, "name": "Entree VIP", "priceHT": 2500, "imageUrl": "...", "category": {"id": 3, "name": "Premium"}, "event": {"id": 1, "title": "Brocante"}}']], 'statuses' => [200 => 'Billet retourne', 401 => self::STATUS_401, 403 => self::STATUS_403_BILLET, 404 => self::STATUS_404_BILLET]], + ]]; + } + + /** @return array */ + private function scannerSection(): array + { + return ['name' => 'Scanner', 'description' => 'Scan de billets pour l\'application mobile.', 'endpoints' => [ + ['method' => 'POST', 'path' => '/api/scan', 'summary' => 'Scanner un billet', 'description' => 'Verifie la reference et l\'etat du billet, le marque comme scanne si valide.', 'headers' => $this->authHeaders(), 'params' => [], 'request' => ['reference' => ['type' => 'string', 'required' => true, 'example' => 'ETICKET-XXXX-XXXX-XXXX']], 'response' => ['success' => ['type' => 'bool', 'example' => true], 'data' => ['type' => 'object', 'example' => '{"state": "accepted", "reason": null, "reference": "ETICKET-XXXX", "billetName": "Entree", "buyerFirstName": "Jean", "buyerLastName": "Dupont", "details": {...}}']], 'statuses' => [200 => 'Scan traite (accepted/refused)', 401 => self::STATUS_401, 404 => self::STATUS_404_BILLET], 'extra' => ['title' => 'Raisons de refus possibles', 'items' => ['already_scanned' => 'Billet deja scanne', 'invalid' => 'Billet invalide (annule ou rembourse)', 'expired' => 'Billet expire', 'exit_definitive' => 'Sortie definitive deja effectuee', 'wrong_event' => 'Billet n\'appartient pas a cet evenement']]], + ]]; + } + /** * @return list */ diff --git a/tests/js/api-env-switcher.test.js b/tests/js/api-env-switcher.test.js new file mode 100644 index 0000000..1b21ab4 --- /dev/null +++ b/tests/js/api-env-switcher.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { initApiEnvSwitcher } from '../../assets/modules/api-env-switcher.js' + +describe('initApiEnvSwitcher', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + it('does nothing without env-switcher', () => { + expect(() => initApiEnvSwitcher()).not.toThrow() + }) + + it('switches to live environment', () => { + document.body.innerHTML = ` +
+ + +
+
+

https://example.com/api/sandbox

+
+

Sandbox desc

+ /api/sandbox + /api/sandbox + ` + initApiEnvSwitcher() + + document.querySelector('[data-env="live"]').click() + + expect(document.getElementById('env-base-url').textContent).toBe('https://example.com/api/live') + expect(document.getElementById('env-description').textContent).toContain('production') + + const prefixes = document.querySelectorAll('.api-env-prefix') + prefixes.forEach(el => { + expect(el.textContent).toBe('/api/live') + expect(el.className).toContain('text-green-400') + }) + }) + + it('switches back to sandbox', () => { + document.body.innerHTML = ` +
+ + +
+
+

https://test.com/api/live

+
+

Live desc

+ /api/live + ` + initApiEnvSwitcher() + + document.querySelector('[data-env="sandbox"]').click() + + expect(document.getElementById('env-base-url').textContent).toBe('https://test.com/api/sandbox') + expect(document.querySelector('.api-env-prefix').textContent).toBe('/api/sandbox') + }) + + it('uses location.origin when no data-host', () => { + document.body.innerHTML = ` +
+ + +
+

+

+ ` + initApiEnvSwitcher() + + document.querySelector('[data-env="live"]').click() + + expect(document.getElementById('env-base-url').textContent).toContain('/api/live') + }) +}) diff --git a/translations/messages.fr.yaml b/translations/messages.fr.yaml new file mode 100644 index 0000000..efe2c0c --- /dev/null +++ b/translations/messages.fr.yaml @@ -0,0 +1 @@ +test_1: Bonjour