Files
crm_ecosplay/src/Service/KeycloakAdminService.php
Serreau Jovann 8b35e2b6d2 feat: comptabilite + prestataires + rapport financier + stats dynamiques
Comptabilite (Super Admin) :
- ComptabiliteController avec 7 exports CSV/JSON compatibles SAGE
  (journal ventes, grand livre, FEC, balance agee, reglements,
  commissions Stripe 1.5%+0.25E, couts services)
- Export PDF via ComptaPdf (FPDF) avec bloc legal pre-rempli,
  tableau pagine, champ signature DocuSeal
- Signature electronique DocuSeal + callback + envoi email signe
  avec template dedie (compta_export_signed.html.twig)
- Rapport financier public (RapportFinancierPdf) : recettes par
  service, depenses (Stripe, infra, prestataires), bilan excedent/deficit
- Codes comptables clients EC-XXXX (plus de 411xxx)

Prestataires (Super Admin) :
- Entite Prestataire (raisonSociale, siret, email, phone, adresse)
- Entite FacturePrestataire (numFacture, montantHt, montantTtc,
  year, month, isPaid, PDF via Vich)
- CRUD complet avec recherche SIRET via proxy API data.gouv.fr
- Commande cron app:reminder:factures-prestataire (5 du mois)
- Factures prestataires integrees dans export couts services
- Sidebar Super Admin : entree Prestataires + Comptabilite

Stats (/admin/stats) :
- Cout prestataire dynamique depuis FacturePrestataire
- Fusion Infra + Prestataire en "Cout de fonctionnement"
- Commission Stripe corrigee (1.5% + 0.25E par transaction)

Divers :
- DocuSealService::sendComptaForSignature() + getApi()
- Customer::generateCodeComptable() format EC-XXXX-XXXXX
- Protection double prefixe EC- a la creation client
- Bouton regenerer PDF cache quand advert state=accepted
- Modals sans script inline (data-modal-open/close dans app.js)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:39:31 +02:00

324 lines
10 KiB
PHP

