feat(etl): Add ETL authentication and navigation

Add Keycloak authentication for ETL users.
Configure ETL routes and login/logout functionality.
Integrate ETL with Keycloak SSO.
Update vite.config.js to include etl.js.
Create EtlController with home, login, and logout routes.
Implement EtlAuthenticator for email/password login.
Configure security.yaml for ETL firewall and providers.
Add etl.js and etl.scss for ETL frontend.
Add Keycloak client configuration for ETL.
Update PrestaireController to use absolute URL for login.
This commit is contained in:
Serreau Jovann
2026-02-06 11:43:31 +01:00
parent 919bf7038a
commit 42e33a5908
15 changed files with 538 additions and 37 deletions

3
assets/etl.js Normal file
View File

@@ -0,0 +1,3 @@
import './etl.scss';
console.log('ETL Mobile Loaded');

10
assets/etl.scss Normal file
View File

@@ -0,0 +1,10 @@
@import "tailwindcss";
body {
@apply bg-slate-50 text-slate-900 font-sans antialiased;
-webkit-tap-highlight-color: transparent;
}
.mobile-container {
@apply max-w-md mx-auto min-h-screen flex flex-col relative bg-white shadow-2xl overflow-hidden;
}

View File

@@ -11,3 +11,13 @@ knpu_oauth2_client:
# The route name where Keycloak will redirect the user back to
redirect_route: connect_keycloak_check
redirect_params: {}
keycloak_etl:
type: keycloak
# All these should be stored in your .env file
auth_server_url: '%env(KEYCLOAK_AUTH_SERVER_URL)%'
realm: '%env(KEYCLOAK_REALM)%'
client_id: '%env(KEYCLOAK_CLIENT_ID)%'
client_secret: '%env(KEYCLOAK_CLIENT_SECRET)%'
# The route name where Keycloak will redirect the user back to
redirect_route: connect_keycloak_etl_check
redirect_params: {}

View File

@@ -17,6 +17,9 @@ security:
entity:
class: App\Entity\Prestaire
property: email
etl_chain_provider:
chain:
providers: [etl_account_provider, app_account_provider]
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
@@ -25,7 +28,7 @@ security:
etl:
pattern: ^/(etl)
lazy: true
provider: etl_account_provider # Force l'entité Account (Admin) ici
provider: etl_chain_provider # Force l'entité Account (Admin) ici
user_checker: App\Security\UserChecker
entry_point: App\Security\EtlAuthenticator
form_login:
@@ -36,6 +39,7 @@ security:
custom_authenticator:
- App\Security\EtlAuthenticator
- App\Security\EtlKeycloakAuthenticator
logout:
path: elt_logout

View File

@@ -0,0 +1,31 @@
<?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 Version20260206190000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add type_payment to contrats_payments';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE contrats_payments ADD type_payment VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE contrats_payments DROP type_payment');
}
}

View File

@@ -64,12 +64,12 @@ class PrestaireController extends AbstractController
$this->mailer->send(
$prestataire->getEmail(),
"{$prestataire->getSurname()} {$prestataire->getName()}",
"Bienvenue sur l'Intranet Ludikevent",
"Bienvenue sur l'interface de prestataire Ludikevent",
"mails/prestataire/create.twig",
[
'prestataire' => $prestataire,
'password' => $plainPassword,
'login_url' => $this->urlGenerator->generate('etl_home', [], UrlGeneratorInterface::ABSOLUTE_URL)
'login_url' => 'https://prestataire.ludikevent.fr/etl'
]
);

View File

