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:
@@ -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