<?php
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class KeycloakAdminService
{
private const PATH_USERS = '/users';
private const PATH_GROUPS = '/groups';
private const AUTH_BEARER = 'Bearer ';
/** Groupes requis pour le CRM E-Cosplay */
private const REQUIRED_GROUPS = [
'superadmin',
'super_admin_asso',
'gp_asso',
'gp_contest',
'gp_mail',
'gp_mailling',
'gp_member',
'gp_ndd',
'gp_sign',
'gp_ticket',
];
private ?string $accessToken = null;
private ?int $tokenExpiresAt = null;
public function __construct(
private HttpClientInterface $httpClient,
#[Autowire(env: 'OAUTH_KEYCLOAK_URL')] private string $keycloakUrl,
#[Autowire(env: 'OAUTH_KEYCLOAK_REALM')] private string $realm,
#[Autowire(env: 'KEYCLOAK_ADMIN_CLIENT_ID')] private string $clientId,
#[Autowire(env: 'KEYCLOAK_ADMIN_CLIENT_SECRET')] private string $clientSecret,
) {
}
/**
* Verifie que tous les groupes requis existent dans Keycloak, cree ceux qui manquent.
*
* @return list<string> liste des groupes crees
*/
public function ensureRequiredGroups(): array
{
$existingGroups = $this->listGroups();
$existingNames = array_map(fn (array $g) => $g['name'], $existingGroups);
$created = [];
foreach (self::REQUIRED_GROUPS as $groupName) {
if (!\in_array($groupName, $existingNames, true) && $this->createGroup($groupName)) {
$created[] = $groupName;
}
}
return $created;
}
/**
* Creer un groupe dans Keycloak.
*/
public function createGroup(string $groupName): bool
{
$token = $this->getToken();
$response = $this->httpClient->request('POST', $this->getAdminUrl(self::PATH_GROUPS), [
'headers' => ['Authorization' => self::AUTH_BEARER.$token],
'json' => ['name' => $groupName],
]);
return 201 === $response->getStatusCode();
}
/**
* Creer un utilisateur dans Keycloak.
*
* @return array{created: bool, keycloakId: string|null, tempPassword: string|null}
*/
public function createUser(string $email, string $firstName, string $lastName): array
{
$token = $this->getToken();
$tempPassword = bin2hex(random_bytes(8));
// Creer l'utilisateur
$response = $this->httpClient->request('POST', $this->getAdminUrl(self::PATH_USERS), [
'headers' => ['Authorization' => self::AUTH_BEARER.$token],
'json' => [
'username' => $email,
'email' => $email,
'firstName' => $firstName,
'lastName' => $lastName,
'enabled' => true,
'emailVerified' => true,
'requiredActions' => ['UPDATE_PASSWORD', 'CONFIGURE_TOTP'],
'credentials' => [
[
'type' => 'password',
'value' => $tempPassword,
'temporary' => true,
],
],
],
]);
if (201 !== $response->getStatusCode()) {
return ['created' => false, 'keycloakId' => null, 'tempPassword' => null];
}
// Recuperer l'ID du user cree
$locationHeader = $response->getHeaders(false)['location'][0] ?? '';
$keycloakId = basename($locationHeader);
if ('' === $keycloakId) {
$keycloakId = $this->getUserIdByEmail($email);
}
return [
'created' => true,
'keycloakId' => $keycloakId,
'tempPassword' => $tempPassword,
];
}
/**
* Ajouter un utilisateur a un groupe Keycloak.
*/
public function addUserToGroup(string $keycloakId, string $groupName): bool
{
$groupId = $this->getGroupIdByName($groupName);
if (null === $groupId) {
return false;
}
$token = $this->getToken();
$response = $this->httpClient->request('PUT', $this->getAdminUrl(self::PATH_USERS.'/'.$keycloakId.'/groups/'.$groupId), [
'headers' => ['Authorization' => self::AUTH_BEARER.$token],
]);
return 204 === $response->getStatusCode();
}
/**
* Supprimer un utilisateur de Keycloak.
*/
public function deleteUser(string $keycloakId): bool
{
$token = $this->getToken();
$response = $this->httpClient->request('DELETE', $this->getAdminUrl(self::PATH_USERS.'/'.$keycloakId), [
'headers' => ['Authorization' => self::AUTH_BEARER.$token],
]);
return 204 === $response->getStatusCode();
}
/**
* Mettre a jour les infos d'un utilisateur dans Keycloak.
*/
public function updateUser(string $keycloakId, string $firstName, string $lastName, string $email): bool
{
$token = $this->getToken();
$response = $this->httpClient->request('PUT', $this->getAdminUrl(self::PATH_USERS.'/'.$keycloakId), [
'headers' => ['Authorization' => self::AUTH_BEARER.$token],
'json' => [
'firstName' => $firstName,
'lastName' => $lastName,
'email' => $email,
'username' => $email,
],
]);
return 204 === $response->getStatusCode();
}
/**
* Reset le mot de passe d'un utilisateur dans Keycloak.
*/
public function resetPassword(string $keycloakId, string $newPassword): bool
{
$token = $this->getToken();
$response = $this->httpClient->request('PUT', $this->getAdminUrl(self::PATH_USERS.'/'.$keycloakId.'/reset-password'), [
'headers' => ['Authorization' => self::AUTH_BEARER.$token],
'json' => [
'type' => 'password',
'value' => $newPassword,
'temporary' => false,
],
]);
return 204 === $response->getStatusCode();
}
/**
* Envoyer un email de reset password a l'utilisateur.
*/
public function sendResetPasswordEmail(string $keycloakId): bool
{
$token = $this->getToken();
$response = $this->httpClient->request('PUT', $this->getAdminUrl(self::PATH_USERS.'/'.$keycloakId.'/execute-actions-email'), [
'headers' => ['Authorization' => self::AUTH_BEARER.$token],
'json' => ['UPDATE_PASSWORD'],
]);
return 200 === $response->getStatusCode() || 204 === $response->getStatusCode();
}
/**
* Recuperer les groupes d'un utilisateur.
*
* @return list<string>
*/
public function getUserGroups(string $keycloakId): array
{
$token = $this->getToken();
$response = $this->httpClient->request('GET', $this->getAdminUrl(self::PATH_USERS.'/'.$keycloakId.'/groups'), [
'headers' => ['Authorization' => self::AUTH_BEARER.$token],
]);
$groups = $response->toArray(false);
return array_map(fn (array $g) => $g['name'], $groups);
}
/**
* Lister tous les utilisateurs du realm.
*
* @return list<array<string, mixed>>
*/
public function listUsers(int $max = 100): array
{
$token = $this->getToken();
$response = $this->httpClient->request('GET', $this->getAdminUrl(self::PATH_USERS), [
'headers' => ['Authorization' => self::AUTH_BEARER.$token],
'query' => ['max' => $max],
]);
return $response->toArray(false);
}
/**
* Recuperer l'ID d'un utilisateur par email.
*/
public function getUserIdByEmail(string $email): ?string
{
$token = $this->getToken();
$response = $this->httpClient->request('GET', $this->getAdminUrl(self::PATH_USERS), [
'headers' => ['Authorization' => self::AUTH_BEARER.$token],
'query' => ['email' => $email, 'exact' => 'true'],
]);
$users = $response->toArray(false);
return $users[0]['id'] ?? null;
}
/**
* Lister tous les groupes du realm.
*
* @return list<array{id: string, name: string}>
*/
public function listGroups(): array
{
$token = $this->getToken();
$response = $this->httpClient->request('GET', $this->getAdminUrl(self::PATH_GROUPS), [
'headers' => ['Authorization' => self::AUTH_BEARER.$token],
]);
return $response->toArray(false);
}
/**
* @return list<string>
*/
public static function getRequiredGroups(): array
{
return self::REQUIRED_GROUPS;
}
private function getGroupIdByName(string $groupName): ?string
{
$token = $this->getToken();
$response = $this->httpClient->request('GET', $this->getAdminUrl(self::PATH_GROUPS), [
'headers' => ['Authorization' => self::AUTH_BEARER.$token],
'query' => ['search' => $groupName, 'exact' => 'true'],
]);
$groups = $response->toArray(false);
foreach ($groups as $group) {
if ($group['name'] === $groupName) {
return $group['id'];
}
}
return null;
}
private function getToken(): string
{
if (null !== $this->accessToken && null !== $this->tokenExpiresAt && time() < $this->tokenExpiresAt) {
return $this->accessToken;
}
$response = $this->httpClient->request('POST', $this->keycloakUrl.'/realms/'.$this->realm.'/protocol/openid-connect/token', [
'body' => [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'grant_type' => 'client_credentials',
],
]);
$data = $response->toArray();
$this->accessToken = $data['access_token'];
$this->tokenExpiresAt = time() + ($data['expires_in'] ?? 300) - 30;
return $this->accessToken;
}
private function getAdminUrl(string $path): string
{
return $this->keycloakUrl.'/admin/realms/'.$this->realm.$path;
}
}