From 0951322d714cbff8396dddcd6a9db3bf23d35e7b Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Tue, 17 Feb 2026 10:07:10 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Permettre=20au=20super=20admin=20de=20s?= =?UTF-8?q?e=20connecter=20et=20acc=C3=A9der=20=C3=A0=20son=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le super admin (table super_admins, master DB) ne pouvait pas se connecter via /api/login car ce firewall n'utilisait que le provider tenant. De même, le JWT n'était pas enrichi pour les super admins, l'endpoint /api/me/roles les rejetait, et le frontend redirigeait systématiquement vers /dashboard. Un chain provider (super_admin + tenant) résout l'authentification, le JwtPayloadEnricher et MyRolesProvider gèrent désormais les deux types d'utilisateurs, et le frontend redirige selon le rôle après login. --- backend/config/packages/doctrine.yaml | 6 + backend/config/packages/security.yaml | 17 +- backend/config/services.yaml | 7 + backend/migrations/Version20260217090323.php | 67 +++++ .../Api/Provider/MyRolesProvider.php | 11 + .../Security/JwtPayloadEnricher.php | 16 +- .../CreateEstablishmentCommand.php | 16 ++ .../CreateEstablishmentHandler.php | 39 +++ .../CreateEstablishmentResult.php | 17 ++ .../SwitchTenant/SwitchTenantCommand.php | 13 + .../SwitchTenant/SwitchTenantHandler.php | 32 +++ .../GetEstablishments/EstablishmentView.php | 19 ++ .../GetEstablishmentsHandler.php | 36 +++ .../GetEstablishmentsQuery.php | 9 + .../EstablishmentMetricsView.php | 22 ++ .../GetEstablishmentsMetricsHandler.php | 71 +++++ .../GetEstablishmentsMetricsQuery.php | 9 + .../Domain/Event/EtablissementCree.php | 36 +++ .../Domain/Event/EtablissementDesactive.php | 32 +++ .../Domain/Event/SuperAdminCree.php | 33 +++ .../Domain/Event/SuperAdminDesactive.php | 32 +++ .../EstablishmentDejaInactifException.php | 20 ++ .../EstablishmentIntrouvableException.php | 28 ++ .../SuperAdminDejaActifException.php | 20 ++ .../SuperAdminIntrouvableException.php | 27 ++ .../Exception/SuperAdminNonActifException.php | 27 ++ .../Model/Establishment/Establishment.php | 116 ++++++++ .../Model/Establishment/EstablishmentId.php | 11 + .../Establishment/EstablishmentStatus.php | 11 + .../Domain/Model/SuperAdmin/SuperAdmin.php | 125 ++++++++ .../Domain/Model/SuperAdmin/SuperAdminId.php | 11 + .../Model/SuperAdmin/SuperAdminStatus.php | 11 + .../Repository/EstablishmentRepository.php | 26 ++ .../Repository/SuperAdminRepository.php | 20 ++ .../CreateEstablishmentProcessor.php | 53 ++++ .../Api/Processor/SwitchTenantProcessor.php | 40 +++ .../EstablishmentCollectionProvider.php | 48 ++++ .../Provider/EstablishmentItemProvider.php | 52 ++++ .../Api/Provider/MetricsProvider.php | 48 ++++ .../Api/Resource/EstablishmentResource.php | 62 ++++ .../Api/Resource/MetricsResource.php | 31 ++ .../Api/Resource/SwitchTenantInput.php | 30 ++ .../Console/CreateTestSuperAdminCommand.php | 107 +++++++ .../DoctrineEstablishmentRepository.php | 130 +++++++++ .../Doctrine/DoctrineSuperAdminRepository.php | 114 ++++++++ .../InMemoryEstablishmentRepository.php | 52 ++++ .../InMemory/InMemorySuperAdminRepository.php | 42 +++ .../Security/SecuritySuperAdmin.php | 57 ++++ .../Security/SuperAdminTenantContext.php | 38 +++ .../Security/SuperAdminUserProvider.php | 67 +++++ .../Api/Provider/MyRolesProviderTest.php | 129 +++++++++ .../JwtPayloadEnricherSuperAdminTest.php | 71 +++++ .../CreateEstablishmentHandlerTest.php | 74 +++++ .../GetEstablishmentsHandlerTest.php | 61 ++++ .../Model/Establishment/EstablishmentTest.php | 148 ++++++++++ .../Model/SuperAdmin/SuperAdminTest.php | 181 ++++++++++++ .../CreateEstablishmentProcessorTest.php | 60 ++++ .../EstablishmentCollectionProviderTest.php | 54 ++++ .../CreateTestSuperAdminCommandTest.php | 106 +++++++ frontend/e2e/super-admin.spec.ts | 208 ++++++++++++++ frontend/src/lib/auth/auth.svelte.ts | 12 + frontend/src/lib/auth/index.ts | 1 + .../features/super-admin/api/super-admin.ts | 81 ++++++ frontend/src/routes/login/+page.svelte | 10 +- .../src/routes/super-admin/+layout.svelte | 157 ++++++++++ .../routes/super-admin/dashboard/+page.svelte | 268 ++++++++++++++++++ .../super-admin/establishments/+page.svelte | 227 +++++++++++++++ .../establishments/new/+page.svelte | 245 ++++++++++++++++ 68 files changed, 4049 insertions(+), 8 deletions(-) create mode 100644 backend/migrations/Version20260217090323.php create mode 100644 backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentCommand.php create mode 100644 backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandler.php create mode 100644 backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentResult.php create mode 100644 backend/src/SuperAdmin/Application/Command/SwitchTenant/SwitchTenantCommand.php create mode 100644 backend/src/SuperAdmin/Application/Command/SwitchTenant/SwitchTenantHandler.php create mode 100644 backend/src/SuperAdmin/Application/Query/GetEstablishments/EstablishmentView.php create mode 100644 backend/src/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsHandler.php create mode 100644 backend/src/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsQuery.php create mode 100644 backend/src/SuperAdmin/Application/Query/GetEstablishmentsMetrics/EstablishmentMetricsView.php create mode 100644 backend/src/SuperAdmin/Application/Query/GetEstablishmentsMetrics/GetEstablishmentsMetricsHandler.php create mode 100644 backend/src/SuperAdmin/Application/Query/GetEstablishmentsMetrics/GetEstablishmentsMetricsQuery.php create mode 100644 backend/src/SuperAdmin/Domain/Event/EtablissementCree.php create mode 100644 backend/src/SuperAdmin/Domain/Event/EtablissementDesactive.php create mode 100644 backend/src/SuperAdmin/Domain/Event/SuperAdminCree.php create mode 100644 backend/src/SuperAdmin/Domain/Event/SuperAdminDesactive.php create mode 100644 backend/src/SuperAdmin/Domain/Exception/EstablishmentDejaInactifException.php create mode 100644 backend/src/SuperAdmin/Domain/Exception/EstablishmentIntrouvableException.php create mode 100644 backend/src/SuperAdmin/Domain/Exception/SuperAdminDejaActifException.php create mode 100644 backend/src/SuperAdmin/Domain/Exception/SuperAdminIntrouvableException.php create mode 100644 backend/src/SuperAdmin/Domain/Exception/SuperAdminNonActifException.php create mode 100644 backend/src/SuperAdmin/Domain/Model/Establishment/Establishment.php create mode 100644 backend/src/SuperAdmin/Domain/Model/Establishment/EstablishmentId.php create mode 100644 backend/src/SuperAdmin/Domain/Model/Establishment/EstablishmentStatus.php create mode 100644 backend/src/SuperAdmin/Domain/Model/SuperAdmin/SuperAdmin.php create mode 100644 backend/src/SuperAdmin/Domain/Model/SuperAdmin/SuperAdminId.php create mode 100644 backend/src/SuperAdmin/Domain/Model/SuperAdmin/SuperAdminStatus.php create mode 100644 backend/src/SuperAdmin/Domain/Repository/EstablishmentRepository.php create mode 100644 backend/src/SuperAdmin/Domain/Repository/SuperAdminRepository.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessor.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Api/Processor/SwitchTenantProcessor.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProvider.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Api/Provider/EstablishmentItemProvider.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Api/Provider/MetricsProvider.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Api/Resource/EstablishmentResource.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Api/Resource/MetricsResource.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Api/Resource/SwitchTenantInput.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Console/CreateTestSuperAdminCommand.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Persistence/Doctrine/DoctrineEstablishmentRepository.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Persistence/Doctrine/DoctrineSuperAdminRepository.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Persistence/InMemory/InMemoryEstablishmentRepository.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Persistence/InMemory/InMemorySuperAdminRepository.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Security/SecuritySuperAdmin.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Security/SuperAdminTenantContext.php create mode 100644 backend/src/SuperAdmin/Infrastructure/Security/SuperAdminUserProvider.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Provider/MyRolesProviderTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Security/JwtPayloadEnricherSuperAdminTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandlerTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsHandlerTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Domain/Model/Establishment/EstablishmentTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Domain/Model/SuperAdmin/SuperAdminTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessorTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProviderTest.php create mode 100644 backend/tests/Unit/SuperAdmin/Infrastructure/Console/CreateTestSuperAdminCommandTest.php create mode 100644 frontend/e2e/super-admin.spec.ts create mode 100644 frontend/src/lib/features/super-admin/api/super-admin.ts create mode 100644 frontend/src/routes/super-admin/+layout.svelte create mode 100644 frontend/src/routes/super-admin/dashboard/+page.svelte create mode 100644 frontend/src/routes/super-admin/establishments/+page.svelte create mode 100644 frontend/src/routes/super-admin/establishments/new/+page.svelte diff --git a/backend/config/packages/doctrine.yaml b/backend/config/packages/doctrine.yaml index 66aee1b..98b87ce 100644 --- a/backend/config/packages/doctrine.yaml +++ b/backend/config/packages/doctrine.yaml @@ -33,6 +33,12 @@ doctrine: dir: '%kernel.project_dir%/src/Communication/Infrastructure/Persistence/Mapping' prefix: 'App\Communication\Infrastructure\Persistence\Mapping' alias: Communication + SuperAdmin: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/SuperAdmin/Infrastructure/Persistence/Mapping' + prefix: 'App\SuperAdmin\Infrastructure\Persistence\Mapping' + alias: SuperAdmin controller_resolver: auto_mapping: false diff --git a/backend/config/packages/security.yaml b/backend/config/packages/security.yaml index 4a802f7..d37fb18 100644 --- a/backend/config/packages/security.yaml +++ b/backend/config/packages/security.yaml @@ -21,6 +21,13 @@ security: # User provider for API authentication (Story 1.4) app_user_provider: id: App\Administration\Infrastructure\Security\DatabaseUserProvider + # Super Admin authentication — master database, not per-tenant (Story 2.10) + super_admin_provider: + id: App\SuperAdmin\Infrastructure\Security\SuperAdminUserProvider + # Chain provider: tries super admin first, then tenant user + all_users_provider: + chain: + providers: ['super_admin_provider', 'app_user_provider'] firewalls: dev: @@ -40,7 +47,12 @@ security: password_path: password success_handler: lexik_jwt_authentication.handler.authentication_success failure_handler: App\Administration\Infrastructure\Security\LoginFailureHandler - provider: app_user_provider + provider: all_users_provider + super_admin_api: + pattern: ^/api/super-admin + stateless: true + jwt: ~ + provider: super_admin_provider api_public: pattern: ^/api/(activation-tokens|activate|token/(refresh|logout)|password/(forgot|reset)|docs)(/|$) stateless: true @@ -49,7 +61,7 @@ security: pattern: ^/api stateless: true jwt: ~ - provider: app_user_provider + provider: all_users_provider main: lazy: true provider: app_user_provider @@ -58,6 +70,7 @@ security: # Note: Only the *first* access control that matches will be used access_control: - { path: ^/api/docs, roles: PUBLIC_ACCESS } + - { path: ^/api/super-admin, roles: ROLE_SUPER_ADMIN } - { path: ^/api/login, roles: PUBLIC_ACCESS } - { path: ^/api/activation-tokens, roles: PUBLIC_ACCESS } - { path: ^/api/activate, roles: PUBLIC_ACCESS } diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 0455a2d..e978537 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -169,6 +169,13 @@ services: App\Scolarite\Domain\Repository\TeacherReplacementRepository: alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineTeacherReplacementRepository + # Super Admin Repositories (Story 2.10 - Multi-établissements) + App\SuperAdmin\Domain\Repository\SuperAdminRepository: + alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineSuperAdminRepository + + App\SuperAdmin\Domain\Repository\EstablishmentRepository: + alias: App\SuperAdmin\Infrastructure\Persistence\Doctrine\DoctrineEstablishmentRepository + # Student Guardian Repository (Story 2.7 - Liaison parents-enfants) App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository: arguments: diff --git a/backend/migrations/Version20260217090323.php b/backend/migrations/Version20260217090323.php new file mode 100644 index 0000000..d02a06c --- /dev/null +++ b/backend/migrations/Version20260217090323.php @@ -0,0 +1,67 @@ +addSql(<<<'SQL' + CREATE TABLE IF NOT EXISTS super_admins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(100) NOT NULL DEFAULT '', + last_name VARCHAR(100) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ + ) + SQL); + + $this->addSql(<<<'SQL' + CREATE TABLE IF NOT EXISTS establishments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + subdomain VARCHAR(100) NOT NULL UNIQUE, + database_name VARCHAR(100) NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES super_admins(id), + last_activity_at TIMESTAMPTZ + ) + SQL); + + $this->addSql('CREATE INDEX IF NOT EXISTS idx_establishments_subdomain ON establishments(subdomain)'); + $this->addSql('CREATE INDEX IF NOT EXISTS idx_establishments_status ON establishments(status)'); + + $this->addSql(<<<'SQL' + CREATE TABLE IF NOT EXISTS establishment_metrics ( + establishment_id UUID PRIMARY KEY REFERENCES establishments(id) ON DELETE CASCADE, + user_count INT NOT NULL DEFAULT 0, + student_count INT NOT NULL DEFAULT 0, + teacher_count INT NOT NULL DEFAULT 0, + last_login_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS establishment_metrics'); + $this->addSql('DROP TABLE IF EXISTS establishments'); + $this->addSql('DROP TABLE IF EXISTS super_admins'); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/MyRolesProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/MyRolesProvider.php index 5f91ea9..3e490fc 100644 --- a/backend/src/Administration/Infrastructure/Api/Provider/MyRolesProvider.php +++ b/backend/src/Administration/Infrastructure/Api/Provider/MyRolesProvider.php @@ -13,6 +13,7 @@ use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Repository\UserRepository; use App\Administration\Infrastructure\Api\Resource\MyRolesOutput; use App\Administration\Infrastructure\Security\SecurityUser; +use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin; use function array_map; @@ -37,6 +38,16 @@ final readonly class MyRolesProvider implements ProviderInterface public function provide(Operation $operation, array $uriVariables = [], array $context = []): MyRolesOutput { $currentUser = $this->security->getUser(); + + if ($currentUser instanceof SecuritySuperAdmin) { + $output = new MyRolesOutput(); + $output->roles = [['value' => 'ROLE_SUPER_ADMIN', 'label' => 'Super Admin']]; + $output->activeRole = 'ROLE_SUPER_ADMIN'; + $output->activeRoleLabel = 'Super Admin'; + + return $output; + } + if (!$currentUser instanceof SecurityUser) { throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); } diff --git a/backend/src/Administration/Infrastructure/Security/JwtPayloadEnricher.php b/backend/src/Administration/Infrastructure/Security/JwtPayloadEnricher.php index e2c2030..d16989a 100644 --- a/backend/src/Administration/Infrastructure/Security/JwtPayloadEnricher.php +++ b/backend/src/Administration/Infrastructure/Security/JwtPayloadEnricher.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Administration\Infrastructure\Security; +use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent; /** @@ -12,7 +13,8 @@ use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent; * Added claims: * - sub: User email (Symfony Security identifier) * - user_id: User UUID (for API consumers) - * - tenant_id: Tenant UUID for multi-tenant isolation + * - tenant_id: Tenant UUID for multi-tenant isolation (regular users only) + * - user_type: "super_admin" for super admins * - roles: List of Symfony roles for authorization * * @see Story 1.4 - User login @@ -22,13 +24,21 @@ final readonly class JwtPayloadEnricher public function onJWTCreated(JWTCreatedEvent $event): void { $user = $event->getUser(); + $payload = $event->getData(); + + if ($user instanceof SecuritySuperAdmin) { + $payload['user_id'] = $user->superAdminId(); + $payload['user_type'] = 'super_admin'; + $payload['roles'] = $user->getRoles(); + $event->setData($payload); + + return; + } if (!$user instanceof SecurityUser) { return; } - $payload = $event->getData(); - // Business claims for multi-tenant isolation and authorization $payload['user_id'] = $user->userId(); $payload['tenant_id'] = $user->tenantId(); diff --git a/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentCommand.php b/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentCommand.php new file mode 100644 index 0000000..ec0c634 --- /dev/null +++ b/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentCommand.php @@ -0,0 +1,16 @@ +name, + subdomain: $command->subdomain, + createdBy: SuperAdminId::fromString($command->superAdminId), + createdAt: $this->clock->now(), + ); + + $this->establishmentRepository->save($establishment); + + return new CreateEstablishmentResult( + establishmentId: (string) $establishment->id, + tenantId: (string) $establishment->tenantId, + name: $establishment->name, + subdomain: $establishment->subdomain, + databaseName: $establishment->databaseName, + ); + } +} diff --git a/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentResult.php b/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentResult.php new file mode 100644 index 0000000..fe24055 --- /dev/null +++ b/backend/src/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentResult.php @@ -0,0 +1,17 @@ +tenantId); + + $establishment = $this->establishmentRepository->findByTenantId($tenantId); + + if ($establishment === null) { + throw EstablishmentIntrouvableException::avecTenantId($tenantId); + } + + $this->superAdminTenantContext->switchTo($tenantId); + } +} diff --git a/backend/src/SuperAdmin/Application/Query/GetEstablishments/EstablishmentView.php b/backend/src/SuperAdmin/Application/Query/GetEstablishments/EstablishmentView.php new file mode 100644 index 0000000..3f5dec4 --- /dev/null +++ b/backend/src/SuperAdmin/Application/Query/GetEstablishments/EstablishmentView.php @@ -0,0 +1,19 @@ +establishmentRepository->findAll(); + + return array_map( + static fn ($e) => new EstablishmentView( + id: (string) $e->id, + tenantId: (string) $e->tenantId, + name: $e->name, + subdomain: $e->subdomain, + status: $e->status->value, + createdAt: $e->createdAt->format('c'), + lastActivityAt: $e->lastActivityAt?->format('c'), + ), + $establishments, + ); + } +} diff --git a/backend/src/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsQuery.php b/backend/src/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsQuery.php new file mode 100644 index 0000000..4eca411 --- /dev/null +++ b/backend/src/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsQuery.php @@ -0,0 +1,9 @@ +connection->fetchAllAssociative( + <<<'SQL' + SELECT + e.id AS establishment_id, + e.name, + e.status, + COALESCE(m.user_count, 0) AS user_count, + COALESCE(m.student_count, 0) AS student_count, + COALESCE(m.teacher_count, 0) AS teacher_count, + m.last_login_at + FROM establishments e + LEFT JOIN establishment_metrics m ON m.establishment_id = e.id + ORDER BY e.name ASC + SQL, + ); + + return array_map( + static function (array $row): EstablishmentMetricsView { + /** @var string $establishmentId */ + $establishmentId = $row['establishment_id']; + /** @var string $name */ + $name = $row['name']; + /** @var string $status */ + $status = $row['status']; + /** @var int $userCount */ + $userCount = $row['user_count']; + /** @var int $studentCount */ + $studentCount = $row['student_count']; + /** @var int $teacherCount */ + $teacherCount = $row['teacher_count']; + /** @var string|null $lastLoginAt */ + $lastLoginAt = $row['last_login_at']; + + return new EstablishmentMetricsView( + establishmentId: $establishmentId, + name: $name, + status: $status, + userCount: $userCount, + studentCount: $studentCount, + teacherCount: $teacherCount, + lastLoginAt: $lastLoginAt, + ); + }, + $rows, + ); + } +} diff --git a/backend/src/SuperAdmin/Application/Query/GetEstablishmentsMetrics/GetEstablishmentsMetricsQuery.php b/backend/src/SuperAdmin/Application/Query/GetEstablishmentsMetrics/GetEstablishmentsMetricsQuery.php new file mode 100644 index 0000000..f4e5526 --- /dev/null +++ b/backend/src/SuperAdmin/Application/Query/GetEstablishmentsMetrics/GetEstablishmentsMetricsQuery.php @@ -0,0 +1,9 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->establishmentId->value; + } +} diff --git a/backend/src/SuperAdmin/Domain/Event/EtablissementDesactive.php b/backend/src/SuperAdmin/Domain/Event/EtablissementDesactive.php new file mode 100644 index 0000000..615cfa4 --- /dev/null +++ b/backend/src/SuperAdmin/Domain/Event/EtablissementDesactive.php @@ -0,0 +1,32 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->establishmentId->value; + } +} diff --git a/backend/src/SuperAdmin/Domain/Event/SuperAdminCree.php b/backend/src/SuperAdmin/Domain/Event/SuperAdminCree.php new file mode 100644 index 0000000..e53c3d2 --- /dev/null +++ b/backend/src/SuperAdmin/Domain/Event/SuperAdminCree.php @@ -0,0 +1,33 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->superAdminId->value; + } +} diff --git a/backend/src/SuperAdmin/Domain/Event/SuperAdminDesactive.php b/backend/src/SuperAdmin/Domain/Event/SuperAdminDesactive.php new file mode 100644 index 0000000..2fb194f --- /dev/null +++ b/backend/src/SuperAdmin/Domain/Event/SuperAdminDesactive.php @@ -0,0 +1,32 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->superAdminId->value; + } +} diff --git a/backend/src/SuperAdmin/Domain/Exception/EstablishmentDejaInactifException.php b/backend/src/SuperAdmin/Domain/Exception/EstablishmentDejaInactifException.php new file mode 100644 index 0000000..3dcb8a4 --- /dev/null +++ b/backend/src/SuperAdmin/Domain/Exception/EstablishmentDejaInactifException.php @@ -0,0 +1,20 @@ +recordEvent(new EtablissementCree( + establishmentId: $establishment->id, + tenantId: $establishment->tenantId, + name: $name, + subdomain: $subdomain, + occurredOn: $createdAt, + )); + + return $establishment; + } + + public function desactiver(DateTimeImmutable $at): void + { + if ($this->status !== EstablishmentStatus::ACTIF) { + throw EstablishmentDejaInactifException::pour($this->id); + } + + $this->status = EstablishmentStatus::INACTIF; + + $this->recordEvent(new EtablissementDesactive( + establishmentId: $this->id, + occurredOn: $at, + )); + } + + public function enregistrerActivite(DateTimeImmutable $at): void + { + $this->lastActivityAt = $at; + } + + /** + * @internal For Infrastructure use only + */ + public static function reconstitute( + EstablishmentId $id, + TenantId $tenantId, + string $name, + string $subdomain, + string $databaseName, + EstablishmentStatus $status, + DateTimeImmutable $createdAt, + ?SuperAdminId $createdBy = null, + ?DateTimeImmutable $lastActivityAt = null, + ): self { + $establishment = new self( + id: $id, + tenantId: $tenantId, + name: $name, + subdomain: $subdomain, + databaseName: $databaseName, + status: $status, + createdAt: $createdAt, + createdBy: $createdBy, + ); + + $establishment->lastActivityAt = $lastActivityAt; + + return $establishment; + } +} diff --git a/backend/src/SuperAdmin/Domain/Model/Establishment/EstablishmentId.php b/backend/src/SuperAdmin/Domain/Model/Establishment/EstablishmentId.php new file mode 100644 index 0000000..1825907 --- /dev/null +++ b/backend/src/SuperAdmin/Domain/Model/Establishment/EstablishmentId.php @@ -0,0 +1,11 @@ +recordEvent(new SuperAdminCree( + superAdminId: $superAdmin->id, + email: $superAdmin->email, + occurredOn: $createdAt, + )); + + return $superAdmin; + } + + public function enregistrerConnexion(DateTimeImmutable $at): void + { + if ($this->status !== SuperAdminStatus::ACTIF) { + throw SuperAdminNonActifException::pourConnexion($this->id); + } + + $this->lastLoginAt = $at; + } + + public function desactiver(DateTimeImmutable $at): void + { + if ($this->status !== SuperAdminStatus::ACTIF) { + throw SuperAdminNonActifException::pourDesactivation($this->id); + } + + $this->status = SuperAdminStatus::INACTIF; + + $this->recordEvent(new SuperAdminDesactive( + superAdminId: $this->id, + occurredOn: $at, + )); + } + + public function reactiver(DateTimeImmutable $at): void + { + if ($this->status === SuperAdminStatus::ACTIF) { + throw SuperAdminDejaActifException::pourReactivation($this->id); + } + + $this->status = SuperAdminStatus::ACTIF; + } + + public function peutSeConnecter(): bool + { + return $this->status === SuperAdminStatus::ACTIF; + } + + /** + * @internal For Infrastructure use only + */ + public static function reconstitute( + SuperAdminId $id, + string $email, + string $hashedPassword, + string $firstName, + string $lastName, + SuperAdminStatus $status, + DateTimeImmutable $createdAt, + ?DateTimeImmutable $lastLoginAt = null, + ): self { + $superAdmin = new self( + id: $id, + email: $email, + hashedPassword: $hashedPassword, + firstName: $firstName, + lastName: $lastName, + status: $status, + createdAt: $createdAt, + ); + + $superAdmin->lastLoginAt = $lastLoginAt; + + return $superAdmin; + } +} diff --git a/backend/src/SuperAdmin/Domain/Model/SuperAdmin/SuperAdminId.php b/backend/src/SuperAdmin/Domain/Model/SuperAdmin/SuperAdminId.php new file mode 100644 index 0000000..510f3f9 --- /dev/null +++ b/backend/src/SuperAdmin/Domain/Model/SuperAdmin/SuperAdminId.php @@ -0,0 +1,11 @@ + + */ +final readonly class CreateEstablishmentProcessor implements ProcessorInterface +{ + public function __construct( + private CreateEstablishmentHandler $handler, + private Security $security, + ) { + } + + /** + * @param EstablishmentResource $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EstablishmentResource + { + /** @var SecuritySuperAdmin $user */ + $user = $this->security->getUser(); + + $result = ($this->handler)(new CreateEstablishmentCommand( + name: $data->name, + subdomain: $data->subdomain, + adminEmail: $data->adminEmail, + superAdminId: $user->superAdminId(), + )); + + $resource = new EstablishmentResource(); + $resource->id = $result->establishmentId; + $resource->tenantId = $result->tenantId; + $resource->name = $result->name; + $resource->subdomain = $result->subdomain; + $resource->databaseName = $result->databaseName; + $resource->status = 'active'; + + return $resource; + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Api/Processor/SwitchTenantProcessor.php b/backend/src/SuperAdmin/Infrastructure/Api/Processor/SwitchTenantProcessor.php new file mode 100644 index 0000000..92ebf45 --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Api/Processor/SwitchTenantProcessor.php @@ -0,0 +1,40 @@ + + */ +final readonly class SwitchTenantProcessor implements ProcessorInterface +{ + public function __construct( + private SwitchTenantHandler $handler, + ) { + } + + /** + * @param SwitchTenantInput $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void + { + try { + ($this->handler)(new SwitchTenantCommand( + tenantId: $data->tenantId, + )); + } catch (EstablishmentIntrouvableException $e) { + throw new NotFoundHttpException($e->getMessage(), $e); + } + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProvider.php b/backend/src/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProvider.php new file mode 100644 index 0000000..330c1da --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProvider.php @@ -0,0 +1,48 @@ + + */ +final readonly class EstablishmentCollectionProvider implements ProviderInterface +{ + public function __construct( + private GetEstablishmentsHandler $handler, + ) { + } + + /** + * @return EstablishmentResource[] + */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + $views = ($this->handler)(new GetEstablishmentsQuery()); + + return array_map( + static function ($view): EstablishmentResource { + $resource = new EstablishmentResource(); + $resource->id = $view->id; + $resource->tenantId = $view->tenantId; + $resource->name = $view->name; + $resource->subdomain = $view->subdomain; + $resource->status = $view->status; + $resource->createdAt = $view->createdAt; + $resource->lastActivityAt = $view->lastActivityAt; + + return $resource; + }, + $views, + ); + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Api/Provider/EstablishmentItemProvider.php b/backend/src/SuperAdmin/Infrastructure/Api/Provider/EstablishmentItemProvider.php new file mode 100644 index 0000000..38c37ff --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Api/Provider/EstablishmentItemProvider.php @@ -0,0 +1,52 @@ + + */ +final readonly class EstablishmentItemProvider implements ProviderInterface +{ + public function __construct( + private EstablishmentRepository $establishmentRepository, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): EstablishmentResource + { + /** @var string $id */ + $id = $uriVariables['id'] ?? throw new NotFoundHttpException(); + + try { + $establishment = $this->establishmentRepository->get( + EstablishmentId::fromString($id), + ); + } catch (Throwable) { + throw new NotFoundHttpException(); + } + + $resource = new EstablishmentResource(); + $resource->id = (string) $establishment->id; + $resource->tenantId = (string) $establishment->tenantId; + $resource->name = $establishment->name; + $resource->subdomain = $establishment->subdomain; + $resource->databaseName = $establishment->databaseName; + $resource->status = $establishment->status->value; + $resource->createdAt = $establishment->createdAt->format('c'); + $resource->lastActivityAt = $establishment->lastActivityAt?->format('c'); + + return $resource; + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Api/Provider/MetricsProvider.php b/backend/src/SuperAdmin/Infrastructure/Api/Provider/MetricsProvider.php new file mode 100644 index 0000000..657f228 --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Api/Provider/MetricsProvider.php @@ -0,0 +1,48 @@ + + */ +final readonly class MetricsProvider implements ProviderInterface +{ + public function __construct( + private GetEstablishmentsMetricsHandler $handler, + ) { + } + + /** + * @return MetricsResource[] + */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + $metrics = ($this->handler)(new GetEstablishmentsMetricsQuery()); + + return array_map( + static function ($view): MetricsResource { + $resource = new MetricsResource(); + $resource->establishmentId = $view->establishmentId; + $resource->name = $view->name; + $resource->status = $view->status; + $resource->userCount = $view->userCount; + $resource->studentCount = $view->studentCount; + $resource->teacherCount = $view->teacherCount; + $resource->lastLoginAt = $view->lastLoginAt; + + return $resource; + }, + $metrics, + ); + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Api/Resource/EstablishmentResource.php b/backend/src/SuperAdmin/Infrastructure/Api/Resource/EstablishmentResource.php new file mode 100644 index 0000000..3f806f8 --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Api/Resource/EstablishmentResource.php @@ -0,0 +1,62 @@ + ['Default', 'create']], + name: 'super_admin_establishments_create', + ), + ], +)] +final class EstablishmentResource +{ + public ?string $id = null; + public ?string $tenantId = null; + + #[Assert\NotBlank(groups: ['create'])] + #[Assert\Length(max: 255, groups: ['create'])] + public string $name = ''; + + #[Assert\NotBlank(groups: ['create'])] + #[Assert\Length(max: 100, groups: ['create'])] + #[Assert\Regex(pattern: '/^[a-z0-9-]+$/', message: 'Le sous-domaine ne peut contenir que des lettres minuscules, chiffres et tirets.', groups: ['create'])] + public string $subdomain = ''; + + #[Assert\NotBlank(groups: ['create'])] + #[Assert\Email(groups: ['create'])] + public string $adminEmail = ''; + + public ?string $databaseName = null; + public ?string $status = null; + public ?string $createdAt = null; + public ?string $lastActivityAt = null; +} diff --git a/backend/src/SuperAdmin/Infrastructure/Api/Resource/MetricsResource.php b/backend/src/SuperAdmin/Infrastructure/Api/Resource/MetricsResource.php new file mode 100644 index 0000000..a67ff69 --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Api/Resource/MetricsResource.php @@ -0,0 +1,31 @@ +addOption('email', null, InputOption::VALUE_OPTIONAL, 'Email address', 'sadmin@test.com') + ->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Password (plain text)', 'SuperAdmin123') + ->addOption('first-name', null, InputOption::VALUE_OPTIONAL, 'First name', 'Super') + ->addOption('last-name', null, InputOption::VALUE_OPTIONAL, 'Last name', 'Admin'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + /** @var string $email */ + $email = $input->getOption('email'); + /** @var string $password */ + $password = $input->getOption('password'); + /** @var string $firstName */ + $firstName = $input->getOption('first-name'); + /** @var string $lastName */ + $lastName = $input->getOption('last-name'); + + // Check if super admin already exists + $existing = $this->superAdminRepository->findByEmail($email); + if ($existing !== null) { + $io->warning(sprintf('Super admin with email "%s" already exists.', $email)); + + $io->table( + ['Property', 'Value'], + [ + ['Super Admin ID', (string) $existing->id], + ['Email', $email], + ['Password', $password], + ['Status', $existing->status->value], + ] + ); + + return Command::SUCCESS; + } + + $hashedPassword = $this->passwordHasher->hash($password); + $now = $this->clock->now(); + + $superAdmin = SuperAdmin::creer( + email: $email, + hashedPassword: $hashedPassword, + firstName: $firstName, + lastName: $lastName, + createdAt: $now, + ); + + $this->superAdminRepository->save($superAdmin); + + $io->success('Test super admin created successfully!'); + + $io->table( + ['Property', 'Value'], + [ + ['Super Admin ID', (string) $superAdmin->id], + ['Email', $email], + ['Password', $password], + ['First Name', $firstName], + ['Last Name', $lastName], + ['Status', $superAdmin->status->value], + ] + ); + + return Command::SUCCESS; + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Persistence/Doctrine/DoctrineEstablishmentRepository.php b/backend/src/SuperAdmin/Infrastructure/Persistence/Doctrine/DoctrineEstablishmentRepository.php new file mode 100644 index 0000000..2b9f21c --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Persistence/Doctrine/DoctrineEstablishmentRepository.php @@ -0,0 +1,130 @@ +connection->executeStatement( + <<<'SQL' + INSERT INTO establishments (id, tenant_id, name, subdomain, database_name, status, created_at, created_by, last_activity_at) + VALUES (:id, :tenant_id, :name, :subdomain, :database_name, :status, :created_at, :created_by, :last_activity_at) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + status = EXCLUDED.status, + last_activity_at = EXCLUDED.last_activity_at + SQL, + [ + 'id' => (string) $establishment->id, + 'tenant_id' => (string) $establishment->tenantId, + 'name' => $establishment->name, + 'subdomain' => $establishment->subdomain, + 'database_name' => $establishment->databaseName, + 'status' => $establishment->status->value, + 'created_at' => $establishment->createdAt->format('Y-m-d H:i:sP'), + 'created_by' => $establishment->createdBy !== null ? (string) $establishment->createdBy : null, + 'last_activity_at' => $establishment->lastActivityAt?->format('Y-m-d H:i:sP'), + ], + ); + } + + #[Override] + public function get(EstablishmentId $id): Establishment + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM establishments WHERE id = :id', + ['id' => (string) $id], + ); + + if ($row === false) { + throw EstablishmentIntrouvableException::avecId($id); + } + + return $this->hydrate($row); + } + + #[Override] + public function findByTenantId(TenantId $tenantId): ?Establishment + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM establishments WHERE tenant_id = :tenant_id', + ['tenant_id' => (string) $tenantId], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + /** + * @return Establishment[] + */ + #[Override] + public function findAll(): array + { + $rows = $this->connection->fetchAllAssociative( + 'SELECT * FROM establishments ORDER BY name ASC', + ); + + return array_map($this->hydrate(...), $rows); + } + + /** + * @param array $row + */ + private function hydrate(array $row): Establishment + { + /** @var string $id */ + $id = $row['id']; + /** @var string $tenantId */ + $tenantId = $row['tenant_id']; + /** @var string $name */ + $name = $row['name']; + /** @var string $subdomain */ + $subdomain = $row['subdomain']; + /** @var string $databaseName */ + $databaseName = $row['database_name']; + /** @var string $status */ + $status = $row['status']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string|null $createdBy */ + $createdBy = $row['created_by']; + /** @var string|null $lastActivityAt */ + $lastActivityAt = $row['last_activity_at']; + + return Establishment::reconstitute( + id: EstablishmentId::fromString($id), + tenantId: TenantId::fromString($tenantId), + name: $name, + subdomain: $subdomain, + databaseName: $databaseName, + status: EstablishmentStatus::from($status), + createdAt: new DateTimeImmutable($createdAt), + createdBy: $createdBy !== null ? SuperAdminId::fromString($createdBy) : null, + lastActivityAt: $lastActivityAt !== null ? new DateTimeImmutable($lastActivityAt) : null, + ); + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Persistence/Doctrine/DoctrineSuperAdminRepository.php b/backend/src/SuperAdmin/Infrastructure/Persistence/Doctrine/DoctrineSuperAdminRepository.php new file mode 100644 index 0000000..08026e3 --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Persistence/Doctrine/DoctrineSuperAdminRepository.php @@ -0,0 +1,114 @@ +connection->executeStatement( + <<<'SQL' + INSERT INTO super_admins (id, email, password_hash, first_name, last_name, status, created_at, last_login_at) + VALUES (:id, :email, :password_hash, :first_name, :last_name, :status, :created_at, :last_login_at) + ON CONFLICT (id) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, + status = EXCLUDED.status, + last_login_at = EXCLUDED.last_login_at + SQL, + [ + 'id' => (string) $superAdmin->id, + 'email' => $superAdmin->email, + 'password_hash' => $superAdmin->hashedPassword, + 'first_name' => $superAdmin->firstName, + 'last_name' => $superAdmin->lastName, + 'status' => $superAdmin->status->value, + 'created_at' => $superAdmin->createdAt->format('Y-m-d H:i:sP'), + 'last_login_at' => $superAdmin->lastLoginAt?->format('Y-m-d H:i:sP'), + ], + ); + } + + #[Override] + public function get(SuperAdminId $id): SuperAdmin + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM super_admins WHERE id = :id', + ['id' => (string) $id], + ); + + if ($row === false) { + throw SuperAdminIntrouvableException::avecId($id); + } + + return $this->hydrate($row); + } + + #[Override] + public function findByEmail(string $email): ?SuperAdmin + { + $row = $this->connection->fetchAssociative( + 'SELECT * FROM super_admins WHERE email = :email', + ['email' => $email], + ); + + if ($row === false) { + return null; + } + + return $this->hydrate($row); + } + + /** + * @param array $row + */ + private function hydrate(array $row): SuperAdmin + { + /** @var string $id */ + $id = $row['id']; + /** @var string $email */ + $email = $row['email']; + /** @var string $passwordHash */ + $passwordHash = $row['password_hash']; + /** @var string $firstName */ + $firstName = $row['first_name']; + /** @var string $lastName */ + $lastName = $row['last_name']; + /** @var string $status */ + $status = $row['status']; + /** @var string $createdAt */ + $createdAt = $row['created_at']; + /** @var string|null $lastLoginAt */ + $lastLoginAt = $row['last_login_at']; + + return SuperAdmin::reconstitute( + id: SuperAdminId::fromString($id), + email: $email, + hashedPassword: $passwordHash, + firstName: $firstName, + lastName: $lastName, + status: SuperAdminStatus::from($status), + createdAt: new DateTimeImmutable($createdAt), + lastLoginAt: $lastLoginAt !== null ? new DateTimeImmutable($lastLoginAt) : null, + ); + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Persistence/InMemory/InMemoryEstablishmentRepository.php b/backend/src/SuperAdmin/Infrastructure/Persistence/InMemory/InMemoryEstablishmentRepository.php new file mode 100644 index 0000000..f976f0c --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Persistence/InMemory/InMemoryEstablishmentRepository.php @@ -0,0 +1,52 @@ + */ + private array $establishments = []; + + #[Override] + public function save(Establishment $establishment): void + { + $this->establishments[(string) $establishment->id] = $establishment; + } + + #[Override] + public function get(EstablishmentId $id): Establishment + { + return $this->establishments[(string) $id] + ?? throw EstablishmentIntrouvableException::avecId($id); + } + + #[Override] + public function findByTenantId(TenantId $tenantId): ?Establishment + { + foreach ($this->establishments as $establishment) { + if ($establishment->tenantId->equals($tenantId)) { + return $establishment; + } + } + + return null; + } + + /** + * @return Establishment[] + */ + #[Override] + public function findAll(): array + { + return array_values($this->establishments); + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Persistence/InMemory/InMemorySuperAdminRepository.php b/backend/src/SuperAdmin/Infrastructure/Persistence/InMemory/InMemorySuperAdminRepository.php new file mode 100644 index 0000000..f411a80 --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Persistence/InMemory/InMemorySuperAdminRepository.php @@ -0,0 +1,42 @@ + */ + private array $superAdmins = []; + + #[Override] + public function save(SuperAdmin $superAdmin): void + { + $this->superAdmins[(string) $superAdmin->id] = $superAdmin; + } + + #[Override] + public function get(SuperAdminId $id): SuperAdmin + { + return $this->superAdmins[(string) $id] + ?? throw SuperAdminIntrouvableException::avecId($id); + } + + #[Override] + public function findByEmail(string $email): ?SuperAdmin + { + foreach ($this->superAdmins as $superAdmin) { + if ($superAdmin->email === $email) { + return $superAdmin; + } + } + + return null; + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Security/SecuritySuperAdmin.php b/backend/src/SuperAdmin/Infrastructure/Security/SecuritySuperAdmin.php new file mode 100644 index 0000000..b4e333d --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Security/SecuritySuperAdmin.php @@ -0,0 +1,57 @@ +email = $email; + } + + #[Override] + public function getUserIdentifier(): string + { + return $this->email; + } + + public function superAdminId(): string + { + return (string) $this->superAdminId; + } + + #[Override] + public function getPassword(): string + { + return $this->hashedPassword; + } + + /** + * @return list + */ + #[Override] + public function getRoles(): array + { + return ['ROLE_SUPER_ADMIN']; + } + + public function eraseCredentials(): void + { + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Security/SuperAdminTenantContext.php b/backend/src/SuperAdmin/Infrastructure/Security/SuperAdminTenantContext.php new file mode 100644 index 0000000..c7c83bc --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Security/SuperAdminTenantContext.php @@ -0,0 +1,38 @@ +currentTenantId = $tenantId; + } + + public function currentTenantId(): ?TenantId + { + return $this->currentTenantId; + } + + public function hasTenant(): bool + { + return $this->currentTenantId !== null; + } + + public function clear(): void + { + $this->currentTenantId = null; + } +} diff --git a/backend/src/SuperAdmin/Infrastructure/Security/SuperAdminUserProvider.php b/backend/src/SuperAdmin/Infrastructure/Security/SuperAdminUserProvider.php new file mode 100644 index 0000000..cae1573 --- /dev/null +++ b/backend/src/SuperAdmin/Infrastructure/Security/SuperAdminUserProvider.php @@ -0,0 +1,67 @@ + + */ +final readonly class SuperAdminUserProvider implements UserProviderInterface +{ + public function __construct( + private SuperAdminRepository $superAdminRepository, + ) { + } + + #[Override] + public function loadUserByIdentifier(string $identifier): UserInterface + { + $superAdmin = $this->superAdminRepository->findByEmail($identifier); + + if ($superAdmin === null) { + throw new SymfonyUserNotFoundException(); + } + + if (!$superAdmin->peutSeConnecter()) { + throw new SymfonyUserNotFoundException(); + } + + /** @var non-empty-string $email */ + $email = $superAdmin->email; + + return new SecuritySuperAdmin( + superAdminId: $superAdmin->id, + email: $email, + hashedPassword: $superAdmin->hashedPassword, + ); + } + + #[Override] + public function refreshUser(UserInterface $user): UserInterface + { + if (!$user instanceof SecuritySuperAdmin) { + throw new InvalidArgumentException('Expected instance of ' . SecuritySuperAdmin::class); + } + + return $this->loadUserByIdentifier($user->getUserIdentifier()); + } + + #[Override] + public function supportsClass(string $class): bool + { + return $class === SecuritySuperAdmin::class; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Provider/MyRolesProviderTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/MyRolesProviderTest.php new file mode 100644 index 0000000..0bf42b4 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Provider/MyRolesProviderTest.php @@ -0,0 +1,129 @@ +createMock(Security::class); + $security->method('getUser')->willReturn( + new SecuritySuperAdmin( + superAdminId: SuperAdminId::generate(), + email: 'sadmin@test.com', + hashedPassword: 'hashed', + ) + ); + + $userRepository = $this->createMock(UserRepository::class); + $roleContext = new RoleContext(new NullActiveRoleStore()); + + $provider = new MyRolesProvider($security, $userRepository, $roleContext); + $output = $provider->provide(new Get()); + + self::assertSame('ROLE_SUPER_ADMIN', $output->activeRole); + self::assertSame('Super Admin', $output->activeRoleLabel); + self::assertCount(1, $output->roles); + self::assertSame('ROLE_SUPER_ADMIN', $output->roles[0]['value']); + self::assertSame('Super Admin', $output->roles[0]['label']); + } + + #[Test] + public function provideReturnsUserRolesForSecurityUser(): void + { + $userId = UserId::generate(); + $tenantId = TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'); + + $securityUser = new SecurityUser( + userId: $userId, + email: 'user@example.com', + hashedPassword: 'hashed', + tenantId: $tenantId, + roles: ['ROLE_PARENT'], + ); + + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn($securityUser); + + $user = User::reconstitute( + id: $userId, + email: new Email('user@example.com'), + roles: [Role::PARENT], + tenantId: $tenantId, + schoolName: 'Test', + statut: StatutCompte::ACTIF, + dateNaissance: null, + createdAt: new DateTimeImmutable(), + hashedPassword: 'hashed', + activatedAt: new DateTimeImmutable(), + consentementParental: null, + ); + + $userRepository = $this->createMock(UserRepository::class); + $userRepository->method('get')->willReturn($user); + + $roleContext = new RoleContext(new NullActiveRoleStore()); + + $provider = new MyRolesProvider($security, $userRepository, $roleContext); + $output = $provider->provide(new Get()); + + self::assertSame('ROLE_PARENT', $output->activeRole); + } + + #[Test] + public function provideThrowsUnauthorizedForUnknownUserType(): void + { + $security = $this->createMock(Security::class); + $security->method('getUser')->willReturn(null); + + $userRepository = $this->createMock(UserRepository::class); + $roleContext = new RoleContext(new NullActiveRoleStore()); + + $provider = new MyRolesProvider($security, $userRepository, $roleContext); + + $this->expectException(UnauthorizedHttpException::class); + $provider->provide(new Get()); + } +} + +/** + * @internal + */ +final class NullActiveRoleStore implements ActiveRoleStore +{ + public function store(User $user, Role $role): void + { + } + + public function get(User $user): ?Role + { + return null; + } + + public function clear(User $user): void + { + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/JwtPayloadEnricherSuperAdminTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/JwtPayloadEnricherSuperAdminTest.php new file mode 100644 index 0000000..6dce427 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Security/JwtPayloadEnricherSuperAdminTest.php @@ -0,0 +1,71 @@ +enricher = new JwtPayloadEnricher(); + } + + #[Test] + public function onJWTCreatedAddsSuperAdminClaimsToPayload(): void + { + $superAdminId = SuperAdminId::generate(); + + $securitySuperAdmin = new SecuritySuperAdmin( + superAdminId: $superAdminId, + email: 'sadmin@test.com', + hashedPassword: 'hashed', + ); + + $initialPayload = ['username' => 'sadmin@test.com']; + $event = new JWTCreatedEvent($initialPayload, $securitySuperAdmin); + + $this->enricher->onJWTCreated($event); + + $payload = $event->getData(); + + self::assertSame((string) $superAdminId, $payload['user_id']); + self::assertSame('super_admin', $payload['user_type']); + self::assertSame(['ROLE_SUPER_ADMIN'], $payload['roles']); + self::assertArrayNotHasKey('tenant_id', $payload); + } + + #[Test] + public function onJWTCreatedPreservesExistingPayloadForSuperAdmin(): void + { + $securitySuperAdmin = new SecuritySuperAdmin( + superAdminId: SuperAdminId::generate(), + email: 'sadmin@test.com', + hashedPassword: 'hashed', + ); + + $initialPayload = [ + 'username' => 'sadmin@test.com', + 'iat' => 1706436600, + 'exp' => 1706438400, + ]; + $event = new JWTCreatedEvent($initialPayload, $securitySuperAdmin); + + $this->enricher->onJWTCreated($event); + + $payload = $event->getData(); + + self::assertSame('sadmin@test.com', $payload['username']); + self::assertSame(1706436600, $payload['iat']); + self::assertSame(1706438400, $payload['exp']); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandlerTest.php b/backend/tests/Unit/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandlerTest.php new file mode 100644 index 0000000..8345d96 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Application/Command/CreateEstablishment/CreateEstablishmentHandlerTest.php @@ -0,0 +1,74 @@ +repository = new InMemoryEstablishmentRepository(); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-16 10:00:00'); + } + }; + + $this->handler = new CreateEstablishmentHandler( + $this->repository, + $clock, + ); + } + + #[Test] + public function createsEstablishmentAndReturnsResult(): void + { + $command = new CreateEstablishmentCommand( + name: 'École Alpha', + subdomain: 'ecole-alpha', + adminEmail: 'admin@ecole-alpha.fr', + superAdminId: self::SUPER_ADMIN_ID, + ); + + $result = ($this->handler)($command); + + self::assertNotEmpty($result->establishmentId); + self::assertNotEmpty($result->tenantId); + self::assertSame('École Alpha', $result->name); + self::assertSame('ecole-alpha', $result->subdomain); + self::assertStringStartsWith('classeo_tenant_', $result->databaseName); + } + + #[Test] + public function savesEstablishmentToRepository(): void + { + $command = new CreateEstablishmentCommand( + name: 'École Beta', + subdomain: 'ecole-beta', + adminEmail: 'admin@ecole-beta.fr', + superAdminId: self::SUPER_ADMIN_ID, + ); + + $result = ($this->handler)($command); + + $establishments = $this->repository->findAll(); + self::assertCount(1, $establishments); + self::assertSame($result->establishmentId, (string) $establishments[0]->id); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsHandlerTest.php b/backend/tests/Unit/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsHandlerTest.php new file mode 100644 index 0000000..0252806 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Application/Query/GetEstablishments/GetEstablishmentsHandlerTest.php @@ -0,0 +1,61 @@ +repository = new InMemoryEstablishmentRepository(); + $this->handler = new GetEstablishmentsHandler($this->repository); + } + + #[Test] + public function returnsEmptyArrayWhenNoEstablishments(): void + { + $result = ($this->handler)(new GetEstablishmentsQuery()); + + self::assertSame([], $result); + } + + #[Test] + public function returnsAllEstablishments(): void + { + $this->repository->save(Establishment::creer( + name: 'École Alpha', + subdomain: 'ecole-alpha', + createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), + createdAt: new DateTimeImmutable('2026-02-16 10:00:00'), + )); + + $this->repository->save(Establishment::creer( + name: 'École Beta', + subdomain: 'ecole-beta', + createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), + createdAt: new DateTimeImmutable('2026-02-16 11:00:00'), + )); + + $result = ($this->handler)(new GetEstablishmentsQuery()); + + self::assertCount(2, $result); + self::assertSame('École Alpha', $result[0]->name); + self::assertSame('ecole-alpha', $result[0]->subdomain); + self::assertSame('active', $result[0]->status); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Domain/Model/Establishment/EstablishmentTest.php b/backend/tests/Unit/SuperAdmin/Domain/Model/Establishment/EstablishmentTest.php new file mode 100644 index 0000000..f0951a9 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Domain/Model/Establishment/EstablishmentTest.php @@ -0,0 +1,148 @@ +createEstablishment(); + + self::assertSame(EstablishmentStatus::ACTIF, $establishment->status); + self::assertSame(self::ESTABLISHMENT_NAME, $establishment->name); + self::assertSame(self::SUBDOMAIN, $establishment->subdomain); + self::assertNull($establishment->lastActivityAt); + self::assertNotEmpty($establishment->databaseName); + self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName); + } + + #[Test] + public function creerRecordsEtablissementCreeEvent(): void + { + $establishment = $this->createEstablishment(); + + $events = $establishment->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(EtablissementCree::class, $events[0]); + self::assertTrue($establishment->id->equals($events[0]->establishmentId)); + self::assertTrue($establishment->tenantId->equals($events[0]->tenantId)); + self::assertSame(self::ESTABLISHMENT_NAME, $events[0]->name); + self::assertSame(self::SUBDOMAIN, $events[0]->subdomain); + } + + #[Test] + public function creerGeneratesTenantIdAndDatabaseName(): void + { + $establishment = $this->createEstablishment(); + + self::assertInstanceOf(TenantId::class, $establishment->tenantId); + self::assertStringStartsWith('classeo_tenant_', $establishment->databaseName); + } + + #[Test] + public function desactiverChangesStatusToInactif(): void + { + $establishment = $this->createEstablishment(); + + $establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00')); + + self::assertSame(EstablishmentStatus::INACTIF, $establishment->status); + } + + #[Test] + public function desactiverRecordsEtablissementDesactiveEvent(): void + { + $establishment = $this->createEstablishment(); + $establishment->pullDomainEvents(); // Clear creation event + + $establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00')); + + $events = $establishment->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(EtablissementDesactive::class, $events[0]); + } + + #[Test] + public function desactiverThrowsWhenAlreadyInactive(): void + { + $establishment = $this->createEstablishment(); + $establishment->desactiver(new DateTimeImmutable('2026-02-16 12:00:00')); + + $this->expectException(EstablishmentDejaInactifException::class); + $establishment->desactiver(new DateTimeImmutable('2026-02-16 13:00:00')); + } + + #[Test] + public function enregistrerActiviteUpdatesLastActivityAt(): void + { + $establishment = $this->createEstablishment(); + $activityAt = new DateTimeImmutable('2026-02-16 15:00:00'); + + $establishment->enregistrerActivite($activityAt); + + self::assertEquals($activityAt, $establishment->lastActivityAt); + } + + #[Test] + public function reconstituteRestoresAllProperties(): void + { + $id = EstablishmentId::generate(); + $tenantId = TenantId::generate(); + $createdBy = SuperAdminId::fromString(self::SUPER_ADMIN_ID); + $createdAt = new DateTimeImmutable('2026-01-01 10:00:00'); + $lastActivityAt = new DateTimeImmutable('2026-02-16 14:30:00'); + + $establishment = Establishment::reconstitute( + id: $id, + tenantId: $tenantId, + name: self::ESTABLISHMENT_NAME, + subdomain: self::SUBDOMAIN, + databaseName: 'classeo_tenant_abc123', + status: EstablishmentStatus::ACTIF, + createdAt: $createdAt, + createdBy: $createdBy, + lastActivityAt: $lastActivityAt, + ); + + self::assertTrue($id->equals($establishment->id)); + self::assertTrue($tenantId->equals($establishment->tenantId)); + self::assertSame(self::ESTABLISHMENT_NAME, $establishment->name); + self::assertSame(self::SUBDOMAIN, $establishment->subdomain); + self::assertSame('classeo_tenant_abc123', $establishment->databaseName); + self::assertSame(EstablishmentStatus::ACTIF, $establishment->status); + self::assertEquals($createdAt, $establishment->createdAt); + self::assertTrue($createdBy->equals($establishment->createdBy)); + self::assertEquals($lastActivityAt, $establishment->lastActivityAt); + self::assertEmpty($establishment->pullDomainEvents()); + } + + private function createEstablishment(): Establishment + { + return Establishment::creer( + name: self::ESTABLISHMENT_NAME, + subdomain: self::SUBDOMAIN, + createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), + createdAt: new DateTimeImmutable('2026-02-16 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Domain/Model/SuperAdmin/SuperAdminTest.php b/backend/tests/Unit/SuperAdmin/Domain/Model/SuperAdmin/SuperAdminTest.php new file mode 100644 index 0000000..027b3a5 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Domain/Model/SuperAdmin/SuperAdminTest.php @@ -0,0 +1,181 @@ +createSuperAdmin(); + + self::assertSame(SuperAdminStatus::ACTIF, $superAdmin->status); + self::assertSame(self::EMAIL, $superAdmin->email); + self::assertSame(self::FIRST_NAME, $superAdmin->firstName); + self::assertSame(self::LAST_NAME, $superAdmin->lastName); + self::assertSame(self::HASHED_PASSWORD, $superAdmin->hashedPassword); + self::assertNull($superAdmin->lastLoginAt); + } + + #[Test] + public function creerRecordsSuperAdminCreeEvent(): void + { + $superAdmin = $this->createSuperAdmin(); + + $events = $superAdmin->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(SuperAdminCree::class, $events[0]); + self::assertSame($superAdmin->id, $events[0]->superAdminId); + self::assertSame(self::EMAIL, $events[0]->email); + } + + #[Test] + public function enregistrerConnexionUpdatesLastLoginAt(): void + { + $superAdmin = $this->createSuperAdmin(); + $loginAt = new DateTimeImmutable('2026-02-16 14:30:00'); + + $superAdmin->enregistrerConnexion($loginAt); + + self::assertEquals($loginAt, $superAdmin->lastLoginAt); + } + + #[Test] + public function enregistrerConnexionThrowsWhenNotActive(): void + { + $superAdmin = $this->createSuperAdmin(); + $superAdmin->desactiver(new DateTimeImmutable('2026-02-16 10:00:00')); + + $this->expectException(SuperAdminNonActifException::class); + $superAdmin->enregistrerConnexion(new DateTimeImmutable('2026-02-16 14:30:00')); + } + + #[Test] + public function desactiverChangeStatusToInactif(): void + { + $superAdmin = $this->createSuperAdmin(); + + $superAdmin->desactiver(new DateTimeImmutable('2026-02-16 10:00:00')); + + self::assertSame(SuperAdminStatus::INACTIF, $superAdmin->status); + } + + #[Test] + public function desactiverRecordsSuperAdminDesactiveEvent(): void + { + $superAdmin = $this->createSuperAdmin(); + $superAdmin->pullDomainEvents(); // Clear creation event + + $superAdmin->desactiver(new DateTimeImmutable('2026-02-16 10:00:00')); + + $events = $superAdmin->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(SuperAdminDesactive::class, $events[0]); + } + + #[Test] + public function desactiverThrowsWhenAlreadyInactive(): void + { + $superAdmin = $this->createSuperAdmin(); + $superAdmin->desactiver(new DateTimeImmutable('2026-02-16 10:00:00')); + + $this->expectException(SuperAdminNonActifException::class); + $superAdmin->desactiver(new DateTimeImmutable('2026-02-16 11:00:00')); + } + + #[Test] + public function reactiverChangesStatusToActif(): void + { + $superAdmin = $this->createSuperAdmin(); + $superAdmin->desactiver(new DateTimeImmutable('2026-02-16 10:00:00')); + + $superAdmin->reactiver(new DateTimeImmutable('2026-02-16 11:00:00')); + + self::assertSame(SuperAdminStatus::ACTIF, $superAdmin->status); + } + + #[Test] + public function reactiverThrowsWhenAlreadyActive(): void + { + $superAdmin = $this->createSuperAdmin(); + + $this->expectException(SuperAdminDejaActifException::class); + $superAdmin->reactiver(new DateTimeImmutable('2026-02-16 11:00:00')); + } + + #[Test] + public function peutSeConnecterReturnsTrueWhenActive(): void + { + $superAdmin = $this->createSuperAdmin(); + + self::assertTrue($superAdmin->peutSeConnecter()); + } + + #[Test] + public function peutSeConnecterReturnsFalseWhenInactive(): void + { + $superAdmin = $this->createSuperAdmin(); + $superAdmin->desactiver(new DateTimeImmutable('2026-02-16 10:00:00')); + + self::assertFalse($superAdmin->peutSeConnecter()); + } + + #[Test] + public function reconstituteRestoresAllProperties(): void + { + $id = SuperAdminId::generate(); + $createdAt = new DateTimeImmutable('2026-01-01 10:00:00'); + $lastLoginAt = new DateTimeImmutable('2026-02-16 14:30:00'); + + $superAdmin = SuperAdmin::reconstitute( + id: $id, + email: self::EMAIL, + hashedPassword: self::HASHED_PASSWORD, + firstName: self::FIRST_NAME, + lastName: self::LAST_NAME, + status: SuperAdminStatus::ACTIF, + createdAt: $createdAt, + lastLoginAt: $lastLoginAt, + ); + + self::assertTrue($id->equals($superAdmin->id)); + self::assertSame(self::EMAIL, $superAdmin->email); + self::assertSame(self::HASHED_PASSWORD, $superAdmin->hashedPassword); + self::assertSame(self::FIRST_NAME, $superAdmin->firstName); + self::assertSame(self::LAST_NAME, $superAdmin->lastName); + self::assertSame(SuperAdminStatus::ACTIF, $superAdmin->status); + self::assertEquals($createdAt, $superAdmin->createdAt); + self::assertEquals($lastLoginAt, $superAdmin->lastLoginAt); + self::assertEmpty($superAdmin->pullDomainEvents()); + } + + private function createSuperAdmin(): SuperAdmin + { + return SuperAdmin::creer( + email: self::EMAIL, + hashedPassword: self::HASHED_PASSWORD, + firstName: self::FIRST_NAME, + lastName: self::LAST_NAME, + createdAt: new DateTimeImmutable('2026-02-16 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessorTest.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessorTest.php new file mode 100644 index 0000000..f8e6c34 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Processor/CreateEstablishmentProcessorTest.php @@ -0,0 +1,60 @@ +createMock(Security::class); + $security->method('getUser')->willReturn($securityUser); + + $processor = new CreateEstablishmentProcessor($handler, $security); + + $input = new EstablishmentResource(); + $input->name = 'École Gamma'; + $input->subdomain = 'ecole-gamma'; + $input->adminEmail = 'admin@ecole-gamma.fr'; + + $result = $processor->process($input, new Post()); + + self::assertNotNull($result->id); + self::assertNotNull($result->tenantId); + self::assertSame('École Gamma', $result->name); + self::assertSame('ecole-gamma', $result->subdomain); + self::assertSame('active', $result->status); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProviderTest.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProviderTest.php new file mode 100644 index 0000000..94771e2 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Api/Provider/EstablishmentCollectionProviderTest.php @@ -0,0 +1,54 @@ +provide(new GetCollection()); + + self::assertSame([], $result); + } + + #[Test] + public function provideReturnsMappedResources(): void + { + $repository = new InMemoryEstablishmentRepository(); + $repository->save(Establishment::creer( + name: 'École Alpha', + subdomain: 'ecole-alpha', + createdBy: SuperAdminId::fromString(self::SUPER_ADMIN_ID), + createdAt: new DateTimeImmutable('2026-02-16 10:00:00'), + )); + + $handler = new GetEstablishmentsHandler($repository); + $provider = new EstablishmentCollectionProvider($handler); + + $result = $provider->provide(new GetCollection()); + + self::assertCount(1, $result); + self::assertSame('École Alpha', $result[0]->name); + self::assertSame('ecole-alpha', $result[0]->subdomain); + self::assertSame('active', $result[0]->status); + } +} diff --git a/backend/tests/Unit/SuperAdmin/Infrastructure/Console/CreateTestSuperAdminCommandTest.php b/backend/tests/Unit/SuperAdmin/Infrastructure/Console/CreateTestSuperAdminCommandTest.php new file mode 100644 index 0000000..53ef6b3 --- /dev/null +++ b/backend/tests/Unit/SuperAdmin/Infrastructure/Console/CreateTestSuperAdminCommandTest.php @@ -0,0 +1,106 @@ +repository = new InMemorySuperAdminRepository(); + + $clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-02-17 10:00:00'); + } + }; + + $passwordHasher = new class implements PasswordHasher { + public function hash(string $plainPassword): string + { + return 'hashed_' . $plainPassword; + } + + public function verify(string $hashedPassword, string $plainPassword): bool + { + return $hashedPassword === 'hashed_' . $plainPassword; + } + }; + + $command = new CreateTestSuperAdminCommand( + $this->repository, + $passwordHasher, + $clock, + ); + + $this->commandTester = new CommandTester($command); + } + + #[Test] + public function createsNewSuperAdmin(): void + { + $exitCode = $this->commandTester->execute([ + '--email' => 'sadmin@test.com', + '--password' => 'SuperAdmin123', + '--first-name' => 'Super', + '--last-name' => 'Admin', + ]); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringContainsString('Test super admin created successfully', $this->commandTester->getDisplay()); + + $superAdmin = $this->repository->findByEmail('sadmin@test.com'); + self::assertNotNull($superAdmin); + self::assertSame('sadmin@test.com', $superAdmin->email); + self::assertSame('Super', $superAdmin->firstName); + self::assertSame('Admin', $superAdmin->lastName); + self::assertSame('hashed_SuperAdmin123', $superAdmin->hashedPassword); + } + + #[Test] + public function returnsSuccessForExistingEmail(): void + { + // Create first + $this->commandTester->execute([ + '--email' => 'sadmin@test.com', + '--password' => 'SuperAdmin123', + ]); + + // Create again with same email + $exitCode = $this->commandTester->execute([ + '--email' => 'sadmin@test.com', + '--password' => 'SuperAdmin123', + ]); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringContainsString('already exists', $this->commandTester->getDisplay()); + } + + #[Test] + public function usesDefaultValues(): void + { + $exitCode = $this->commandTester->execute([]); + + self::assertSame(Command::SUCCESS, $exitCode); + + $superAdmin = $this->repository->findByEmail('sadmin@test.com'); + self::assertNotNull($superAdmin); + self::assertSame('Super', $superAdmin->firstName); + self::assertSame('Admin', $superAdmin->lastName); + } +} diff --git a/frontend/e2e/super-admin.spec.ts b/frontend/e2e/super-admin.spec.ts new file mode 100644 index 0000000..2ba3a55 --- /dev/null +++ b/frontend/e2e/super-admin.spec.ts @@ -0,0 +1,208 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const projectRoot = join(__dirname, '../..'); +const composeFile = join(projectRoot, 'compose.yaml'); + +// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts) +const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const urlMatch = baseUrl.match(/:(\d+)$/); +const PORT = urlMatch ? urlMatch[1] : '4173'; +const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`; + +const SA_PASSWORD = 'SuperAdmin123'; +const REGULAR_PASSWORD = 'TestPassword123'; + +function getSuperAdminEmail(browserName: string): string { + return `e2e-sadmin-${browserName}@test.com`; +} + +function getRegularUserEmail(browserName: string): string { + return `e2e-sadmin-regular-${browserName}@example.com`; +} + +// eslint-disable-next-line no-empty-pattern +test.beforeAll(async ({}, testInfo) => { + const browserName = testInfo.project.name; + const saEmail = getSuperAdminEmail(browserName); + const regularEmail = getRegularUserEmail(browserName); + + try { + // Create a test super admin + const saResult = execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-super-admin --email=${saEmail} --password=${SA_PASSWORD} 2>&1`, + { encoding: 'utf-8' } + ); + console.warn( + `[${browserName}] Super admin created or exists:`, + saResult.includes('already exists') ? 'exists' : 'created' + ); + + // Create a regular user (for access control test) + const userResult = execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --email=${regularEmail} --password=${REGULAR_PASSWORD} 2>&1`, + { encoding: 'utf-8' } + ); + console.warn( + `[${browserName}] Regular user created or exists:`, + userResult.includes('already exists') ? 'exists' : 'created' + ); + } catch (error) { + console.error(`[${browserName}] Failed to create test users:`, error); + } +}); + +async function loginAsSuperAdmin( + page: import('@playwright/test').Page, + email: string +) { + await page.goto(`${ALPHA_URL}/login`); + await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible(); + + await page.locator('#email').fill(email); + await page.locator('#password').fill(SA_PASSWORD); + + const submitButton = page.getByRole('button', { name: /se connecter/i }); + await Promise.all([ + page.waitForURL('**/super-admin/dashboard', { timeout: 30000 }), + submitButton.click() + ]); +} + +test.describe('Super Admin', () => { + test.describe('Login & Redirect', () => { + test('super admin login redirects to /super-admin/dashboard', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + + await loginAsSuperAdmin(page, email); + + await expect(page).toHaveURL(/\/super-admin\/dashboard/); + }); + }); + + test.describe('Dashboard', () => { + test('dashboard displays stats cards', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + + await loginAsSuperAdmin(page, email); + + // The dashboard should show stat cards + await expect(page.locator('.stat-card').first()).toBeVisible({ timeout: 10000 }); + + // Verify dashboard heading + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + }); + }); + + test.describe('Navigation', () => { + test('navigates to establishments page', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + + await loginAsSuperAdmin(page, email); + + const etablissementsLink = page.getByRole('link', { name: /établissements/i }); + await Promise.all([ + page.waitForURL('**/super-admin/establishments', { timeout: 10000 }), + etablissementsLink.click() + ]); + + await expect(page).toHaveURL(/\/super-admin\/establishments/); + await expect( + page.getByRole('heading', { name: /établissements/i }) + ).toBeVisible(); + }); + + test('establishments page has create button', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + + await loginAsSuperAdmin(page, email); + + // Navigate via SPA link (page.goto would reload and lose in-memory token) + const etablissementsLink = page.getByRole('link', { name: /établissements/i }); + await Promise.all([ + page.waitForURL('**/super-admin/establishments', { timeout: 10000 }), + etablissementsLink.click() + ]); + + await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ + timeout: 10000 + }); + + // Check "Nouvel établissement" button/link + await expect( + page.getByRole('link', { name: /nouvel établissement/i }) + ).toBeVisible(); + }); + }); + + test.describe('Create Establishment Form', () => { + test('new establishment form has required fields', async ({ page }, testInfo) => { + const email = getSuperAdminEmail(testInfo.project.name); + + await loginAsSuperAdmin(page, email); + + // Navigate via SPA links (page.goto would reload and lose in-memory token) + const etablissementsLink = page.getByRole('link', { name: /établissements/i }); + await Promise.all([ + page.waitForURL('**/super-admin/establishments', { timeout: 10000 }), + etablissementsLink.click() + ]); + + const newLink = page.getByRole('link', { name: /nouvel établissement/i }); + await expect(newLink).toBeVisible({ timeout: 10000 }); + await Promise.all([ + page.waitForURL('**/super-admin/establishments/new', { timeout: 10000 }), + newLink.click() + ]); + + // Verify form fields + await expect(page.locator('#name')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#subdomain')).toBeVisible(); + await expect(page.locator('#adminEmail')).toBeVisible(); + + // Submit button should be disabled when empty + const submitButton = page.getByRole('button', { + name: /créer l'établissement/i + }); + await expect(submitButton).toBeDisabled(); + + // Fill in the form + await page.locator('#name').fill('École Test E2E'); + await page.locator('#adminEmail').fill('admin-e2e@test.com'); + + // Subdomain should be auto-generated + await expect(page.locator('#subdomain')).not.toHaveValue(''); + + // Submit button should be enabled + await expect(submitButton).toBeEnabled(); + }); + }); + + test.describe('Access Control', () => { + test('regular user is redirected away from /super-admin', async ({ page }, testInfo) => { + const regularEmail = getRegularUserEmail(testInfo.project.name); + + // Login as regular user on alpha tenant + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(regularEmail); + await page.locator('#password').fill(REGULAR_PASSWORD); + + const submitButton = page.getByRole('button', { name: /se connecter/i }); + await Promise.all([ + page.waitForURL('**/dashboard', { timeout: 30000 }), + submitButton.click() + ]); + + // Try to navigate to super-admin area + await page.goto(`${ALPHA_URL}/super-admin/dashboard`); + + // Should be redirected away (to /dashboard since not super admin) + await expect(page).not.toHaveURL(/\/super-admin/, { timeout: 10000 }); + }); + }); +}); diff --git a/frontend/src/lib/auth/auth.svelte.ts b/frontend/src/lib/auth/auth.svelte.ts index 1eeeb5e..16e2314 100644 --- a/frontend/src/lib/auth/auth.svelte.ts +++ b/frontend/src/lib/auth/auth.svelte.ts @@ -327,6 +327,18 @@ export function isAuthenticated(): boolean { return accessToken !== null; } +/** + * Parse les rôles depuis le JWT en mémoire. + * Utilisé pour la redirection post-login (super admin vs utilisateur normal). + */ +export function getJwtRoles(): string[] { + if (!accessToken) return []; + const payload = parseJwtPayload(accessToken); + if (!payload) return []; + const roles = payload['roles']; + return Array.isArray(roles) ? roles : []; +} + /** * Retourne le token actuel (pour debug uniquement). */ diff --git a/frontend/src/lib/auth/index.ts b/frontend/src/lib/auth/index.ts index b16b07a..8f53137 100644 --- a/frontend/src/lib/auth/index.ts +++ b/frontend/src/lib/auth/index.ts @@ -5,6 +5,7 @@ export { authenticatedFetch, isAuthenticated, getAccessToken, + getJwtRoles, getCurrentUserId, type LoginCredentials, type LoginResult, diff --git a/frontend/src/lib/features/super-admin/api/super-admin.ts b/frontend/src/lib/features/super-admin/api/super-admin.ts new file mode 100644 index 0000000..749f1a0 --- /dev/null +++ b/frontend/src/lib/features/super-admin/api/super-admin.ts @@ -0,0 +1,81 @@ +import { getApiBaseUrl } from '$lib/api/config'; +import { authenticatedFetch } from '$lib/auth/auth.svelte'; + +const apiUrl = getApiBaseUrl(); + +export interface EstablishmentData { + id: string; + tenantId: string; + name: string; + subdomain: string; + databaseName?: string; + status: string; + createdAt?: string; + lastActivityAt?: string; +} + +export interface EstablishmentMetrics { + establishmentId: string; + name: string; + status: string; + userCount: number; + studentCount: number; + teacherCount: number; + lastLoginAt: string | null; +} + +export interface CreateEstablishmentInput { + name: string; + subdomain: string; + adminEmail: string; +} + +export async function getEstablishments(): Promise { + const response = await authenticatedFetch(`${apiUrl}/super-admin/establishments`); + if (!response.ok) { + throw new Error('Erreur lors du chargement des établissements'); + } + const data = await response.json(); + return data['hydra:member'] ?? data['member'] ?? data; +} + +export async function getEstablishment(id: string): Promise { + const response = await authenticatedFetch(`${apiUrl}/super-admin/establishments/${id}`); + if (!response.ok) { + throw new Error('Établissement introuvable'); + } + return response.json(); +} + +export async function createEstablishment(input: CreateEstablishmentInput): Promise { + const response = await authenticatedFetch(`${apiUrl}/super-admin/establishments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input) + }); + if (!response.ok) { + const error = await response.json().catch(() => null); + throw new Error(error?.message ?? 'Erreur lors de la création'); + } + return response.json(); +} + +export async function getMetrics(): Promise { + const response = await authenticatedFetch(`${apiUrl}/super-admin/metrics`); + if (!response.ok) { + throw new Error('Erreur lors du chargement des métriques'); + } + const data = await response.json(); + return data['hydra:member'] ?? data['member'] ?? data; +} + +export async function switchTenant(tenantId: string): Promise { + const response = await authenticatedFetch(`${apiUrl}/super-admin/switch-tenant`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tenantId }) + }); + if (!response.ok) { + throw new Error('Erreur lors du basculement de contexte'); + } +} diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 3142cb1..9123633 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -1,7 +1,7 @@ + +{#if hasAccess} +
+
+
+
+ +

