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:
2026-02-17 10:07:10 +01:00
parent c856dfdcda
commit 0951322d71
68 changed files with 4049 additions and 8 deletions

View 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 });
});
});
});

View File

@@ -327,6 +327,18 @@ export function isAuthenticated(): boolean {
return accessToken !== null;
}
/**
* Parse les rôles depuis le JWT en mémoire.
* Utilisé pour la redirection post-login (super admin vs utilisateur normal).
*/
export function getJwtRoles(): string[] {
if (!accessToken) return [];
const payload = parseJwtPayload(accessToken);
if (!payload) return [];
const roles = payload['roles'];
return Array.isArray(roles) ? roles : [];
}
/**
* Retourne le token actuel (pour debug uniquement).
*/

View File

@@ -5,6 +5,7 @@ export {
authenticatedFetch,
isAuthenticated,
getAccessToken,
getJwtRoles,
getCurrentUserId,
type LoginCredentials,
type LoginResult,

View 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');
}
}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
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';
const justActivated = $derived($page.url.searchParams.get('activated') === 'true');
@@ -110,8 +110,12 @@
});
if (result.success) {
// Rediriger vers le dashboard
goto('/dashboard');
const roles = getJwtRoles();
if (roles.includes('ROLE_SUPER_ADMIN')) {
goto('/super-admin/dashboard');
} else {
goto('/dashboard');
}
} else if (result.error) {
// Gérer les différents types d'erreur
switch (result.error.type) {

View 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>

View 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>

View 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>

View 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>