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:
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
authenticatedFetch,
|
||||
isAuthenticated,
|
||||
getAccessToken,
|
||||
getJwtRoles,
|
||||
getCurrentUserId,
|
||||
type LoginCredentials,
|
||||
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">
|
||||
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) {
|
||||
|
||||
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