Add SSO E-Cosplay (Keycloak OIDC) and dynamic navbar active state

- Install knpuniversity/oauth2-client-bundle and stevenmaguire/oauth2-keycloak
- Register KnpUOAuth2ClientBundle in bundles.php
- Configure Keycloak OIDC client (realm e-cosplay, auth.esy-web.dev)
- Add keycloakId field to User entity with migration
- Create KeycloakAuthenticator with group-to-role mapping (/superadmin -> ROLE_ROOT)
- Create OAuthController with SSO routes (/connection/sso/login, logout, check)
- Add custom_authenticator to security firewall with form_login entry point
- Add auth.esy-web.dev to nelmio external_redirects whitelist and CSP form-action
- Add SSO button and error flash messages to login page
- Make navbar active state dynamic based on current route (desktop + mobile)
- Add Keycloak env vars to .env, .env.local, and ansible/env.local.j2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-19 10:38:19 +01:00
parent 28763e7ee1
commit 2405fcc2da
15 changed files with 557 additions and 5 deletions

7
.env
View File

@@ -47,6 +47,13 @@ STRIPE_WEBHOOK_SECRET=
STRIPE_MODE=test
SMIME_PASSPHRASE='KLreLnyR07x5h#3$AC'
###> SSO E-Cosplay (Keycloak OIDC) ###
OAUTH_KEYCLOAK_CLIENT_ID=e-ticket
OAUTH_KEYCLOAK_CLIENT_SECRET=changeme
OAUTH_KEYCLOAK_URL=https://auth.esy-web.dev
OAUTH_KEYCLOAK_REALM=e-cosplay
###< SSO E-Cosplay (Keycloak OIDC) ###
###> s3/minio ###
S3_ENDPOINT=http://minio:9000
S3_ACCESS_KEY=e-ticket

View File

@@ -19,3 +19,7 @@ STRIPE_MODE=live
SMIME_PASSPHRASE='{{ smime_passphrase }}'
MEILISEARCH_URL=http://meilisearch:7700
MEILISEARCH_API_KEY={{ meilisearch_api_key }}
OAUTH_KEYCLOAK_CLIENT_ID=e-ticket
OAUTH_KEYCLOAK_CLIENT_SECRET=1oLwbhJDNVmGH8CES1OdQtzR7dECOlII
OAUTH_KEYCLOAK_URL=https://auth.esy-web.dev
OAUTH_KEYCLOAK_REALM=e-cosplay

View File

@@ -12,6 +12,7 @@
"doctrine/orm": "^3.6",
"dompdf/dompdf": "*",
"endroid/qr-code-bundle": "*",
"knpuniversity/oauth2-client-bundle": "^2.20",
"league/flysystem-aws-s3-v3": "^3.32",
"league/flysystem-bundle": "^3.6",
"liip/imagine-bundle": "^2.17",
@@ -19,6 +20,7 @@
"nelmio/security-bundle": "^3.9",
"phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3",
"stevenmaguire/oauth2-keycloak": "^6.1",
"stripe/stripe-php": "*",
"symfony/asset": "8.0.*",
"symfony/console": "8.0.*",