Classeo Super Admin

+
+ +
+
+ +
+ {@render children()} +
+
+{:else} +
+

Vérification des accès...

+
+{/if} + + diff --git a/frontend/src/routes/super-admin/dashboard/+page.svelte b/frontend/src/routes/super-admin/dashboard/+page.svelte new file mode 100644 index 0000000..e0e1b52 --- /dev/null +++ b/frontend/src/routes/super-admin/dashboard/+page.svelte @@ -0,0 +1,268 @@ + + +
+

Dashboard

+ + {#if error} +
{error}
+ {/if} + + {#if isLoading} +
Chargement...
+ {:else} +
+
+ {establishments.length} + Établissements +
+
+ {activeCount} + Actifs +
+
+ {totalUsers} + Utilisateurs +
+
+ {totalStudents} + Élèves +
+
+ {totalTeachers} + Enseignants +
+
+ +

Établissements

+ {#if establishments.length === 0} +

Aucun établissement. Créer le premier

+ {:else} +
+ {#each establishments as establishment} + {@const metricsData = metrics.find((m) => m.establishmentId === establishment.id)} +
+
+

{establishment.name}

+ + {establishment.status === 'active' ? 'Actif' : 'Inactif'} + +
+

{establishment.subdomain}

+ {#if metricsData} +
+ {metricsData.userCount} utilisateurs + {metricsData.studentCount} élèves + {metricsData.teacherCount} enseignants +
+ {/if} +
+ + + Détail + +
+
+ {/each} +
+ {/if} + {/if} +
+ + diff --git a/frontend/src/routes/super-admin/establishments/+page.svelte b/frontend/src/routes/super-admin/establishments/+page.svelte new file mode 100644 index 0000000..5e57483 --- /dev/null +++ b/frontend/src/routes/super-admin/establishments/+page.svelte @@ -0,0 +1,227 @@ + + +
+ + + {#if error} +
{error}
+ {/if} + + {#if isLoading} +
Chargement...
+ {:else if establishments.length === 0} +
+

Aucun établissement configuré.

+ Créer le premier établissement +
+ {:else} +
+ + + + + + + + + + + + {#each establishments as establishment} + + + + + + + + {/each} + +
NomSous-domaineStatutDernière activitéActions
+ + {establishment.name} + + {establishment.subdomain} + + {establishment.status === 'active' ? 'Actif' : 'Inactif'} + + + {#if establishment.lastActivityAt} + {new Date(establishment.lastActivityAt).toLocaleDateString('fr-FR')} + {:else} + — + {/if} + + +
+
+ {/if} +
+ + diff --git a/frontend/src/routes/super-admin/establishments/new/+page.svelte b/frontend/src/routes/super-admin/establishments/new/+page.svelte new file mode 100644 index 0000000..fd894b6 --- /dev/null +++ b/frontend/src/routes/super-admin/establishments/new/+page.svelte @@ -0,0 +1,245 @@ + + +
+ + + {#if error} +
{error}
+ {/if} + +
+
+ + +
+ +
+ +
+ + .classeo.fr +
+

Lettres minuscules, chiffres et tirets uniquement

+
+ +
+ + +

Un email d'invitation sera envoyé à cette adresse

+
+ +
+ Annuler + +
+
+
+ +