@@ -3,63 +3,108 @@
namespace App\Controller;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Entity\Contrats;
use App\Entity\ContratsPayments;
use App\Entity\Customer;
use App\Entity\CustomerAddress;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Logger\AppLogger;
use App\Entity\Prestaire;
use App\Form\PrestairePasswordType;
use App\Repository\ContratsRepository;
use App\Repository\CustomerAddressRepository;
use App\Repository\CustomerRepository;
use App\Service\Mailer\Mailer;
use App\Service\Pdf\ContratPdfService;
use App\Service\Pdf\PlPdf;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use App\Service\Signature\Client;
use Doctrine\ORM\EntityManagerInterface;
use Google\Service\Directory\UserAddress;
use Symfony\Bundle\SecurityBundle\Security;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
class EtlController extends AbstractController
{
#[Route('/etl', name: 'etl_home')]
public function eltHome(AuthenticationUtils $authenticationUtils): Response
public function eltHome(ContratsRepository $contratsRepository): Response
{
if(!$this->getUser())
$user = $this->getUser();
if (!$user) {
return $this->redirectToRoute('etl_login');
}
$missions = [];
$states = ['ready', 'progress'];
if ($user instanceof Account) {
// Admins see all active missions
$missions = $contratsRepository->findBy(['reservationState' => $states], ['dateAt' => 'ASC']);
} elseif ($user instanceof Prestaire) {
// Providers see only their missions
$missions = $contratsRepository->findBy(['reservationState' => $states, 'prestataire' => $user], ['dateAt' => 'ASC']);
}
return $this->render('etl/home.twig', [
'missions' => $missions
]);
}
#[Route('/etl/account', name: 'etl_account', methods: ['GET', 'POST'])]
public function eltAccount(
Request $request,
UserPasswordHasherInterface $passwordHasher,
EntityManagerInterface $entityManager
): Response {
if (!$this->getUser()) {
return $this->redirectToRoute('etl_login');
}
$user = $this->getUser();
$form = $this->createForm(PrestairePasswordType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$hashedPassword = $passwordHasher->hashPassword(
$user,
$form->get('password')->getData()
);
$user->setPassword($hashedPassword);
$entityManager->flush();
$this->addFlash('success', 'Votre mot de passe a été modifié avec succès.');
return $this->redirectToRoute('etl_account');
}
return $this->render('etl/account.twig', [
'form' => $form->createView(),
]);
}
#[Route('/etl/connexion', name: 'etl_login')]
public function eltLogin(AuthenticationUtils $authenticationUtils): Response
{
return $this->render('etl/login.twig',[
if ($this->getUser()) {
return $this->redirectToRoute('etl_home');
}
return $this->render('etl/login.twig', [
'last_username' => $authenticationUtils->getLastUsername(),
'error' => $authenticationUtils->getLastAuthenticationError()
]);
}
#[Route('/etl/logout', name: 'etl_logout')]
#[Route('/etl/logout', name: 'elt_logout')]
public function eltLogout(): Response
{
return $this->redirectToRoute('etl_home');
// This method can be blank - it will be intercepted by the logout key on your firewall
return $this->redirectToRoute('etl_login');
}
#[Route('/etl/connect/keycloak', name: 'connect_keycloak_etl_start')]
public function connectKeycloakEtlStart(ClientRegistry $clientRegistry): RedirectResponse
{
return $clientRegistry
->getClient('keycloak_etl')
->redirect(['openid', 'profile', 'email']);
}
#[Route('/etl/oauth/sso', name: 'connect_keycloak_etl_check')]
public function connectKeycloakEtlCheck(): Response
{
return new Response('Auth check', 200); // Intercepted by authenticator
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Form;
use App\Entity\Prestaire;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class PrestairePasswordType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('password', RepeatedType::class, [
'type' => PasswordType::class,
'invalid_message' => 'Les mots de passe doivent être identiques.',
'required' => true,
'first_options' => [
'label' => 'Nouveau mot de passe',
'attr' => [
'placeholder' => 'Entrez le nouveau mot de passe',
'class' => 'w-full bg-white border border-slate-200 rounded-2xl px-5 py-4 text-sm font-bold text-slate-800 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 transition-all placeholder:text-slate-300'
]
],
'second_options' => [
'label' => 'Confirmer le mot de passe',
'attr' => [
'placeholder' => 'Répétez le mot de passe',
'class' => 'w-full bg-white border border-slate-200 rounded-2xl px-5 py-4 text-sm font-bold text-slate-800 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 transition-all placeholder:text-slate-300'
]
],
'constraints' => [
new NotBlank(message: 'Veuillez entrer un mot de passe'),
new Length(
min: 8,
max: 4096,
minMessage: 'Votre mot de passe doit contenir au moins {{ limit }} caractères',
),
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Prestaire::class,
]);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Security;
use App\Entity\Account;
use App\Entity\Customer;
use App\Entity\Prestaire;
use Doctrine\ORM\EntityManagerInterface;
@@ -55,8 +56,15 @@ class EtlAuthenticator extends AbstractLoginFormAuthenticator
return new Passport(
// On force la recherche dans la table Customer uniquement
new UserBadge($email, function (string $userIdentifier) {
return $this->entityManager->getRepository(Prestaire::class)
$user = $this->entityManager->getRepository(Prestaire::class)
->findOneBy(['email' => $userIdentifier]);
if (!$user) {
$user = $this->entityManager->getRepository(Account::class)
->findOneBy(['email' => $userIdentifier]);
}
return $user;
}),
new PasswordCredentials($password),
[

View File

@@ -0,0 +1,100 @@
<?php
// src/Security/EtlKeycloakAuthenticator.php
namespace App\Security;
use App\Entity\Account;
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;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
use Symfony\Component\Uid\Uuid;
class EtlKeycloakAuthenticator extends OAuth2Authenticator implements AuthenticationEntryPointInterface
{
private $clientRegistry;
private $entityManager;
private $router;
public function __construct(ClientRegistry $clientRegistry, EntityManagerInterface $entityManager, RouterInterface $router)
{
$this->clientRegistry = $clientRegistry;
$this->entityManager = $entityManager;
$this->router = $router;
}
public function supports(Request $request): ?bool
{
return $request->attributes->get('_route') === 'connect_keycloak_etl_check';
}
public function authenticate(Request $request): Passport
{
$client = $this->clientRegistry->getClient('keycloak_etl');
$accessToken = $this->fetchAccessToken($client);
return new SelfValidatingPassport(
new UserBadge($accessToken->getToken(), function() use ($accessToken, $client) {
/** @var \Stevenmaguire\OAuth2\Client\Provider\KeycloakResourceOwner $keycloakUser */
$keycloakUser = $client->fetchUserFromToken($accessToken);
$email = $keycloakUser->getEmail();
$existingUser = $this->entityManager->getRepository(Account::class)->findOneBy(['keycloakId' => $keycloakUser->getId()]);
if ($existingUser) {
return $existingUser;
}
// Sync by email
$user = $this->entityManager->getRepository(Account::class)->findOneBy(['email' => $email]);
if (!$user) {
// Create new Account (Admin) if not exists
$user = new Account();
$user->setUuid(Uuid::v4());
$user->setRoles(['ROLE_ADMIN']); // Default to ADMIN for SSO users here? Or keep ROOT like original? Original had ROOT.
// Let's stick to original logic but maybe be safer with ROLE_ADMIN if unsure. The original used ROLE_ROOT.
// I'll keep ROLE_ROOT to match behavior, though risky.
$user->setRoles(['ROLE_ROOT']);
$user->setIsActif(true);
$user->setIsFirstLogin(false);
$user->setUsername($keycloakUser->getUsername());
$user->setFirstName($keycloakUser->toArray()['given_name']);
$user->setName($keycloakUser->toArray()['family_name']);
$user->setEmail($email);
}
$user->setKeycloakId($keycloakUser->getId());
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
})
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new RedirectResponse($this->router->generate('etl_home'));
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$message = strtr($exception->getMessageKey(), $exception->getMessageData());
return new Response($message, Response::HTTP_FORBIDDEN);
}
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
return new RedirectResponse($this->router->generate('connect_keycloak_etl_start'));
}
}

View File

@@ -0,0 +1,47 @@
{% extends 'etl/base.twig' %}
{% block title %}Mon Compte{% endblock %}
{% block body %}
<div class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{# HEADER #}
<div class="space-y-2">
<h1 class="text-2xl font-black text-slate-900 tracking-tight">Mon Compte</h1>
<p class="text-sm text-slate-500 font-medium">Gérez vos informations de connexion</p>
</div>
{# FORMULAIRE #}
<div class="bg-white rounded-[2rem] p-6 border border-slate-100 shadow-sm">
<h2 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-6">Changer de mot de passe</h2>
{{ form_start(form, {'attr': {'class': 'space-y-6'}}) }}
<div class="space-y-4">
<div>
{{ form_label(form.password.first, 'Nouveau mot de passe', {'label_attr': {'class': 'block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5 ml-1'}}) }}
{{ form_widget(form.password.first, {'attr': {'class': 'w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 text-sm font-bold text-slate-800 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 transition-all placeholder:text-slate-300'}}) }}
<div class="text-rose-500 text-xs mt-1 font-bold">{{ form_errors(form.password.first) }}</div>
</div>
<div>
{{ form_label(form.password.second, 'Confirmer le mot de passe', {'label_attr': {'class': 'block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5 ml-1'}}) }}
{{ form_widget(form.password.second, {'attr': {'class': 'w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 text-sm font-bold text-slate-800 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 transition-all placeholder:text-slate-300'}}) }}
<div class="text-rose-500 text-xs mt-1 font-bold">{{ form_errors(form.password.second) }}</div>
</div>
</div>
<button type="submit" class="w-full bg-blue-600 text-white font-black text-sm uppercase tracking-widest py-4 rounded-2xl shadow-lg shadow-blue-600/30 active:scale-95 transition-all hover:bg-blue-700 mt-4">
Mettre à jour
</button>
{{ form_end(form) }}
</div>
{# RETOUR #}
<div class="text-center">
<a href="{{ path('etl_home') }}" class="text-xs font-bold text-slate-400 hover:text-blue-600 transition-colors">Retour au tableau de bord</a>
</div>
</div>
{% endblock %}

68
templates/etl/base.twig Normal file
View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="fr" class="bg-slate-50">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#ffffff">
<title>{% block title %}Espace Prestataire{% endblock %} - LudikEvent</title>
{{ vite_asset('etl.js', []) }}
{% block stylesheets %}{% endblock %}
</head>
<body class="font-sans antialiased text-slate-900 bg-slate-50 selection:bg-blue-500 selection:text-white h-full">
<div class="mobile-container">
{# HEADER #}
<header class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-slate-100 px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-600 rounded-xl flex items-center justify-center text-white font-black text-sm shadow-lg shadow-blue-500/30">
L
</div>
<span class="text-sm font-black uppercase tracking-widest text-slate-900">Prestataire</span>
</div>
{% if app.user %}
<a href="{{ path('elt_logout') }}" class="p-2 rounded-full bg-slate-50 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
</a>
{% endif %}
</header>
{# MAIN CONTENT #}
<main class="flex-1 overflow-y-auto px-6 py-8 pb-24">
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="mb-6 p-4 rounded-2xl text-xs font-bold uppercase tracking-wide
{{ label == 'success' ? 'bg-emerald-50 text-emerald-600 border border-emerald-100' : 'bg-rose-50 text-rose-600 border border-rose-100' }}">
{{ message }}
</div>
{% endfor %}
{% endfor %}
{% block body %}{% endblock %}
</main>
{# BOTTOM NAV (Optional, only if logged in) #}
{% if app.user %}
<nav class="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-100 px-6 py-3 flex justify-around items-center z-40 max-w-md mx-auto">
<a href="{{ path('etl_home') }}" class="flex flex-col items-center gap-1 text-blue-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg>
<span class="text-[9px] font-bold uppercase tracking-widest">Accueil</span>
</a>
<a href="#" class="flex flex-col items-center gap-1 text-slate-400 hover:text-slate-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>
<span class="text-[9px] font-bold uppercase tracking-widest">Missions</span>
</a>
<a href="{{ path('etl_account') }}" class="flex flex-col items-center gap-1 text-slate-400 hover:text-slate-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
<span class="text-[9px] font-bold uppercase tracking-widest">Compte</span>
</a>
</nav>
{% endif %}
</div>
{% block javascripts %}{% endblock %}
</body>
</html>

64
templates/etl/home.twig Normal file
View File

@@ -0,0 +1,64 @@
{% extends 'etl/base.twig' %}
{% block title %}Tableau de bord{% endblock %}
{% block body %}
<div class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{# WELCOME CARD #}
<div class="bg-blue-600 rounded-[2rem] p-6 text-white shadow-xl shadow-blue-600/20 relative overflow-hidden">
<div class="absolute top-0 right-0 -mr-8 -mt-8 w-32 h-32 bg-white/10 rounded-full blur-2xl"></div>
<p class="text-[10px] font-black uppercase tracking-widest opacity-80 mb-1">Bienvenue</p>
<h2 class="text-2xl font-black italic tracking-tighter">{{ app.user.userIdentifier }}</h2>
<div class="mt-6 flex items-center gap-4">
<div class="bg-white/20 backdrop-blur-md rounded-xl px-4 py-2 flex flex-col">
<span class="text-[9px] font-bold uppercase tracking-wide opacity-80">Missions</span>
<span class="text-lg font-black">0</span>
</div>
<div class="bg-white/20 backdrop-blur-md rounded-xl px-4 py-2 flex flex-col">
<span class="text-[9px] font-bold uppercase tracking-wide opacity-80">À venir</span>
<span class="text-lg font-black">0</span>
</div>
</div>
</div>
{# SECTIONS #}
<div class="space-y-4">
<h3 class="px-2 text-xs font-black text-slate-400 uppercase tracking-widest">Accès Rapide</h3>
<div class="grid grid-cols-2 gap-4">
<a href="#" class="bg-white p-5 rounded-[1.5rem] border border-slate-100 shadow-sm hover:border-blue-100 transition-all group active:scale-95">
<div class="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center text-blue-600 mb-3 group-hover:bg-blue-600 group-hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>
</div>
<p class="text-xs font-bold text-slate-900">Mes Missions</p>
<p class="text-[9px] text-slate-400 font-medium mt-0.5">Planning & Détails</p>
</a>
<a href="#" class="bg-white p-5 rounded-[1.5rem] border border-slate-100 shadow-sm hover:border-blue-100 transition-all group active:scale-95">
<div class="w-10 h-10 bg-emerald-50 rounded-xl flex items-center justify-center text-emerald-600 mb-3 group-hover:bg-emerald-600 group-hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<p class="text-xs font-bold text-slate-900">Validations</p>
<p class="text-[9px] text-slate-400 font-medium mt-0.5">États des lieux</p>
</a>
</div>
</div>
{# UPCOMING LIST (Placeholder) #}
<div class="space-y-4">
<h3 class="px-2 text-xs font-black text-slate-400 uppercase tracking-widest">Prochainement</h3>
<div class="bg-white rounded-[2rem] border border-slate-100 p-8 text-center">
<div class="inline-flex p-4 bg-slate-50 rounded-full text-slate-300 mb-3">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
<p class="text-xs font-bold text-slate-900">Aucune mission à venir</p>
<p class="text-[10px] text-slate-400 mt-1">Votre planning est vide pour le moment.</p>
</div>
</div>
</div>
{% endblock %}

56
templates/etl/login.twig Normal file
View File

@@ -0,0 +1,56 @@
{% extends 'etl/base.twig' %}
{% block title %}Connexion{% endblock %}
{% block body %}
<div class="flex flex-col justify-center min-h-[60vh] space-y-8">
<div class="text-center space-y-2">
<h1 class="text-2xl font-black text-slate-900 tracking-tight">Bienvenue</h1>
<p class="text-sm text-slate-500 font-medium">Connectez-vous à votre espace prestataire</p>
</div>
{% if error %}
<div class="p-4 rounded-2xl bg-rose-50 border border-rose-100 text-rose-600 text-xs font-bold uppercase tracking-wide text-center animate-in fade-in zoom-in-95 duration-300">
Identifiants incorrects
</div>
{% endif %}
<form method="post" class="space-y-6">
<div class="space-y-4">
<div>
<label for="username" class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Email</label>
<input type="email" value="{{ last_username }}" name="_username" id="username" class="w-full bg-white border border-slate-200 rounded-2xl px-5 py-4 text-sm font-bold text-slate-800 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 transition-all placeholder:text-slate-300" placeholder="votre@email.com" autocomplete="email" required autofocus>
</div>
<div>
<label for="password" class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Mot de passe</label>
<input type="password" name="_password" id="password" class="w-full bg-white border border-slate-200 rounded-2xl px-5 py-4 text-sm font-bold text-slate-800 outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 transition-all placeholder:text-slate-300" placeholder="••••••••" autocomplete="current-password" required>
</div>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate_etl') }}">
<button type="submit" class="w-full bg-blue-600 text-white font-black text-sm uppercase tracking-widest py-4 rounded-2xl shadow-lg shadow-blue-600/30 active:scale-95 transition-all hover:bg-blue-700">
Se connecter
</button>
</form>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-slate-200"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-slate-50 px-2 text-slate-400 font-bold tracking-widest">Ou</span>
</div>
</div>
<a href="{{ path('connect_keycloak_etl_start') }}" class="flex items-center justify-center w-full bg-slate-900 text-white font-black text-sm uppercase tracking-widest py-4 rounded-2xl shadow-lg hover:bg-slate-800 active:scale-95 transition-all">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
Connexion SSO (Interne)
</a>
<div class="text-center">
<a href="#" class="text-xs font-bold text-slate-400 hover:text-blue-600 transition-colors">Mot de passe oublié ?</a>
</div>
</div>
{% endblock %}

View File

@@ -46,6 +46,7 @@ export default defineConfig({
error: resolve(__dirname, 'assets/error.js'),
reserve: resolve(__dirname, 'assets/reserve.js'),
flow_reservation: resolve(__dirname, 'assets/flow_reservation.js'),
etl: resolve(__dirname, 'assets/etl.js'),
}
},
},