feat: Permettre au super admin de se connecter et accéder à son dashboard
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.
This commit is contained in:
@@ -33,6 +33,12 @@ doctrine:
|
|||||||
dir: '%kernel.project_dir%/src/Communication/Infrastructure/Persistence/Mapping'
|
dir: '%kernel.project_dir%/src/Communication/Infrastructure/Persistence/Mapping'
|
||||||
prefix: 'App\Communication\Infrastructure\Persistence\Mapping'
|
prefix: 'App\Communication\Infrastructure\Persistence\Mapping'
|
||||||
alias: Communication
|
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:
|
controller_resolver:
|
||||||
auto_mapping: false
|
auto_mapping: false
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ security:
|
|||||||
# User provider for API authentication (Story 1.4)
|
# User provider for API authentication (Story 1.4)
|
||||||
app_user_provider:
|
app_user_provider:
|
||||||
id: App\Administration\Infrastructure\Security\DatabaseUserProvider
|
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:
|
firewalls:
|
||||||
dev:
|
dev:
|
||||||
@@ -40,7 +47,12 @@ security:
|
|||||||
password_path: password
|
password_path: password
|
||||||
success_handler: lexik_jwt_authentication.handler.authentication_success
|
success_handler: lexik_jwt_authentication.handler.authentication_success
|
||||||
failure_handler: App\Administration\Infrastructure\Security\LoginFailureHandler
|
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:
|
api_public:
|
||||||
pattern: ^/api/(activation-tokens|activate|token/(refresh|logout)|password/(forgot|reset)|docs)(/|$)
|
pattern: ^/api/(activation-tokens|activate|token/(refresh|logout)|password/(forgot|reset)|docs)(/|$)
|
||||||
stateless: true
|
stateless: true
|
||||||
@@ -49,7 +61,7 @@ security:
|
|||||||
pattern: ^/api
|
pattern: ^/api
|
||||||
stateless: true
|
stateless: true
|
||||||
jwt: ~
|
jwt: ~
|
||||||
provider: app_user_provider
|
provider: all_users_provider
|
||||||
main:
|
main:
|
||||||
lazy: true
|
lazy: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
@@ -58,6 +70,7 @@ security:
|
|||||||
# Note: Only the *first* access control that matches will be used
|
# Note: Only the *first* access control that matches will be used
|
||||||
access_control:
|
access_control:
|
||||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/super-admin, roles: ROLE_SUPER_ADMIN }
|
||||||
- { path: ^/api/login, roles: PUBLIC_ACCESS }
|
- { path: ^/api/login, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/api/activation-tokens, roles: PUBLIC_ACCESS }
|
- { path: ^/api/activation-tokens, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/api/activate, roles: PUBLIC_ACCESS }
|
- { path: ^/api/activate, roles: PUBLIC_ACCESS }
|
||||||
|
|||||||
@@ -169,6 +169,13 @@ services:
|
|||||||
App\Scolarite\Domain\Repository\TeacherReplacementRepository:
|
App\Scolarite\Domain\Repository\TeacherReplacementRepository:
|
||||||
alias: App\Scolarite\Infrastructure\Persistence\Doctrine\DoctrineTeacherReplacementRepository
|
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)
|
# Student Guardian Repository (Story 2.7 - Liaison parents-enfants)
|
||||||
App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository:
|
App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository:
|
||||||
arguments:
|
arguments:
|
||||||
|
|||||||
67
backend/migrations/Version20260217090323.php
Normal file
67
backend/migrations/Version20260217090323.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260217090323 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create super_admins and establishments tables for multi-establishment management (Story 2.10)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Administration\Domain\Model\User\UserId;
|
|||||||
use App\Administration\Domain\Repository\UserRepository;
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
use App\Administration\Infrastructure\Api\Resource\MyRolesOutput;
|
use App\Administration\Infrastructure\Api\Resource\MyRolesOutput;
|
||||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||||
|
|
||||||
use function array_map;
|
use function array_map;
|
||||||
|
|
||||||
@@ -37,6 +38,16 @@ final readonly class MyRolesProvider implements ProviderInterface
|
|||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): MyRolesOutput
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): MyRolesOutput
|
||||||
{
|
{
|
||||||
$currentUser = $this->security->getUser();
|
$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) {
|
if (!$currentUser instanceof SecurityUser) {
|
||||||
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
|
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Administration\Infrastructure\Security;
|
namespace App\Administration\Infrastructure\Security;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
|
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,7 +13,8 @@ use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
|
|||||||
* Added claims:
|
* Added claims:
|
||||||
* - sub: User email (Symfony Security identifier)
|
* - sub: User email (Symfony Security identifier)
|
||||||
* - user_id: User UUID (for API consumers)
|
* - 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
|
* - roles: List of Symfony roles for authorization
|
||||||
*
|
*
|
||||||
* @see Story 1.4 - User login
|
* @see Story 1.4 - User login
|
||||||
@@ -22,13 +24,21 @@ final readonly class JwtPayloadEnricher
|
|||||||
public function onJWTCreated(JWTCreatedEvent $event): void
|
public function onJWTCreated(JWTCreatedEvent $event): void
|
||||||
{
|
{
|
||||||
$user = $event->getUser();
|
$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) {
|
if (!$user instanceof SecurityUser) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = $event->getData();
|
|
||||||
|
|
||||||
// Business claims for multi-tenant isolation and authorization
|
// Business claims for multi-tenant isolation and authorization
|
||||||
$payload['user_id'] = $user->userId();
|
$payload['user_id'] = $user->userId();
|
||||||
$payload['tenant_id'] = $user->tenantId();
|
$payload['tenant_id'] = $user->tenantId();
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Command\CreateEstablishment;
|
||||||
|
|
||||||
|
final readonly class CreateEstablishmentCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $name,
|
||||||
|
public string $subdomain,
|
||||||
|
public string $adminEmail,
|
||||||
|
public string $superAdminId,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Command\CreateEstablishment;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\Establishment;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Domain\Repository\EstablishmentRepository;
|
||||||
|
|
||||||
|
final readonly class CreateEstablishmentHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EstablishmentRepository $establishmentRepository,
|
||||||
|
private Clock $clock,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(CreateEstablishmentCommand $command): CreateEstablishmentResult
|
||||||
|
{
|
||||||
|
$establishment = Establishment::creer(
|
||||||
|
name: $command->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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Command\CreateEstablishment;
|
||||||
|
|
||||||
|
final readonly class CreateEstablishmentResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $establishmentId,
|
||||||
|
public string $tenantId,
|
||||||
|
public string $name,
|
||||||
|
public string $subdomain,
|
||||||
|
public string $databaseName,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Command\SwitchTenant;
|
||||||
|
|
||||||
|
final readonly class SwitchTenantCommand
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $tenantId,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Command\SwitchTenant;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\SuperAdmin\Domain\Exception\EstablishmentIntrouvableException;
|
||||||
|
use App\SuperAdmin\Domain\Repository\EstablishmentRepository;
|
||||||
|
use App\SuperAdmin\Infrastructure\Security\SuperAdminTenantContext;
|
||||||
|
|
||||||
|
final readonly class SwitchTenantHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EstablishmentRepository $establishmentRepository,
|
||||||
|
private SuperAdminTenantContext $superAdminTenantContext,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(SwitchTenantCommand $command): void
|
||||||
|
{
|
||||||
|
$tenantId = TenantId::fromString($command->tenantId);
|
||||||
|
|
||||||
|
$establishment = $this->establishmentRepository->findByTenantId($tenantId);
|
||||||
|
|
||||||
|
if ($establishment === null) {
|
||||||
|
throw EstablishmentIntrouvableException::avecTenantId($tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->superAdminTenantContext->switchTo($tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Query\GetEstablishments;
|
||||||
|
|
||||||
|
final readonly class EstablishmentView
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $tenantId,
|
||||||
|
public string $name,
|
||||||
|
public string $subdomain,
|
||||||
|
public string $status,
|
||||||
|
public string $createdAt,
|
||||||
|
public ?string $lastActivityAt,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Query\GetEstablishments;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Domain\Repository\EstablishmentRepository;
|
||||||
|
|
||||||
|
final readonly class GetEstablishmentsHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EstablishmentRepository $establishmentRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return EstablishmentView[]
|
||||||
|
*/
|
||||||
|
public function __invoke(GetEstablishmentsQuery $query): array
|
||||||
|
{
|
||||||
|
$establishments = $this->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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Query\GetEstablishments;
|
||||||
|
|
||||||
|
final readonly class GetEstablishmentsQuery
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Query\GetEstablishmentsMetrics;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anonymized metrics for an establishment — no personal data.
|
||||||
|
*/
|
||||||
|
final readonly class EstablishmentMetricsView
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $establishmentId,
|
||||||
|
public string $name,
|
||||||
|
public string $status,
|
||||||
|
public int $userCount,
|
||||||
|
public int $studentCount,
|
||||||
|
public int $teacherCount,
|
||||||
|
public ?string $lastLoginAt,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Query\GetEstablishmentsMetrics;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns anonymized aggregate metrics for all establishments.
|
||||||
|
* No personal data (names, emails, grades) is ever exposed.
|
||||||
|
*/
|
||||||
|
final readonly class GetEstablishmentsMetricsHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return EstablishmentMetricsView[]
|
||||||
|
*/
|
||||||
|
public function __invoke(GetEstablishmentsMetricsQuery $query): array
|
||||||
|
{
|
||||||
|
$rows = $this->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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Application\Query\GetEstablishmentsMetrics;
|
||||||
|
|
||||||
|
final readonly class GetEstablishmentsMetricsQuery
|
||||||
|
{
|
||||||
|
}
|
||||||
36
backend/src/SuperAdmin/Domain/Event/EtablissementCree.php
Normal file
36
backend/src/SuperAdmin/Domain/Event/EtablissementCree.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Event;
|
||||||
|
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
final readonly class EtablissementCree implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public EstablishmentId $establishmentId,
|
||||||
|
public TenantId $tenantId,
|
||||||
|
public string $name,
|
||||||
|
public string $subdomain,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->establishmentId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Event;
|
||||||
|
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
final readonly class EtablissementDesactive implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public EstablishmentId $establishmentId,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->establishmentId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/src/SuperAdmin/Domain/Event/SuperAdminCree.php
Normal file
33
backend/src/SuperAdmin/Domain/Event/SuperAdminCree.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Event;
|
||||||
|
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
final readonly class SuperAdminCree implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public SuperAdminId $superAdminId,
|
||||||
|
public string $email,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->superAdminId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/src/SuperAdmin/Domain/Event/SuperAdminDesactive.php
Normal file
32
backend/src/SuperAdmin/Domain/Event/SuperAdminDesactive.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Event;
|
||||||
|
|
||||||
|
use App\Shared\Domain\DomainEvent;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Override;
|
||||||
|
use Ramsey\Uuid\UuidInterface;
|
||||||
|
|
||||||
|
final readonly class SuperAdminDesactive implements DomainEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public SuperAdminId $superAdminId,
|
||||||
|
private DateTimeImmutable $occurredOn,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function occurredOn(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->occurredOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function aggregateId(): UuidInterface
|
||||||
|
{
|
||||||
|
return $this->superAdminId->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Exception;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class EstablishmentDejaInactifException extends DomainException
|
||||||
|
{
|
||||||
|
public static function pour(EstablishmentId $id): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('L\'établissement %s est déjà inactif.', $id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Exception;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class EstablishmentIntrouvableException extends DomainException
|
||||||
|
{
|
||||||
|
public static function avecId(EstablishmentId $id): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Établissement introuvable avec l\'id %s.', $id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function avecTenantId(TenantId $tenantId): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Établissement introuvable avec le tenant_id %s.', $tenantId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Exception;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class SuperAdminDejaActifException extends DomainException
|
||||||
|
{
|
||||||
|
public static function pourReactivation(SuperAdminId $id): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Le Super Admin %s est déjà actif.', $id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Exception;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class SuperAdminIntrouvableException extends DomainException
|
||||||
|
{
|
||||||
|
public static function avecId(SuperAdminId $id): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Super Admin introuvable avec l\'id %s.', $id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function avecEmail(string $email): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Super Admin introuvable avec l\'email %s.', $email),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Exception;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
final class SuperAdminNonActifException extends DomainException
|
||||||
|
{
|
||||||
|
public static function pourConnexion(SuperAdminId $id): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Le Super Admin %s ne peut pas se connecter car son compte est inactif.', $id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function pourDesactivation(SuperAdminId $id): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
sprintf('Le Super Admin %s est déjà inactif.', $id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Model\Establishment;
|
||||||
|
|
||||||
|
use App\Shared\Domain\AggregateRoot;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\SuperAdmin\Domain\Event\EtablissementCree;
|
||||||
|
use App\SuperAdmin\Domain\Event\EtablissementDesactive;
|
||||||
|
use App\SuperAdmin\Domain\Exception\EstablishmentDejaInactifException;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate Root for an Establishment (tenant) — lives in master database.
|
||||||
|
*
|
||||||
|
* Each Establishment maps to a tenant with its own database.
|
||||||
|
*/
|
||||||
|
final class Establishment extends AggregateRoot
|
||||||
|
{
|
||||||
|
public private(set) ?DateTimeImmutable $lastActivityAt = null;
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public private(set) EstablishmentId $id,
|
||||||
|
public private(set) TenantId $tenantId,
|
||||||
|
public private(set) string $name,
|
||||||
|
public private(set) string $subdomain,
|
||||||
|
public private(set) string $databaseName,
|
||||||
|
public private(set) EstablishmentStatus $status,
|
||||||
|
public private(set) DateTimeImmutable $createdAt,
|
||||||
|
public private(set) ?SuperAdminId $createdBy,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function creer(
|
||||||
|
string $name,
|
||||||
|
string $subdomain,
|
||||||
|
SuperAdminId $createdBy,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
): self {
|
||||||
|
$tenantId = TenantId::generate();
|
||||||
|
|
||||||
|
$establishment = new self(
|
||||||
|
id: EstablishmentId::generate(),
|
||||||
|
tenantId: $tenantId,
|
||||||
|
name: $name,
|
||||||
|
subdomain: $subdomain,
|
||||||
|
databaseName: sprintf('classeo_tenant_%s', str_replace('-', '', (string) $tenantId)),
|
||||||
|
status: EstablishmentStatus::ACTIF,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
createdBy: $createdBy,
|
||||||
|
);
|
||||||
|
|
||||||
|
$establishment->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Model\Establishment;
|
||||||
|
|
||||||
|
use App\Shared\Domain\EntityId;
|
||||||
|
|
||||||
|
final readonly class EstablishmentId extends EntityId
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Model\Establishment;
|
||||||
|
|
||||||
|
enum EstablishmentStatus: string
|
||||||
|
{
|
||||||
|
case ACTIF = 'active';
|
||||||
|
case INACTIF = 'inactive';
|
||||||
|
}
|
||||||
125
backend/src/SuperAdmin/Domain/Model/SuperAdmin/SuperAdmin.php
Normal file
125
backend/src/SuperAdmin/Domain/Model/SuperAdmin/SuperAdmin.php
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Model\SuperAdmin;
|
||||||
|
|
||||||
|
use App\Shared\Domain\AggregateRoot;
|
||||||
|
use App\SuperAdmin\Domain\Event\SuperAdminCree;
|
||||||
|
use App\SuperAdmin\Domain\Event\SuperAdminDesactive;
|
||||||
|
use App\SuperAdmin\Domain\Exception\SuperAdminDejaActifException;
|
||||||
|
use App\SuperAdmin\Domain\Exception\SuperAdminNonActifException;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate Root for Super Admin — lives in the master database, not per-tenant.
|
||||||
|
*
|
||||||
|
* A Super Admin can manage all establishments and switch between tenant contexts.
|
||||||
|
* Authentication is separate from regular users (different user provider, different table).
|
||||||
|
*/
|
||||||
|
final class SuperAdmin extends AggregateRoot
|
||||||
|
{
|
||||||
|
public private(set) ?DateTimeImmutable $lastLoginAt = null;
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public private(set) SuperAdminId $id,
|
||||||
|
public private(set) string $email,
|
||||||
|
public private(set) string $hashedPassword,
|
||||||
|
public private(set) string $firstName,
|
||||||
|
public private(set) string $lastName,
|
||||||
|
public private(set) SuperAdminStatus $status,
|
||||||
|
public private(set) DateTimeImmutable $createdAt,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function creer(
|
||||||
|
string $email,
|
||||||
|
string $hashedPassword,
|
||||||
|
string $firstName,
|
||||||
|
string $lastName,
|
||||||
|
DateTimeImmutable $createdAt,
|
||||||
|
): self {
|
||||||
|
$superAdmin = new self(
|
||||||
|
id: SuperAdminId::generate(),
|
||||||
|
email: $email,
|
||||||
|
hashedPassword: $hashedPassword,
|
||||||
|
firstName: $firstName,
|
||||||
|
lastName: $lastName,
|
||||||
|
status: SuperAdminStatus::ACTIF,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
$superAdmin->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Model\SuperAdmin;
|
||||||
|
|
||||||
|
use App\Shared\Domain\EntityId;
|
||||||
|
|
||||||
|
final readonly class SuperAdminId extends EntityId
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Model\SuperAdmin;
|
||||||
|
|
||||||
|
enum SuperAdminStatus: string
|
||||||
|
{
|
||||||
|
case ACTIF = 'active';
|
||||||
|
case INACTIF = 'inactive';
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Repository;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\Establishment;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||||
|
|
||||||
|
interface EstablishmentRepository
|
||||||
|
{
|
||||||
|
public function save(Establishment $establishment): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \App\SuperAdmin\Domain\Exception\EstablishmentIntrouvableException
|
||||||
|
*/
|
||||||
|
public function get(EstablishmentId $id): Establishment;
|
||||||
|
|
||||||
|
public function findByTenantId(TenantId $tenantId): ?Establishment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Establishment[]
|
||||||
|
*/
|
||||||
|
public function findAll(): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Domain\Repository;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdmin;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
|
||||||
|
interface SuperAdminRepository
|
||||||
|
{
|
||||||
|
public function save(SuperAdmin $superAdmin): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \App\SuperAdmin\Domain\Exception\SuperAdminIntrouvableException
|
||||||
|
*/
|
||||||
|
public function get(SuperAdminId $id): SuperAdmin;
|
||||||
|
|
||||||
|
public function findByEmail(string $email): ?SuperAdmin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Api\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentCommand;
|
||||||
|
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||||
|
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||||
|
use Override;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProcessorInterface<EstablishmentResource, EstablishmentResource>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Api\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\SuperAdmin\Application\Command\SwitchTenant\SwitchTenantCommand;
|
||||||
|
use App\SuperAdmin\Application\Command\SwitchTenant\SwitchTenantHandler;
|
||||||
|
use App\SuperAdmin\Domain\Exception\EstablishmentIntrouvableException;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Resource\SwitchTenantInput;
|
||||||
|
use Override;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProcessorInterface<SwitchTenantInput, void>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Api\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\SuperAdmin\Application\Query\GetEstablishments\GetEstablishmentsHandler;
|
||||||
|
use App\SuperAdmin\Application\Query\GetEstablishments\GetEstablishmentsQuery;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProviderInterface<EstablishmentResource>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Api\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||||
|
use App\SuperAdmin\Domain\Repository\EstablishmentRepository;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||||
|
use Override;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProviderInterface<EstablishmentResource>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Api\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\SuperAdmin\Application\Query\GetEstablishmentsMetrics\GetEstablishmentsMetricsHandler;
|
||||||
|
use App\SuperAdmin\Application\Query\GetEstablishmentsMetrics\GetEstablishmentsMetricsQuery;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Resource\MetricsResource;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements ProviderInterface<MetricsResource>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Api\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Provider\EstablishmentCollectionProvider;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Provider\EstablishmentItemProvider;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'Establishment',
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
uriTemplate: '/super-admin/establishments',
|
||||||
|
provider: EstablishmentCollectionProvider::class,
|
||||||
|
security: "is_granted('ROLE_SUPER_ADMIN')",
|
||||||
|
name: 'super_admin_establishments_list',
|
||||||
|
),
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/super-admin/establishments/{id}',
|
||||||
|
provider: EstablishmentItemProvider::class,
|
||||||
|
security: "is_granted('ROLE_SUPER_ADMIN')",
|
||||||
|
name: 'super_admin_establishments_detail',
|
||||||
|
),
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/super-admin/establishments',
|
||||||
|
processor: CreateEstablishmentProcessor::class,
|
||||||
|
security: "is_granted('ROLE_SUPER_ADMIN')",
|
||||||
|
validationContext: ['groups' => ['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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Api\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Provider\MetricsProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'EstablishmentMetrics',
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
uriTemplate: '/super-admin/metrics',
|
||||||
|
provider: MetricsProvider::class,
|
||||||
|
security: "is_granted('ROLE_SUPER_ADMIN')",
|
||||||
|
name: 'super_admin_metrics',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class MetricsResource
|
||||||
|
{
|
||||||
|
public string $establishmentId = '';
|
||||||
|
public string $name = '';
|
||||||
|
public string $status = '';
|
||||||
|
public int $userCount = 0;
|
||||||
|
public int $studentCount = 0;
|
||||||
|
public int $teacherCount = 0;
|
||||||
|
public ?string $lastLoginAt = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Api\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Processor\SwitchTenantProcessor;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'SwitchTenant',
|
||||||
|
operations: [
|
||||||
|
new Post(
|
||||||
|
uriTemplate: '/super-admin/switch-tenant',
|
||||||
|
processor: SwitchTenantProcessor::class,
|
||||||
|
security: "is_granted('ROLE_SUPER_ADMIN')",
|
||||||
|
output: false,
|
||||||
|
status: 204,
|
||||||
|
name: 'super_admin_switch_tenant',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class SwitchTenantInput
|
||||||
|
{
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
#[Assert\Uuid]
|
||||||
|
public string $tenantId = '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Console;
|
||||||
|
|
||||||
|
use App\Administration\Application\Port\PasswordHasher;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdmin;
|
||||||
|
use App\SuperAdmin\Domain\Repository\SuperAdminRepository;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an already-activated test super admin for E2E tests.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:dev:create-test-super-admin',
|
||||||
|
description: 'Creates a test super admin for E2E tests',
|
||||||
|
)]
|
||||||
|
final class CreateTestSuperAdminCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SuperAdminRepository $superAdminRepository,
|
||||||
|
private readonly PasswordHasher $passwordHasher,
|
||||||
|
private readonly Clock $clock,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Persistence\Doctrine;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\SuperAdmin\Domain\Exception\EstablishmentIntrouvableException;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\Establishment;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentStatus;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Domain\Repository\EstablishmentRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final readonly class DoctrineEstablishmentRepository implements EstablishmentRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function save(Establishment $establishment): void
|
||||||
|
{
|
||||||
|
$this->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<string, mixed> $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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Persistence\Doctrine;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Domain\Exception\SuperAdminIntrouvableException;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdmin;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminStatus;
|
||||||
|
use App\SuperAdmin\Domain\Repository\SuperAdminRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final readonly class DoctrineSuperAdminRepository implements SuperAdminRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Connection $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Override]
|
||||||
|
public function save(SuperAdmin $superAdmin): void
|
||||||
|
{
|
||||||
|
$this->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<string, mixed> $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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Persistence\InMemory;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\SuperAdmin\Domain\Exception\EstablishmentIntrouvableException;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\Establishment;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||||
|
use App\SuperAdmin\Domain\Repository\EstablishmentRepository;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final class InMemoryEstablishmentRepository implements EstablishmentRepository
|
||||||
|
{
|
||||||
|
/** @var array<string, Establishment> */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Persistence\InMemory;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Domain\Exception\SuperAdminIntrouvableException;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdmin;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Domain\Repository\SuperAdminRepository;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
final class InMemorySuperAdminRepository implements SuperAdminRepository
|
||||||
|
{
|
||||||
|
/** @var array<string, SuperAdmin> */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Security;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use Override;
|
||||||
|
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
final readonly class SecuritySuperAdmin implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
|
{
|
||||||
|
/** @var non-empty-string */
|
||||||
|
private string $email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param non-empty-string $email
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private SuperAdminId $superAdminId,
|
||||||
|
string $email,
|
||||||
|
private string $hashedPassword,
|
||||||
|
) {
|
||||||
|
$this->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<string>
|
||||||
|
*/
|
||||||
|
#[Override]
|
||||||
|
public function getRoles(): array
|
||||||
|
{
|
||||||
|
return ['ROLE_SUPER_ADMIN'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function eraseCredentials(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Security;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the currently selected tenant for a Super Admin session.
|
||||||
|
*
|
||||||
|
* When a Super Admin switches to an establishment context,
|
||||||
|
* they get ROLE_ADMIN rights within that tenant scope.
|
||||||
|
*/
|
||||||
|
final class SuperAdminTenantContext
|
||||||
|
{
|
||||||
|
private ?TenantId $currentTenantId = null;
|
||||||
|
|
||||||
|
public function switchTo(TenantId $tenantId): void
|
||||||
|
{
|
||||||
|
$this->currentTenantId = $tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentTenantId(): ?TenantId
|
||||||
|
{
|
||||||
|
return $this->currentTenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasTenant(): bool
|
||||||
|
{
|
||||||
|
return $this->currentTenantId !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->currentTenantId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\SuperAdmin\Infrastructure\Security;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Domain\Repository\SuperAdminRepository;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Override;
|
||||||
|
use Symfony\Component\Security\Core\Exception\UserNotFoundException as SymfonyUserNotFoundException;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User provider for Super Admin authentication.
|
||||||
|
*
|
||||||
|
* Separate from the regular DatabaseUserProvider because Super Admins
|
||||||
|
* live in the master database and are not scoped by tenant.
|
||||||
|
*
|
||||||
|
* @implements UserProviderInterface<SecuritySuperAdmin>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Infrastructure\Api\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\Administration\Application\Port\ActiveRoleStore;
|
||||||
|
use App\Administration\Application\Service\RoleContext;
|
||||||
|
use App\Administration\Domain\Model\User\Email;
|
||||||
|
use App\Administration\Domain\Model\User\Role;
|
||||||
|
use App\Administration\Domain\Model\User\StatutCompte;
|
||||||
|
use App\Administration\Domain\Model\User\User;
|
||||||
|
use App\Administration\Domain\Model\User\UserId;
|
||||||
|
use App\Administration\Domain\Repository\UserRepository;
|
||||||
|
use App\Administration\Infrastructure\Api\Provider\MyRolesProvider;
|
||||||
|
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||||
|
|
||||||
|
final class MyRolesProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function provideReturnsSuperAdminRoleForSecuritySuperAdmin(): void
|
||||||
|
{
|
||||||
|
$security = $this->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
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Administration\Infrastructure\Security;
|
||||||
|
|
||||||
|
use App\Administration\Infrastructure\Security\JwtPayloadEnricher;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||||
|
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class JwtPayloadEnricherSuperAdminTest extends TestCase
|
||||||
|
{
|
||||||
|
private JwtPayloadEnricher $enricher;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Application\Command\CreateEstablishment;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentCommand;
|
||||||
|
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||||
|
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class CreateEstablishmentHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
|
||||||
|
private InMemoryEstablishmentRepository $repository;
|
||||||
|
private CreateEstablishmentHandler $handler;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Application\Query\GetEstablishments;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Application\Query\GetEstablishments\GetEstablishmentsHandler;
|
||||||
|
use App\SuperAdmin\Application\Query\GetEstablishments\GetEstablishmentsQuery;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\Establishment;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class GetEstablishmentsHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
|
||||||
|
private InMemoryEstablishmentRepository $repository;
|
||||||
|
private GetEstablishmentsHandler $handler;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Domain\Model\Establishment;
|
||||||
|
|
||||||
|
use App\Shared\Domain\Tenant\TenantId;
|
||||||
|
use App\SuperAdmin\Domain\Event\EtablissementCree;
|
||||||
|
use App\SuperAdmin\Domain\Event\EtablissementDesactive;
|
||||||
|
use App\SuperAdmin\Domain\Exception\EstablishmentDejaInactifException;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\Establishment;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentStatus;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class EstablishmentTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
private const string ESTABLISHMENT_NAME = 'École Alpha';
|
||||||
|
private const string SUBDOMAIN = 'ecole-alpha';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function creerCreatesActiveEstablishment(): void
|
||||||
|
{
|
||||||
|
$establishment = $this->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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Domain\Model\SuperAdmin;
|
||||||
|
|
||||||
|
use App\SuperAdmin\Domain\Event\SuperAdminCree;
|
||||||
|
use App\SuperAdmin\Domain\Event\SuperAdminDesactive;
|
||||||
|
use App\SuperAdmin\Domain\Exception\SuperAdminDejaActifException;
|
||||||
|
use App\SuperAdmin\Domain\Exception\SuperAdminNonActifException;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdmin;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminStatus;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class SuperAdminTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string EMAIL = 'superadmin@classeo.fr';
|
||||||
|
private const string FIRST_NAME = 'Jean';
|
||||||
|
private const string LAST_NAME = 'Dupont';
|
||||||
|
private const string HASHED_PASSWORD = '$argon2id$hashed';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function creerCreatesActiveSuperAdmin(): void
|
||||||
|
{
|
||||||
|
$superAdmin = $this->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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Api\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Processor\CreateEstablishmentProcessor;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
|
||||||
|
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
|
||||||
|
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
final class CreateEstablishmentProcessorTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function processCreatesEstablishmentAndReturnsResource(): void
|
||||||
|
{
|
||||||
|
$repository = new InMemoryEstablishmentRepository();
|
||||||
|
$clock = new class implements Clock {
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable('2026-02-16 10:00:00');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$handler = new CreateEstablishmentHandler($repository, $clock);
|
||||||
|
|
||||||
|
$securityUser = new SecuritySuperAdmin(
|
||||||
|
SuperAdminId::fromString(self::SUPER_ADMIN_ID),
|
||||||
|
'superadmin@classeo.fr',
|
||||||
|
'hashed',
|
||||||
|
);
|
||||||
|
|
||||||
|
$security = $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Api\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use App\SuperAdmin\Application\Query\GetEstablishments\GetEstablishmentsHandler;
|
||||||
|
use App\SuperAdmin\Domain\Model\Establishment\Establishment;
|
||||||
|
use App\SuperAdmin\Domain\Model\SuperAdmin\SuperAdminId;
|
||||||
|
use App\SuperAdmin\Infrastructure\Api\Provider\EstablishmentCollectionProvider;
|
||||||
|
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemoryEstablishmentRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class EstablishmentCollectionProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
private const string SUPER_ADMIN_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function provideReturnsEmptyArrayWhenNoEstablishments(): void
|
||||||
|
{
|
||||||
|
$repository = new InMemoryEstablishmentRepository();
|
||||||
|
$handler = new GetEstablishmentsHandler($repository);
|
||||||
|
$provider = new EstablishmentCollectionProvider($handler);
|
||||||
|
|
||||||
|
$result = $provider->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\SuperAdmin\Infrastructure\Console;
|
||||||
|
|
||||||
|
use App\Administration\Application\Port\PasswordHasher;
|
||||||
|
use App\Shared\Domain\Clock;
|
||||||
|
use App\SuperAdmin\Infrastructure\Console\CreateTestSuperAdminCommand;
|
||||||
|
use App\SuperAdmin\Infrastructure\Persistence\InMemory\InMemorySuperAdminRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
final class CreateTestSuperAdminCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
private InMemorySuperAdminRepository $repository;
|
||||||
|
private CommandTester $commandTester;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
208
frontend/e2e/super-admin.spec.ts
Normal file
208
frontend/e2e/super-admin.spec.ts
Normal file
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -327,6 +327,18 @@ export function isAuthenticated(): boolean {
|
|||||||
return accessToken !== null;
|
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).
|
* Retourne le token actuel (pour debug uniquement).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export {
|
|||||||
authenticatedFetch,
|
authenticatedFetch,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
|
getJwtRoles,
|
||||||
getCurrentUserId,
|
getCurrentUserId,
|
||||||
type LoginCredentials,
|
type LoginCredentials,
|
||||||
type LoginResult,
|
type LoginResult,
|
||||||
|
|||||||
81
frontend/src/lib/features/super-admin/api/super-admin.ts
Normal file
81
frontend/src/lib/features/super-admin/api/super-admin.ts
Normal file
@@ -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<EstablishmentData[]> {
|
||||||
|
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<EstablishmentData> {
|
||||||
|
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<EstablishmentData> {
|
||||||
|
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<EstablishmentMetrics[]> {
|
||||||
|
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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { login, type LoginResult } from '$lib/auth';
|
import { login, getJwtRoles, type LoginResult } from '$lib/auth';
|
||||||
import TurnstileCaptcha from '$lib/components/TurnstileCaptcha.svelte';
|
import TurnstileCaptcha from '$lib/components/TurnstileCaptcha.svelte';
|
||||||
|
|
||||||
const justActivated = $derived($page.url.searchParams.get('activated') === 'true');
|
const justActivated = $derived($page.url.searchParams.get('activated') === 'true');
|
||||||
@@ -110,8 +110,12 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Rediriger vers le dashboard
|
const roles = getJwtRoles();
|
||||||
|
if (roles.includes('ROLE_SUPER_ADMIN')) {
|
||||||
|
goto('/super-admin/dashboard');
|
||||||
|
} else {
|
||||||
goto('/dashboard');
|
goto('/dashboard');
|
||||||
|
}
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
// Gérer les différents types d'erreur
|
// Gérer les différents types d'erreur
|
||||||
switch (result.error.type) {
|
switch (result.error.type) {
|
||||||
|
|||||||
157
frontend/src/routes/super-admin/+layout.svelte
Normal file
157
frontend/src/routes/super-admin/+layout.svelte
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { isAuthenticated, refreshToken } from '$lib/auth/auth.svelte';
|
||||||
|
import { fetchRoles, getActiveRole } from '$lib/features/roles/roleContext.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
let hasAccess = $derived(isAuthenticated() && getActiveRole() === 'ROLE_SUPER_ADMIN');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
untrack(async () => {
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
const refreshed = await refreshToken();
|
||||||
|
if (!refreshed) {
|
||||||
|
goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fetchRoles();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const role = getActiveRole();
|
||||||
|
if (role !== null && role !== 'ROLE_SUPER_ADMIN') {
|
||||||
|
goto('/dashboard');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEstablishmentsActive = $derived($page.url.pathname.startsWith('/super-admin/establishments'));
|
||||||
|
const isDashboardActive = $derived(
|
||||||
|
$page.url.pathname === '/super-admin' || $page.url.pathname === '/super-admin/dashboard'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if hasAccess}
|
||||||
|
<div class="super-admin-layout">
|
||||||
|
<header class="sa-header">
|
||||||
|
<div class="sa-header-inner">
|
||||||
|
<div class="sa-brand">
|
||||||
|
<span class="sa-logo">SA</span>
|
||||||
|
<h1>Classeo Super Admin</h1>
|
||||||
|
</div>
|
||||||
|
<nav class="sa-nav">
|
||||||
|
<a
|
||||||
|
href="/super-admin/dashboard"
|
||||||
|
class="sa-nav-link"
|
||||||
|
class:active={isDashboardActive}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/super-admin/establishments"
|
||||||
|
class="sa-nav-link"
|
||||||
|
class:active={isEstablishmentsActive}
|
||||||
|
>
|
||||||
|
Établissements
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="sa-main">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="sa-loading">
|
||||||
|
<p>Vérification des accès...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.super-admin-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-header {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: white;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-header-inner {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-logo {
|
||||||
|
background: #e94560;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-brand h1 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-nav-link {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-nav-link:hover {
|
||||||
|
color: white;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-nav-link.active {
|
||||||
|
color: white;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-main {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
268
frontend/src/routes/super-admin/dashboard/+page.svelte
Normal file
268
frontend/src/routes/super-admin/dashboard/+page.svelte
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
getEstablishments,
|
||||||
|
getMetrics,
|
||||||
|
switchTenant,
|
||||||
|
type EstablishmentData,
|
||||||
|
type EstablishmentMetrics
|
||||||
|
} from '$lib/features/super-admin/api/super-admin';
|
||||||
|
|
||||||
|
let establishments = $state<EstablishmentData[]>([]);
|
||||||
|
let metrics = $state<EstablishmentMetrics[]>([]);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
let totalUsers = $derived(metrics.reduce((sum, m) => sum + m.userCount, 0));
|
||||||
|
let totalStudents = $derived(metrics.reduce((sum, m) => sum + m.studentCount, 0));
|
||||||
|
let totalTeachers = $derived(metrics.reduce((sum, m) => sum + m.teacherCount, 0));
|
||||||
|
let activeCount = $derived(establishments.filter((e) => e.status === 'active').length);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const [estData, metricsData] = await Promise.all([getEstablishments(), getMetrics()]);
|
||||||
|
establishments = estData;
|
||||||
|
metrics = metricsData;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSwitch(tenantId: string) {
|
||||||
|
try {
|
||||||
|
await switchTenant(tenantId);
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur lors du basculement';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sa-dashboard">
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error-banner">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading">Chargement...</div>
|
||||||
|
{:else}
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{establishments.length}</span>
|
||||||
|
<span class="stat-label">Établissements</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{activeCount}</span>
|
||||||
|
<span class="stat-label">Actifs</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{totalUsers}</span>
|
||||||
|
<span class="stat-label">Utilisateurs</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{totalStudents}</span>
|
||||||
|
<span class="stat-label">Élèves</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{totalTeachers}</span>
|
||||||
|
<span class="stat-label">Enseignants</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Établissements</h3>
|
||||||
|
{#if establishments.length === 0}
|
||||||
|
<p class="empty">Aucun établissement. <a href="/super-admin/establishments/new">Créer le premier</a></p>
|
||||||
|
{:else}
|
||||||
|
<div class="establishments-grid">
|
||||||
|
{#each establishments as establishment}
|
||||||
|
{@const metricsData = metrics.find((m) => m.establishmentId === establishment.id)}
|
||||||
|
<div class="establishment-card">
|
||||||
|
<div class="est-header">
|
||||||
|
<h4>{establishment.name}</h4>
|
||||||
|
<span class="badge" class:active={establishment.status === 'active'}>
|
||||||
|
{establishment.status === 'active' ? 'Actif' : 'Inactif'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="est-subdomain">{establishment.subdomain}</p>
|
||||||
|
{#if metricsData}
|
||||||
|
<div class="est-metrics">
|
||||||
|
<span>{metricsData.userCount} utilisateurs</span>
|
||||||
|
<span>{metricsData.studentCount} élèves</span>
|
||||||
|
<span>{metricsData.teacherCount} enseignants</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="est-actions">
|
||||||
|
<button
|
||||||
|
class="btn-switch"
|
||||||
|
onclick={() => handleSwitch(establishment.tenantId)}
|
||||||
|
>
|
||||||
|
Accéder
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/super-admin/establishments/{establishment.id}"
|
||||||
|
class="btn-detail"
|
||||||
|
>
|
||||||
|
Détail
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sa-dashboard h2 {
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-dashboard h3 {
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.establishments-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.establishment-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.est-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.est-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.active {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.est-subdomain {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #666;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.est-metrics {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.est-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-switch {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-switch:hover {
|
||||||
|
background: #16213e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-detail {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-detail:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty a {
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
227
frontend/src/routes/super-admin/establishments/+page.svelte
Normal file
227
frontend/src/routes/super-admin/establishments/+page.svelte
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
getEstablishments,
|
||||||
|
switchTenant,
|
||||||
|
type EstablishmentData
|
||||||
|
} from '$lib/features/super-admin/api/super-admin';
|
||||||
|
|
||||||
|
let establishments = $state<EstablishmentData[]>([]);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
establishments = await getEstablishments();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSwitch(tenantId: string) {
|
||||||
|
try {
|
||||||
|
await switchTenant(tenantId);
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Erreur lors du basculement';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="establishments-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Établissements</h2>
|
||||||
|
<a href="/super-admin/establishments/new" class="btn-create">
|
||||||
|
Nouvel établissement
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error-banner">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading">Chargement...</div>
|
||||||
|
{:else if establishments.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Aucun établissement configuré.</p>
|
||||||
|
<a href="/super-admin/establishments/new" class="btn-create">Créer le premier établissement</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Sous-domaine</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th>Dernière activité</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each establishments as establishment}
|
||||||
|
<tr>
|
||||||
|
<td class="name-cell">
|
||||||
|
<a href="/super-admin/establishments/{establishment.id}">
|
||||||
|
{establishment.name}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="subdomain-cell">{establishment.subdomain}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" class:active={establishment.status === 'active'}>
|
||||||
|
{establishment.status === 'active' ? 'Actif' : 'Inactif'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="date-cell">
|
||||||
|
{#if establishment.lastActivityAt}
|
||||||
|
{new Date(establishment.lastActivityAt).toLocaleDateString('fr-FR')}
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button
|
||||||
|
class="btn-sm"
|
||||||
|
onclick={() => handleSwitch(establishment.tenantId)}
|
||||||
|
>
|
||||||
|
Accéder
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.establishments-page h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-create {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-create:hover {
|
||||||
|
background: #16213e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #666;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell a {
|
||||||
|
color: #1a1a2e;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subdomain-cell {
|
||||||
|
color: #666;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-cell {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.active {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm:hover {
|
||||||
|
background: #16213e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
245
frontend/src/routes/super-admin/establishments/new/+page.svelte
Normal file
245
frontend/src/routes/super-admin/establishments/new/+page.svelte
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { createEstablishment } from '$lib/features/super-admin/api/super-admin';
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let subdomain = $state('');
|
||||||
|
let adminEmail = $state('');
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
let isValid = $derived(
|
||||||
|
name.trim().length > 0 && subdomain.trim().length > 0 && adminEmail.trim().length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
function generateSubdomain() {
|
||||||
|
subdomain = name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isValid || isSubmitting) return;
|
||||||
|
|
||||||
|
isSubmitting = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createEstablishment({
|
||||||
|
name: name.trim(),
|
||||||
|
subdomain: subdomain.trim(),
|
||||||
|
adminEmail: adminEmail.trim()
|
||||||
|
});
|
||||||
|
goto('/super-admin/establishments');
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Erreur lors de la création';
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="create-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<a href="/super-admin/establishments" class="back-link">← Retour</a>
|
||||||
|
<h2>Nouvel établissement</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error-banner">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="create-form" onsubmit={handleSubmit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Nom de l'établissement</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
oninput={generateSubdomain}
|
||||||
|
placeholder="École Primaire Saint-Exupéry"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="subdomain">Sous-domaine</label>
|
||||||
|
<div class="subdomain-input">
|
||||||
|
<input
|
||||||
|
id="subdomain"
|
||||||
|
type="text"
|
||||||
|
bind:value={subdomain}
|
||||||
|
placeholder="ecole-saint-exupery"
|
||||||
|
pattern="[a-z0-9-]+"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span class="subdomain-suffix">.classeo.fr</span>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Lettres minuscules, chiffres et tirets uniquement</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="adminEmail">Email du premier administrateur</label>
|
||||||
|
<input
|
||||||
|
id="adminEmail"
|
||||||
|
type="email"
|
||||||
|
bind:value={adminEmail}
|
||||||
|
placeholder="directeur@ecole.fr"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p class="hint">Un email d'invitation sera envoyé à cette adresse</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="/super-admin/establishments" class="btn-cancel">Annuler</a>
|
||||||
|
<button type="submit" class="btn-submit" disabled={!isValid || isSubmitting}>
|
||||||
|
{#if isSubmitting}
|
||||||
|
Création en cours...
|
||||||
|
{:else}
|
||||||
|
Créer l'établissement
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.create-page {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1a1a2e;
|
||||||
|
box-shadow: 0 0 0 3px rgba(26, 26, 46, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subdomain-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subdomain-input input {
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subdomain-suffix {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #888;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
padding: 0.625rem 1.5rem;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:hover:not(:disabled) {
|
||||||
|
background: #16213e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user