252
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "a708b901e0ade836caa13855beebd9d1",
"content-hash": "ecb55cf346fc28e16c4caec521a016e2",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -1872,6 +1872,69 @@
],
"time": "2025-12-01T22:03:15+00:00"
},
{
"name": "firebase/php-jwt",
"version": "v7.0.3",
"source": {
"type": "git",
"url": "https://github.com/firebase/php-jwt.git",
"reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e",
"reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/firebase/php-jwt/issues",
"source": "https://github.com/firebase/php-jwt/tree/v7.0.3"
},
"time": "2026-02-25T22:16:40+00:00"
},
{
"name": "guzzlehttp/guzzle",
"version": "7.10.0",
@@ -2324,6 +2387,66 @@
},
"time": "2025-11-30T20:12:26+00:00"
},
{
"name": "knpuniversity/oauth2-client-bundle",
"version": "v2.20.2",
"source": {
"type": "git",
"url": "https://github.com/knpuniversity/oauth2-client-bundle.git",
"reference": "9ce4fcea69dbbf4d19ee7368b8d623ec2d73d3c7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/knpuniversity/oauth2-client-bundle/zipball/9ce4fcea69dbbf4d19ee7368b8d623ec2d73d3c7",
"reference": "9ce4fcea69dbbf4d19ee7368b8d623ec2d73d3c7",
"shasum": ""
},
"require": {
"league/oauth2-client": "^2.0",
"php": ">=8.1",
"symfony/dependency-injection": "^6.4|^7.3|^8.0",
"symfony/framework-bundle": "^6.4|^7.3|^8.0",
"symfony/http-foundation": "^6.4|^7.3|^8.0",
"symfony/routing": "^6.4|^7.3|^8.0",
"symfony/security-core": "^6.4|^7.3|^8.0",
"symfony/security-http": "^6.4|^7.3|^8.0"
},
"require-dev": {
"league/oauth2-facebook": "^1.1|^2.0",
"symfony/phpunit-bridge": "^7.3",
"symfony/yaml": "^6.4|^7.3|^8.0"
},
"suggest": {
"symfony/security-guard": "For integration with Symfony's Guard Security layer"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"KnpU\\OAuth2ClientBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ryan Weaver",
"email": "ryan@symfonycasts.com"
}
],
"description": "Integration with league/oauth2-client to provide services",
"homepage": "https://symfonycasts.com",
"keywords": [
"oauth",
"oauth2"
],
"support": {
"issues": "https://github.com/knpuniversity/oauth2-client-bundle/issues",
"source": "https://github.com/knpuniversity/oauth2-client-bundle/tree/v2.20.2"
},
"time": "2026-02-12T17:07:18+00:00"
},
{
"name": "league/flysystem",
"version": "3.32.0",
@@ -2637,6 +2760,71 @@
],
"time": "2024-09-21T08:32:55+00:00"
},
{
"name": "league/oauth2-client",
"version": "2.9.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/oauth2-client.git",
"reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e",
"reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/guzzle": "^6.5.8 || ^7.4.5",
"php": "^7.1 || >=8.0.0 <8.6.0"
},
"require-dev": {
"mockery/mockery": "^1.3.5",
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.11"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\OAuth2\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alex Bilbie",
"email": "hello@alexbilbie.com",
"homepage": "http://www.alexbilbie.com",
"role": "Developer"
},
{
"name": "Woody Gilk",
"homepage": "https://github.com/shadowhand",
"role": "Contributor"
}
],
"description": "OAuth 2.0 Client Library",
"keywords": [
"Authentication",
"SSO",
"authorization",
"identity",
"idp",
"oauth",
"oauth2",
"single sign on"
],
"support": {
"issues": "https://github.com/thephpleague/oauth2-client/issues",
"source": "https://github.com/thephpleague/oauth2-client/tree/2.9.0"
},
"time": "2025-11-25T22:17:17+00:00"
},
{
"name": "liip/imagine-bundle",
"version": "2.17.1",
@@ -4036,6 +4224,68 @@
},
"time": "2026-03-03T17:31:43+00:00"
},
{
"name": "stevenmaguire/oauth2-keycloak",
"version": "6.1.0",
"source": {
"type": "git",
"url": "https://github.com/stevenmaguire/oauth2-keycloak.git",
"reference": "459cc58576d37f5823de2a677a7b17667b85ba7f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stevenmaguire/oauth2-keycloak/zipball/459cc58576d37f5823de2a677a7b17667b85ba7f",
"reference": "459cc58576d37f5823de2a677a7b17667b85ba7f",
"shasum": ""
},
"require": {
"firebase/php-jwt": "^7.0",
"league/oauth2-client": "^2.8",
"php": "^8.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^1.12",
"phpunit/phpunit": "~9.6.4",
"squizlabs/php_codesniffer": "~3.7.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Stevenmaguire\\OAuth2\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Steven Maguire",
"email": "stevenmaguire@gmail.com",
"homepage": "https://github.com/stevenmaguire"
}
],
"description": "Keycloak OAuth 2.0 Client Provider for The PHP League OAuth2-Client",
"keywords": [
"authorisation",
"authorization",
"client",
"keycloak",
"oauth",
"oauth2"
],
"support": {
"issues": "https://github.com/stevenmaguire/oauth2-keycloak/issues",
"source": "https://github.com/stevenmaguire/oauth2-keycloak/tree/6.1.0"
},
"time": "2026-03-03T11:50:29+00:00"
},
{
"name": "stripe/stripe-php",
"version": "v19.4.1",

View File

@@ -16,4 +16,5 @@ return [
Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true],
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
Nelmio\SecurityBundle\NelmioSecurityBundle::class => ['all' => true],
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,12 @@
knpu_oauth2_client:
clients:
keycloak:
type: generic
provider_class: Stevenmaguire\OAuth2\Client\Provider\Keycloak
client_id: '%env(OAUTH_KEYCLOAK_CLIENT_ID)%'
client_secret: '%env(OAUTH_KEYCLOAK_CLIENT_SECRET)%'
redirect_route: app_oauth_keycloak_check
redirect_params: {}
provider_options:
authServerUrl: '%env(OAUTH_KEYCLOAK_URL)%'
realm: '%env(OAUTH_KEYCLOAK_REALM)%'

View File

@@ -51,6 +51,9 @@ nelmio_security:
- 'https://fonts.gstatic.com'
object-src:
- 'none'
form-action:
- 'self'
- 'https://auth.esy-web.dev'
block-all-mixed-content: true
permissions_policy:
@@ -71,3 +74,4 @@ nelmio_security:
- stripe.com
- checkout.stripe.com
- hooks.stripe.com
- auth.esy-web.dev

View File

@@ -19,6 +19,9 @@ security:
login_path: app_login
check_path: app_login
enable_csrf: true
custom_authenticators:
- App\Security\KeycloakAuthenticator
entry_point: form_login
logout:
path: app_logout
target: app_home

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260319093607 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE messenger_log (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, message_class VARCHAR(255) NOT NULL, message_body TEXT DEFAULT NULL, status VARCHAR(20) NOT NULL, error_message TEXT DEFAULT NULL, stack_trace TEXT DEFAULT NULL, transport_name VARCHAR(255) DEFAULT NULL, retry_count INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, failed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX idx_messenger_log_status ON messenger_log (status)');
$this->addSql('CREATE INDEX idx_messenger_log_created_at ON messenger_log (created_at)');
$this->addSql('ALTER TABLE "user" ADD keycloak_id VARCHAR(255) DEFAULT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649491914B1 ON "user" (keycloak_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE messenger_log');
$this->addSql('DROP INDEX UNIQ_8D93D649491914B1');
$this->addSql('ALTER TABLE "user" DROP keycloak_id');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Controller;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Attribute\Route;
class OAuthController extends AbstractController
{
#[Route('/connection/sso/login', name: 'app_oauth_keycloak')]
public function connect(ClientRegistry $clientRegistry): RedirectResponse
{
return $clientRegistry->getClient('keycloak')->redirect(['openid', 'email', 'profile']);
}
#[Route('/connection/sso/logout', name: 'app_oauth_keycloak_logout')]
public function logout(): RedirectResponse
{
return $this->redirectToRoute('app_home');
}
#[Route('/connection/sso/check', name: 'app_oauth_keycloak_check')]
public function check(): void
{
// Handled by KeycloakAuthenticator
}
}

View File

@@ -34,6 +34,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column]
private ?string $password = null;
#[ORM\Column(length: 255, unique: true, nullable: true)]
private ?string $keycloakId = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
@@ -122,6 +125,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this->createdAt;
}
public function getKeycloakId(): ?string
{
return $this->keycloakId;
}
public function setKeycloakId(?string $keycloakId): static
{
$this->keycloakId = $keycloakId;
return $this;
}
public function eraseCredentials(): void
{
// Required by UserInterface — no temporary credentials to clear

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Security;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class KeycloakAuthenticator extends OAuth2Authenticator
{
public function __construct(
private ClientRegistry $clientRegistry,
private EntityManagerInterface $em,
private RouterInterface $router,
) {
}
public function supports(Request $request): ?bool
{
return 'app_oauth_keycloak_check' === $request->attributes->get('_route');
}
private const GROUP_ROLE_MAP = [
'/superadmin' => 'ROLE_ROOT',
];
public function authenticate(Request $request): Passport
{
$client = $this->clientRegistry->getClient('keycloak');
$accessToken = $this->fetchAccessToken($client);
return new SelfValidatingPassport(
new UserBadge($accessToken->getToken(), function () use ($accessToken, $client) {
$keycloakUser = $client->fetchUserFromToken($accessToken);
$keycloakId = $keycloakUser->getId();
$email = $keycloakUser->getEmail();
$data = $keycloakUser->toArray();
$roles = $this->mapGroupsToRoles($data['groups'] ?? []);
$user = $this->em->getRepository(User::class)->findOneBy(['keycloakId' => $keycloakId]);
if ($user) {
$user->setEmail($email);
$user->setFirstName($data['given_name'] ?? $user->getFirstName());
$user->setLastName($data['family_name'] ?? $user->getLastName());
$user->setRoles($roles);
$this->em->flush();
return $user;
}
$existingUser = $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
if ($existingUser) {
$existingUser->setKeycloakId($keycloakId);
$existingUser->setRoles($roles);
$this->em->flush();
return $existingUser;
}
$newUser = new User();
$newUser->setKeycloakId($keycloakId);
$newUser->setEmail($email);
$newUser->setFirstName($data['given_name'] ?? '');
$newUser->setLastName($data['family_name'] ?? '');
$newUser->setPassword('');
$newUser->setRoles($roles);
$this->em->persist($newUser);
$this->em->flush();
return $newUser;
}),
);
}
/**
* @param list<string> $groups
*
* @return list<string>
*/
private function mapGroupsToRoles(array $groups): array
{
$roles = [];
foreach ($groups as $group) {
if (isset(self::GROUP_ROLE_MAP[$group])) {
$roles[] = self::GROUP_ROLE_MAP[$group];
}
}
return $roles;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new RedirectResponse($this->router->generate('app_account'));
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$request->getSession()->getFlashBag()->add('error', 'Echec de la connexion SSO E-Cosplay.');
return new RedirectResponse($this->router->generate('app_login'));
}
}

View File

@@ -50,6 +50,15 @@
".php-cs-fixer.dist.php"
]
},
"knpuniversity/oauth2-client-bundle": {
"version": "2.20",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.20",
"ref": "1ff300d8c030f55c99219cc55050b97a695af3f6"
}
},
"league/flysystem-bundle": {
"version": "3.6",
"recipe": {

View File

@@ -82,9 +82,10 @@
</div>
<div class="hidden lg:flex items-center space-x-1">
<a href="{{ path('app_home') }}" itemprop="url" class="px-3 py-2 text-xs font-black uppercase tracking-widest transition-all bg-yellow-400 border-2 border-gray-900 shadow-[2px_2px_0px_rgba(0,0,0,1)]"><span itemprop="name">Accueil</span></a>
{% set current_route = app.request.attributes.get('_route') %}
<a href="{{ path('app_home') }}" itemprop="url" class="px-3 py-2 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_home' ? 'bg-yellow-400 border-2 border-gray-900 shadow-[2px_2px_0px_rgba(0,0,0,1)]' : 'hover:text-indigo-600' }}"><span itemprop="name">Accueil</span></a>
<a href="#" itemprop="url" class="px-3 py-2 text-xs font-black uppercase tracking-widest transition-all hover:text-indigo-600"><span itemprop="name">Evenements</span></a>
<a href="{{ path('app_contact') }}" itemprop="url" class="px-3 py-2 text-xs font-black uppercase tracking-widest transition-all hover:text-indigo-600"><span itemprop="name">Contact</span></a>
<a href="{{ path('app_contact') }}" itemprop="url" class="px-3 py-2 text-xs font-black uppercase tracking-widest transition-all {{ current_route == 'app_contact' ? 'bg-yellow-400 border-2 border-gray-900 shadow-[2px_2px_0px_rgba(0,0,0,1)]' : 'hover:text-indigo-600' }}"><span itemprop="name">Contact</span></a>
</div>
<div class="flex items-center space-x-4 border-l-4 border-gray-900 pl-6 h-full">
@@ -110,9 +111,9 @@
<div id="mobile-menu" class="hidden lg:hidden border-t-4 border-gray-900 bg-white" role="menu">
<div class="p-4 space-y-2 uppercase font-black italic">
<a href="{{ path('app_home') }}" class="block p-3 border-2 border-transparent hover:border-gray-900 hover:bg-gray-50" role="menuitem">Accueil</a>
<a href="{{ path('app_home') }}" class="block p-3 border-2 {{ current_route == 'app_home' ? 'border-gray-900 bg-yellow-400' : 'border-transparent hover:border-gray-900 hover:bg-gray-50' }}" role="menuitem">Accueil</a>
<a href="#" class="block p-3 border-2 border-transparent hover:border-gray-900 hover:bg-gray-50" role="menuitem">Evenements</a>
<a href="{{ path('app_contact') }}" class="block p-3 border-2 border-transparent hover:border-gray-900 hover:bg-gray-50" role="menuitem">Contact</a>
<a href="{{ path('app_contact') }}" class="block p-3 border-2 {{ current_route == 'app_contact' ? 'border-gray-900 bg-yellow-400' : 'border-transparent hover:border-gray-900 hover:bg-gray-50' }}" role="menuitem">Contact</a>
{% if app.user %}
<a href="{{ path('app_account') }}" class="block p-3 border-2 border-transparent hover:border-gray-900 hover:bg-gray-50" role="menuitem">Mon espace</a>
{% else %}

View File

@@ -1,7 +1,68 @@
{% extends 'base.html.twig' %}
{% block title %}Connexion - E-Ticket{% endblock %}
{% block description %}Connectez-vous a votre compte E-Ticket pour gerer vos evenements et billets{% endblock %}
{% block body %}
<div style="max-width:28rem;margin:0 auto;padding:3rem 1rem;">
<h1 class="text-3xl font-black uppercase tracking-tighter italic" style="border-bottom:4px solid #111827;display:inline-block;margin-bottom:0.5rem;">Connexion</h1>
<p class="font-bold text-gray-600 italic" style="margin-bottom:2rem;">Accedez a votre espace.</p>
{% if error %}
<div style="border:4px solid #111827;padding:1rem 1.5rem;margin-bottom:2rem;background:#fee2e2;box-shadow:4px 4px 0 rgba(0,0,0,1);">
<p class="font-black text-sm">{{ error.messageKey|trans(error.messageData, 'security') }}</p>
</div>
{% endif %}
{% for message in app.flashes('error') %}
<div style="border:4px solid #111827;padding:1rem 1.5rem;margin-bottom:2rem;background:#fee2e2;box-shadow:4px 4px 0 rgba(0,0,0,1);">
<p class="font-black text-sm">{{ message }}</p>
</div>
{% endfor %}
<form method="post" action="{{ path('app_login') }}" style="display:flex;flex-direction:column;gap:1.5rem;">
<div>
<label for="login_email" class="text-xs font-black uppercase tracking-widest" style="display:block;margin-bottom:0.5rem;">Email</label>
<input type="email" id="login_email" name="_username" required autofocus
value="{{ last_username }}"
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;"
class="focus:border-indigo-600"
placeholder="jean.dupont@exemple.fr">
</div>
<div>
<label for="login_password" class="text-xs font-black uppercase tracking-widest" style="display:block;margin-bottom:0.5rem;">Mot de passe</label>
<input type="password" id="login_password" name="_password" required
style="width:100%;padding:0.75rem 1rem;border:3px solid #111827;font-weight:700;outline:none;"
class="focus:border-indigo-600"
placeholder="••••••••">
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<div>
<button type="submit"
style="width:100%;padding:0.75rem 2rem;border:3px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);cursor:pointer;"
class="bg-yellow-400 font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
Se connecter
</button>
</div>
</form>
<div style="display:flex;align-items:center;gap:1rem;margin-top:2rem;">
<div style="flex:1;height:3px;background:#111827;"></div>
<span class="text-xs font-black uppercase tracking-widest text-gray-500">ou</span>
<div style="flex:1;height:3px;background:#111827;"></div>
</div>
<a href="{{ path('app_oauth_keycloak') }}"
style="display:block;width:100%;padding:0.75rem 2rem;border:3px solid #111827;box-shadow:4px 4px 0 rgba(0,0,0,1);text-align:center;text-decoration:none;margin-top:1.5rem;"
class="bg-gray-900 text-white font-black uppercase text-sm tracking-widest hover:bg-indigo-600 transition-all">
Se connecter avec E-Cosplay
</a>
<div style="margin-top:2rem;text-align:center;">
<p class="text-sm font-bold text-gray-600">Pas encore de compte ? <a href="{{ path('app_register') }}" class="text-indigo-600 hover:underline font-black">Inscription</a></p>
</div>
</div>
{% endblock %}