diff --git a/assets/controllers/application_controller.js b/assets/controllers/application_controller.js index 5aba951..ebc6e66 100644 --- a/assets/controllers/application_controller.js +++ b/assets/controllers/application_controller.js @@ -5,8 +5,9 @@ export default class extends Controller { static values = { application: String, organization: String, + user: Number, } - static targets = ['hidden', 'submitBtn'] + static targets = ['hidden', 'submitBtn', 'appList'] connect() { // Map each editor to its toolbar and hidden field @@ -40,6 +41,9 @@ export default class extends Controller { hiddenTarget.value = quill.root.innerHTML }) } + if(this.userValue){ + this.loadApplications(); + } } handleAuthorizeSubmit(event) { @@ -107,4 +111,59 @@ export default class extends Controller { alert('Erreur lors de l\'action'); }); } + + async loadApplications() { + if (!this.userValue) return; + + try { + // Note: Ensure the URL matches your route prefix (e.g. /application/user/123) + // Adjust the base path below if your controller route is prefixed! + const response = await fetch(`/application/user/${this.userValue}`); + + if (!response.ok) throw new Error("Failed to load apps"); + + const apps = await response.json(); + this.renderApps(apps); + + } catch (error) { + console.error(error); + this.appListTarget.innerHTML = `Erreur`; + } + } + + renderApps(apps) { + if (apps.length === 0) { + // Span 2 columns if empty so the message is centered + this.appListTarget.innerHTML = `Aucune application`; + return; + } + + const html = apps.map(app => { + const url = `https://${app.subDomain}.solutions-easy.moi`; + + // Check for logo string vs object + const logoSrc = (typeof app.logoMiniUrl === 'string') ? app.logoMiniUrl : ''; + + // Render Icon (Image or Fallback) + const iconHtml = logoSrc + ? `` + : ``; + + // Return a Card-like block + return ` + + + ${iconHtml} + + + ${app.name} + + + `; + }).join(''); + + this.appListTarget.innerHTML = html; + } } \ No newline at end of file diff --git a/docs/Client_Setup.md b/docs/Client_Setup.md new file mode 100644 index 0000000..51917c0 --- /dev/null +++ b/docs/Client_Setup.md @@ -0,0 +1,327 @@ +# Client setup +## Add needed dependencies +```bash + composer require nelmio/cors-bundle + composer require knpuniversity/oauth2-client-bundle +``` + +## Configure the bundle +### nelmio/cors-bundle +```yaml +# config/packages/nelmio_cors.yaml +nelmio_cors: + defaults: + origin_regex: true + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] + allow_headers: ['Content-Type', 'Authorization'] + expose_headers: ['Link'] + max_age: 3600 + paths: + '^/token$': + origin_regex: true + allow_origin: ['*'] + allow_headers: ['Content-Type', 'Authorization'] + allow_methods: ['POST', 'OPTIONS'] + allow_credentials: true + max_age: 3600 + '^/authorize$': + origin_regex: true + allow_origin: ['*'] + allow_headers: ['Content-Type', 'Authorization'] + allow_methods: ['GET', 'POST', 'OPTIONS'] + allow_credentials: true + max_age: 3600 +``` +### knpuniversity/oauth2-client-bundle +```yaml +# config/packages/knpu_oauth2_client.yaml +knpu_oauth2_client: + clients: + # configure your clients as described here: https://github.com/knpuniversity/oauth2-client-bundle#configuration + sudalys: + type: generic + provider_class: Sudalys\OAuth2\Client\Provider\Sudalys + client_id: '%env(OAUTH2_CLIENT_ID)%' + client_secret: '%env(OAUTH2_CLIENT_SECRET)%' + redirect_route: uri # The route to redirect to after authentication (must match the one in the server DB uri DB) + provider_options: { + domain: + } + use_state: false +``` + +### .env +```dotenv +# .env +# CORS +CORS_ALLOW_ORIGIN=http://*.your domain/*' +# OAUTH2 +OAUTH2_CLIENT_ID= +OAUTH2_CLIENT_SECRET= +``` + +Copy and paste the client library then modify the conposer.json autoloard directive to include the new library +```json + "autoload": { + "psr-4": { + "App\\": "src/", + "Sudalys\\OAuth2\\Client\\": "libs/sudalys/oauth2-client/src" + } + }, +``` + +```php +clientRegistry = $clientRegistry; + $this->em = $em; + $this->router = $router; + $this->urlGenerator = $urlGenerator; + } + + public function start(Request $request, AuthenticationException $authException = null): Response + { + // Use the KnpU client to generate the correct authorization URL, + // including state / redirect_uri / scope / pkce as configured. + $client = $this->getSudalysClient(); + + // Option A: let the client use the configured redirect uri and default scopes: + return $client->redirect(); + + // Option B (explicit): specify scopes and an explicit redirect_uri (absolute URL) + // $redirectUri = $this->urlGenerator->generate('sudalys_check', [], UrlGeneratorInterface::ABSOLUTE_URL); + // return $client->redirect(['openid', 'profile'], ['redirect_uri' => $redirectUri]); + } + + + public function supports(Request $request): ?bool + { + // If your OAuth redirect route is named 'sudalys_check', check by route: + if ($request->attributes->get('_route') === 'sudalys_check') { + return true; + } + + // fallback: also support requests containing the authorization code + return (bool) $request->query->get('code'); + } + + public function authenticate(Request $request): Passport + { + $client = $this->getSudalysClient(); + $accessToken = $this->fetchAccessToken($client); + $session = $request->getSession(); + $session->set('access_token', $accessToken->getToken()); + + // Stocker également le refresh token s'il est disponible + if ($accessToken->getRefreshToken()) { + $session->set('refresh_token', $accessToken->getRefreshToken()); + } + return new SelfValidatingPassport( + new UserBadge($accessToken->getToken(), function () use ($accessToken, $client) { + //show in log the access token + $sudalysSsoUser = $client->fetchUserFromToken($accessToken); + + $ssoId = $sudalysSsoUser->getId(); + + /* + * On regarde si le token est valide + */ + if($accessToken->getExpires() > time()) { + // Token valide, on regarde si l'utilisateur existe en bdd locale + /** @var User $userInDatabase */ + $user = $this->em->getRepository(User::class)->findOneBy(['ssoId' => $ssoId]); + + /** + * on cree l'utilisateur s'il n'existe pas + **/ + if (!$user) { + $user = new User(); + $user->setEmail($sudalysSsoUser->getEmail()); + $user->setName($sudalysSsoUser->getName()); + $user->setSurname($sudalysSsoUser->getSurname()); + $user->setSsoId($sudalysSsoUser->getId()); + $this->em->persist($user); + }else{ + // On met a jour l'utilisateur + $user->setEmail($sudalysSsoUser->getEmail()); + $user->setName($sudalysSsoUser->getName()); + $user->setSurname($sudalysSsoUser->getSurname()); + $this->em->persist($user); + } + $this->em->flush(); + return $user; + } + }) + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + // change "app_homepage" to some route in your app + $targetUrl = $this->router->generate('app_index'); + return new RedirectResponse($targetUrl); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $message = strtr($exception->getMessageKey(), $exception->getMessageData()); + return new Response($message, Response::HTTP_FORBIDDEN); + } + + /** + * + */ + private function getSudalysClient() + { + return $this->clientRegistry->getClient('sudalys'); + } + +} + +``` + +```php +namespace App\Security\SsoAuthenticator; + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + // change "app_homepage" to some route in your app + $targetUrl = $this->router->generate('your redirect route'); + return new RedirectResponse($targetUrl); + } +``` + +### Security.yaml +```yaml + app_user_provider: + entity: + class: App\Entity\User + property: email + + firewalls: + main: + lazy: true + provider: app_user_provider + custom_authenticators: + - App\Security\SsoAuthenticator + entry_point: App\Security\SsoAuthenticator + logout: + path: app_logout + target: app_after_logout + invalidate_session: true + delete_cookies: ['PHPSESSID'] + + access_control: + - { path: ^/sso/login, roles: PUBLIC_ACCESS } + - { path: ^/sso/check, roles: PUBLIC_ACCESS } + - { path: ^/, roles: IS_AUTHENTICATED_FULLY } +``` +### Setup oauth controller +```php +getClient('sudalys')->redirect(); + } + + #[Route('/sso/check', name: 'sudalys_sso_check')] + public function connectCheckAction(Request $request) + { + return $this->redirectToRoute('app_index'); + } + + + #[Route('/logout', name: 'app_logout')] + public function logout(): void + { + throw new \Exception('This should never be reached!'); + } + + #[Route('/logout-redirect', name: 'app_after_logout')] + public function afterLogout(): RedirectResponse + { + // SSO logout URL — adjust if necessary + $ssoLogout = 'http://portail.solutions-easy.moi/sso_logout'; + + return new RedirectResponse($ssoLogout); + } +} +``` +# Server setup +## Create OAuth2 client +```cmd +php bin/console league:oauth2-server:create-client --redirect-uri="http://your-client-domain/sso/check" --scope="openid" --scope="profile" --scope="email" --grant-type=authorization_code +``` +If there is a scope or grand error, delete the client do the following first +```cmd +php bin/console league:oauth2-server:delete-client +``` +Identifier can be found in the database oauth2_client table +The recreate the client and enter the scopes and grant types after creating the client directly in the db +```text +scopes = email profile openid +grants = authorization_code +``` diff --git a/src/Controller/ApplicationController.php b/src/Controller/ApplicationController.php index e883449..b7aa207 100644 --- a/src/Controller/ApplicationController.php +++ b/src/Controller/ApplicationController.php @@ -4,26 +4,32 @@ namespace App\Controller; use App\Entity\Apps; use App\Entity\Organizations; +use App\Repository\UserRepository; use App\Service\ActionService; use App\Service\ApplicationService; use App\Service\LoggerService; +use App\Service\UserOrganizationAppService; use App\Service\UserService; use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface; - +use Symfony\Component\Asset\Packages; #[Route(path: '/application', name: 'application_')] class ApplicationController extends AbstractController { public function __construct(private readonly EntityManagerInterface $entityManager, - private readonly UserService $userService, - private readonly ActionService $actionService, - private readonly LoggerService $loggerService, - private readonly ApplicationService $applicationService) + private readonly UserService $userService, + private readonly ActionService $actionService, + private readonly LoggerService $loggerService, + private readonly ApplicationService $applicationService, + private readonly UserRepository $userRepository, + private Packages $assetsManager, + private readonly UserOrganizationAppService $userOrganizationAppService) { } @@ -172,4 +178,30 @@ class ApplicationController extends AbstractController return new Response('', Response::HTTP_OK); } + + #[Route(path:'/user/{id}', name: 'user', methods: ['GET'])] + public function getApplicationUsers(int $id): JSONResponse + { + $user = $this->userRepository->find($id); + $actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier()); + if (!$user) { + $this->loggerService->logEntityNotFound('User', ['message'=> 'User not found for application list'], $actingUser->getId()); + return new JsonResponse(['error' => 'User not found'], Response::HTTP_NOT_FOUND); + } + if ($this->isGranted('ROLE_SUPER_ADMIN')) { + $applications = $this->entityManager->getRepository(Apps::class)->findAll(); + }else{ + $applications = $this->userOrganizationAppService->getUserApplications($user); + + } + $data = array_map(function($app) { + return [ + 'name' => $app->getName(), + 'subDomain' => $app->getSubDomain(), + 'logoMiniUrl' => $this->assetsManager->getUrl($app->getLogoMiniUrl()), + ]; + }, $applications); + + return new JsonResponse($data, Response::HTTP_OK); + } } diff --git a/src/Service/UserOrganizationAppService.php b/src/Service/UserOrganizationAppService.php index bf2615f..53fc759 100644 --- a/src/Service/UserOrganizationAppService.php +++ b/src/Service/UserOrganizationAppService.php @@ -248,4 +248,26 @@ class UserOrganizationAppService $uoaAdmin->setIsActive(true); } } + + /** + * Get users applications links for a given user + * + * @param User $user + * @return Apps[] + */ + public function getUserApplications(User $user): array + { + $uos = $this->entityManager->getRepository(UsersOrganizations::class)->findBy(['users' => $user]); + $apps = []; + foreach ($uos as $uo) { + $uoas = $this->entityManager->getRepository(UserOrganizatonApp::class)->findBy(['userOrganization' => $uo, 'isActive' => true]); + foreach ($uoas as $uoa) { + $app = $uoa->getApplication(); + if (!in_array($app, $apps, true)) { + $apps[] = $app; + } + } + } + return $apps; + } } diff --git a/templates/application/edit.html.twig b/templates/application/edit.html.twig index d694a73..7c89081 100644 --- a/templates/application/edit.html.twig +++ b/templates/application/edit.html.twig @@ -14,6 +14,9 @@ {# Description (full) #}
+
+
Description complète
+
@@ -53,6 +56,9 @@ {# Description Small #}
+
+
Description courte
+
diff --git a/templates/elements/menu.html.twig b/templates/elements/menu.html.twig index 1b308c2..fecb6f2 100644 --- a/templates/elements/menu.html.twig +++ b/templates/elements/menu.html.twig @@ -1,61 +1,42 @@ \ No newline at end of file diff --git a/templates/elements/navbar.html.twig b/templates/elements/navbar.html.twig index 9b52742..a701010 100644 --- a/templates/elements/navbar.html.twig +++ b/templates/elements/navbar.html.twig @@ -72,6 +72,35 @@
+