feat: Attribution de rôles multiples par utilisateur

Les utilisateurs Classeo étaient limités à un seul rôle, alors que
dans la réalité scolaire un directeur peut aussi être enseignant,
ou un parent peut avoir un rôle vie scolaire. Cette limitation
obligeait à créer des comptes distincts par fonction.

Le modèle User supporte désormais plusieurs rôles simultanés avec
basculement via le header. L'admin peut attribuer/retirer des rôles
depuis l'interface de gestion, avec des garde-fous : pas d'auto-
destitution, pas d'escalade de privilèges (seul SUPER_ADMIN peut
attribuer SUPER_ADMIN), vérification du statut actif pour le
switch de rôle, et TTL explicite sur le cache de rôle actif.
This commit is contained in:
2026-02-10 07:57:43 +01:00
parent 9ccad77bf0
commit e930c505df
93 changed files with 2527 additions and 165 deletions

View File

@@ -69,16 +69,11 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
await expect(page).toHaveURL(/\/admin\/pedagogy/, { timeout: 10000 });
});
test('pedagogy card is visible on admin dashboard', async ({ page, browserName }) => {
// Svelte 5 delegated onclick is not triggered by Playwright click on webkit
test.skip(browserName === 'webkit', 'Demo role switcher click not supported on webkit');
test('pedagogy card is visible on admin dashboard', async ({ page }) => {
await loginAsAdmin(page);
// Switch to admin view in demo dashboard
// Authenticated admin sees admin dashboard directly via role context
await page.goto(`${ALPHA_URL}/dashboard`);
const adminButton = page.getByRole('button', { name: /admin/i });
await adminButton.click();
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({
timeout: 10000

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import {
getRoles,
getActiveRole,
hasMultipleRoles,
switchTo,
getIsSwitching
} from '$features/roles/roleContext.svelte';
let selectedRole = $state(getActiveRole() ?? '');
let switchError = $state<string | null>(null);
// Sync selected role when active role changes externally
$effect(() => {
const active = getActiveRole();
if (active) {
selectedRole = active;
}
});
async function handleSwitch() {
if (selectedRole === getActiveRole()) return;
switchError = null;
const success = await switchTo(selectedRole);
if (!success) {
switchError = 'Erreur lors du basculement';
selectedRole = getActiveRole() ?? '';
}
}
</script>
{#if hasMultipleRoles()}
<div class="role-switcher">
<label for="role-switcher" class="role-switcher-label">Vue :</label>
<select
id="role-switcher"
bind:value={selectedRole}
onchange={handleSwitch}
disabled={getIsSwitching()}
class="role-switcher-select"
>
{#each getRoles() as role}
<option value={role.value}>{role.label}</option>
{/each}
</select>
{#if getIsSwitching()}
<span class="role-switcher-spinner"></span>
{/if}
{#if switchError}
<span class="role-switcher-error">{switchError}</span>
{/if}
</div>
{/if}
<style>
.role-switcher {
display: flex;
align-items: center;
gap: 0.5rem;
}
.role-switcher-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
white-space: nowrap;
}
.role-switcher-select {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--accent-primary, #0ea5e9);
background: var(--accent-primary-light, #e0f2fe);
border: 1px solid var(--accent-primary, #0ea5e9);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
appearance: auto;
}
.role-switcher-select:hover:not(:disabled) {
background: var(--accent-primary, #0ea5e9);
color: white;
}
.role-switcher-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.role-switcher-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--border-subtle, #e2e8f0);
border-top-color: var(--accent-primary, #0ea5e9);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.role-switcher-error {
font-size: 0.75rem;
color: var(--color-alert, #ef4444);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,86 @@
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch } from '$lib/auth';
/**
* Types pour la gestion des rôles utilisateur.
*
* @see Story 2.6 - Attribution des rôles
*/
export interface RoleInfo {
value: string;
label: string;
}
export interface MyRolesResponse {
roles: RoleInfo[];
activeRole: string;
activeRoleLabel: string;
}
export interface SwitchRoleResponse {
activeRole: string;
activeRoleLabel: string;
}
/**
* Récupère les rôles de l'utilisateur courant et le rôle actif.
*/
export async function getMyRoles(): Promise<MyRolesResponse> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/roles`);
if (!response.ok) {
throw new Error('Failed to fetch roles');
}
return await response.json();
}
/**
* Bascule le contexte vers un autre rôle.
*/
export async function switchRole(role: string): Promise<SwitchRoleResponse> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/switch-role`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ role })
});
if (!response.ok) {
throw new Error('Failed to switch role');
}
return await response.json();
}
/**
* Met à jour les rôles d'un utilisateur (admin uniquement).
*/
export async function updateUserRoles(userId: string, roles: string[]): Promise<void> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/users/${userId}/roles`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ roles })
});
if (!response.ok) {
let errorMessage = `Erreur lors de la mise à jour des rôles (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) {
errorMessage = errorData['hydra:description'];
} else if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// JSON parsing failed, keep default message
}
throw new Error(errorMessage);
}
}

View File

@@ -0,0 +1,111 @@
import { getMyRoles, switchRole as apiSwitchRole, type RoleInfo } from './api/roles';
/**
* Contexte de rôle réactif.
*
* Gère le rôle actif de l'utilisateur lorsqu'il possède plusieurs rôles (FR5).
* Le rôle actif détermine quelle vue (dashboard, navigation) est affichée.
*
* @see Story 2.6 - Attribution des rôles
*/
// État réactif
let roles = $state<RoleInfo[]>([]);
let activeRole = $state<string | null>(null);
let activeRoleLabel = $state<string | null>(null);
let isLoading = $state(false);
let isSwitching = $state(false);
let isFetched = $state(false);
/**
* Charge les rôles de l'utilisateur courant depuis l'API.
* Protégé contre les appels multiples (guard isFetched).
*/
export async function fetchRoles(): Promise<void> {
if (isFetched || isLoading) return;
isLoading = true;
try {
const data = await getMyRoles();
roles = data.roles;
activeRole = data.activeRole;
activeRoleLabel = data.activeRoleLabel;
isFetched = true;
} catch (error) {
console.error('[roleContext] Failed to fetch roles:', error);
} finally {
isLoading = false;
}
}
/**
* Bascule vers un autre rôle.
*/
export async function switchTo(role: string): Promise<boolean> {
if (role === activeRole) return true;
isSwitching = true;
try {
const data = await apiSwitchRole(role);
activeRole = data.activeRole;
activeRoleLabel = data.activeRoleLabel;
return true;
} catch (error) {
console.error('[roleContext] Failed to switch role:', error);
return false;
} finally {
isSwitching = false;
}
}
/**
* Indique si l'utilisateur a plusieurs rôles (et donc peut basculer).
*/
export function hasMultipleRoles(): boolean {
return roles.length > 1;
}
/**
* Retourne les rôles disponibles.
*/
export function getRoles(): RoleInfo[] {
return roles;
}
/**
* Retourne le rôle actif.
*/
export function getActiveRole(): string | null {
return activeRole;
}
/**
* Retourne le libellé du rôle actif.
*/
export function getActiveRoleLabel(): string | null {
return activeRoleLabel;
}
/**
* Indique si le chargement initial est en cours.
*/
export function getIsLoading(): boolean {
return isLoading;
}
/**
* Indique si un basculement de rôle est en cours.
*/
export function getIsSwitching(): boolean {
return isSwitching;
}
/**
* Réinitialise l'état (à appeler au logout).
*/
export function resetRoleContext(): void {
roles = [];
activeRole = null;
activeRoleLabel = null;
isFetched = false;
}

View File

@@ -3,6 +3,7 @@
import { browser } from '$app/environment';
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import { onLogout } from '$lib/auth/auth.svelte';
import { resetRoleContext } from '$features/roles/roleContext.svelte';
let { children } = $props();
@@ -21,6 +22,7 @@
// Clear user-specific caches on logout to prevent cross-account data leakage
onLogout(() => {
queryClient.removeQueries({ queryKey: ['sessions'] });
resetRoleContext();
});
</script>

View File

@@ -1,14 +1,23 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { logout } from '$lib/auth/auth.svelte';
import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte';
import { fetchRoles, resetRoleContext } from '$features/roles/roleContext.svelte';
let { children } = $props();
let isLoggingOut = $state(false);
// Load user roles on mount for multi-role context switching (FR5)
$effect(() => {
untrack(() => fetchRoles());
});
async function handleLogout() {
isLoggingOut = true;
try {
resetRoleContext();
await logout();
} finally {
isLoggingOut = false;
@@ -38,6 +47,7 @@
<span class="logo-text">Classeo</span>
</button>
<nav class="header-nav">
<RoleSwitcher />
<a href="/dashboard" class="nav-link">Tableau de bord</a>
<a href="/admin/users" class="nav-link" class:active={isUsersActive}>Utilisateurs</a>
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch, getCurrentUserId } from '$lib/auth';
import { updateUserRoles } from '$features/roles/api/roles';
// Types
interface User {
@@ -8,6 +9,7 @@
email: string;
role: string;
roleLabel: string;
roles: string[];
firstName: string;
lastName: string;
statut: string;
@@ -21,6 +23,7 @@
// Role options (admin can assign these roles)
const ROLE_OPTIONS = [
{ value: 'ROLE_SUPER_ADMIN', label: 'Super Admin' },
{ value: 'ROLE_ADMIN', label: 'Directeur' },
{ value: 'ROLE_PROF', label: 'Enseignant' },
{ value: 'ROLE_VIE_SCOLAIRE', label: 'Vie Scolaire' },
@@ -53,7 +56,7 @@
let newFirstName = $state('');
let newLastName = $state('');
let newEmail = $state('');
let newRole = $state('');
let newRoles = $state<string[]>([]);
let isSubmitting = $state(false);
let isResending = $state<string | null>(null);
@@ -64,6 +67,12 @@
let isBlocking = $state(false);
let isUnblocking = $state<string | null>(null);
// Roles modal state
let showRolesModal = $state(false);
let rolesTargetUser = $state<User | null>(null);
let selectedRoles = $state<string[]>([]);
let isSavingRoles = $state(false);
// Load users on mount
$effect(() => {
loadUsers();
@@ -106,7 +115,7 @@
}
async function handleCreateUser() {
if (!newFirstName.trim() || !newLastName.trim() || !newEmail.trim() || !newRole) return;
if (!newFirstName.trim() || !newLastName.trim() || !newEmail.trim() || newRoles.length === 0) return;
try {
isSubmitting = true;
@@ -121,7 +130,7 @@
firstName: newFirstName.trim(),
lastName: newLastName.trim(),
email: newEmail.trim().toLowerCase(),
role: newRole
roles: newRoles
})
});
@@ -196,7 +205,7 @@
newFirstName = '';
newLastName = '';
newEmail = '';
newRole = '';
newRoles = [];
error = null;
}
@@ -248,7 +257,7 @@
}
function canBlockUser(user: User): boolean {
return user.statut !== 'suspended' && user.statut !== 'archived' && user.id !== getCurrentUserId();
return user.statut === 'active' && user.id !== getCurrentUserId();
}
function openBlockModal(user: User) {
@@ -346,6 +355,59 @@
}
}
function openRolesModal(user: User) {
rolesTargetUser = user;
selectedRoles = [...(user.roles ?? [user.role])];
showRolesModal = true;
error = null;
}
function closeRolesModal() {
showRolesModal = false;
rolesTargetUser = null;
selectedRoles = [];
}
function toggleRole(roleValue: string) {
if (selectedRoles.includes(roleValue)) {
if (selectedRoles.length > 1) {
selectedRoles = selectedRoles.filter((r) => r !== roleValue);
}
} else {
selectedRoles = [...selectedRoles, roleValue];
}
}
async function handleSaveRoles() {
if (!rolesTargetUser || selectedRoles.length === 0) return;
try {
isSavingRoles = true;
error = null;
await updateUserRoles(rolesTargetUser.id, selectedRoles);
successMessage = `Les rôles de ${rolesTargetUser.firstName} ${rolesTargetUser.lastName} ont été mis à jour.`;
closeRolesModal();
await loadUsers();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la mise à jour des rôles';
} finally {
isSavingRoles = false;
}
}
function toggleNewRole(roleValue: string) {
if (newRoles.includes(roleValue)) {
newRoles = newRoles.filter((r) => r !== roleValue);
} else {
newRoles = [...newRoles, roleValue];
}
}
function getRoleLabelByValue(roleValue: string): string {
const found = ROLE_OPTIONS.find((r) => r.value === roleValue);
return found?.label ?? roleValue;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('fr-FR', {
@@ -445,7 +507,15 @@
</td>
<td class="user-email">{user.email}</td>
<td>
<span class="role-badge">{user.roleLabel}</span>
<div class="role-badges">
{#if user.roles && user.roles.length > 0}
{#each user.roles as roleValue}
<span class="role-badge">{getRoleLabelByValue(roleValue)}</span>
{/each}
{:else}
<span class="role-badge">{user.roleLabel}</span>
{/if}
</div>
</td>
<td>
<span class="status-badge {getStatutClass(user.statut, user.invitationExpiree)}">
@@ -459,6 +529,14 @@
</td>
<td class="date-cell">{formatDate(user.invitedAt)}</td>
<td class="actions-cell">
{#if user.id !== getCurrentUserId()}
<button
class="btn-secondary btn-sm"
onclick={() => openRolesModal(user)}
>
Rôles
</button>
{/if}
{#if canResendInvitation(user)}
<button
class="btn-secondary btn-sm"
@@ -565,15 +643,19 @@
/>
</div>
<div class="form-group">
<label for="user-role">Rôle *</label>
<select id="user-role" bind:value={newRole} required>
<option value="">-- Sélectionner un rôle --</option>
{#each ROLE_OPTIONS as role}
<option value={role.value}>{role.label}</option>
{/each}
</select>
</div>
<fieldset class="roles-fieldset">
<legend class="roles-legend">Rôle(s) *</legend>
{#each ROLE_OPTIONS as role}
<label class="role-checkbox-label">
<input
type="checkbox"
checked={newRoles.includes(role.value)}
onchange={() => toggleNewRole(role.value)}
/>
<span class="role-checkbox-text">{role.label}</span>
</label>
{/each}
</fieldset>
<div class="form-hint-block">
Un email d'invitation sera automatiquement envoyé à l'utilisateur.
@@ -587,7 +669,7 @@
<button
type="submit"
class="btn-primary"
disabled={isSubmitting || !newFirstName.trim() || !newLastName.trim() || !newEmail.trim() || !newRole}
disabled={isSubmitting || !newFirstName.trim() || !newLastName.trim() || !newEmail.trim() || newRoles.length === 0}
>
{#if isSubmitting}
Envoi de l'invitation...
@@ -663,6 +745,74 @@
</div>
{/if}
<!-- Edit Roles Modal -->
{#if showRolesModal && rolesTargetUser}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeRolesModal} role="presentation">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="roles-modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeRolesModal(); }}
>
<header class="modal-header">
<h2 id="roles-modal-title">Modifier les rôles</h2>
<button class="modal-close" onclick={closeRolesModal} aria-label="Fermer">×</button>
</header>
<div class="modal-body">
<p class="roles-modal-user">
{rolesTargetUser.firstName} {rolesTargetUser.lastName} ({rolesTargetUser.email})
</p>
<fieldset class="roles-fieldset">
<legend class="roles-legend">Rôles attribués</legend>
{#each ROLE_OPTIONS as role}
<label class="role-checkbox-label">
<input
type="checkbox"
checked={selectedRoles.includes(role.value)}
onchange={() => toggleRole(role.value)}
disabled={selectedRoles.includes(role.value) && selectedRoles.length === 1}
/>
<span class="role-checkbox-text">{role.label}</span>
{#if selectedRoles.includes(role.value) && selectedRoles.length === 1}
<span class="role-checkbox-hint">(dernier rôle)</span>
{/if}
</label>
{/each}
</fieldset>
<div class="form-hint-block">
Un utilisateur doit avoir au moins un rôle. Les utilisateurs avec plusieurs rôles
pourront basculer entre leurs différentes vues.
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeRolesModal} disabled={isSavingRoles}>
Annuler
</button>
<button
type="button"
class="btn-primary"
onclick={handleSaveRoles}
disabled={isSavingRoles || selectedRoles.length === 0}
>
{#if isSavingRoles}
Enregistrement...
{:else}
Enregistrer
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.users-page {
padding: 1.5rem;
@@ -1129,8 +1279,7 @@
color: #374151;
}
.form-group input,
.form-group select {
.form-group input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
@@ -1139,8 +1288,7 @@
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
@@ -1191,6 +1339,59 @@
border-top: 1px solid #e5e7eb;
}
/* Role badges */
.role-badges {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
/* Roles modal */
.roles-modal-user {
margin: 0 0 1.25rem;
font-size: 0.875rem;
color: #6b7280;
}
.roles-fieldset {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
margin: 0 0 1.25rem;
}
.roles-legend {
font-size: 0.875rem;
font-weight: 600;
color: #374151;
padding: 0 0.5rem;
}
.role-checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
cursor: pointer;
}
.role-checkbox-label input[type='checkbox'] {
width: 1rem;
height: 1rem;
accent-color: #3b82f6;
}
.role-checkbox-text {
font-size: 0.875rem;
color: #374151;
}
.role-checkbox-hint {
font-size: 0.75rem;
color: #9ca3af;
font-style: italic;
}
@media (max-width: 768px) {
.filters-bar {
flex-direction: column;

View File

@@ -1,16 +1,29 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { logout } from '$lib/auth/auth.svelte';
import { isAuthenticated, refreshToken, logout } from '$lib/auth/auth.svelte';
import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte';
import { fetchRoles, resetRoleContext } from '$features/roles/roleContext.svelte';
let { children } = $props();
let isLoggingOut = $state(false);
// Note: Authentication is handled by authenticatedFetch in the page component.
// If not authenticated, authenticatedFetch will attempt refresh and redirect to /login if needed.
// Load user roles on mount for multi-role context switching (FR5)
// Guard: only fetch if authenticated (or refresh succeeds), otherwise stay in demo mode
$effect(() => {
untrack(async () => {
if (!isAuthenticated()) {
const refreshed = await refreshToken();
if (!refreshed) return;
}
fetchRoles();
});
});
async function handleLogout() {
isLoggingOut = true;
try {
resetRoleContext();
await logout();
} finally {
isLoggingOut = false;
@@ -33,6 +46,7 @@
<span class="logo-text">Classeo</span>
</button>
<nav class="header-nav">
<RoleSwitcher />
<a href="/dashboard" class="nav-link active">Tableau de bord</a>
<button class="nav-button" onclick={goSettings}>Parametres</button>
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>

View File

@@ -5,12 +5,33 @@
import DashboardTeacher from '$lib/components/organisms/Dashboard/DashboardTeacher.svelte';
import DashboardStudent from '$lib/components/organisms/Dashboard/DashboardStudent.svelte';
import DashboardAdmin from '$lib/components/organisms/Dashboard/DashboardAdmin.svelte';
import { getActiveRole, getIsLoading } from '$features/roles/roleContext.svelte';
type UserRole = 'parent' | 'teacher' | 'student' | 'admin' | 'direction';
type DashboardView = 'parent' | 'teacher' | 'student' | 'admin';
// For now, default to parent role with demo data
// TODO: Fetch real user profile from /api/me when endpoint is implemented
let userRole = $state<UserRole>('parent');
const ROLE_TO_VIEW: Record<string, DashboardView> = {
ROLE_PARENT: 'parent',
ROLE_PROF: 'teacher',
ROLE_ELEVE: 'student',
ROLE_ADMIN: 'admin',
ROLE_SUPER_ADMIN: 'admin',
ROLE_VIE_SCOLAIRE: 'admin',
ROLE_SECRETARIAT: 'admin'
};
// Fallback demo role when not authenticated or roles not loaded
let demoRole = $state<DashboardView>('parent');
// Use real role context if available, otherwise fallback to demo
let dashboardView = $derived<DashboardView>(
ROLE_TO_VIEW[getActiveRole() ?? ''] ?? demoRole
);
// True when roles come from the API (user is authenticated)
let hasRoleContext = $derived(getActiveRole() !== null);
// True when role loading has started (indicates an authenticated user)
let isRoleLoading = $derived(getIsLoading());
// Simulated first login detection (in real app, this comes from API)
let isFirstLogin = $state(true);
@@ -26,16 +47,13 @@
function handleToggleSerenity(enabled: boolean) {
serenityEnabled = enabled;
// TODO: POST to /api/me/preferences when backend is ready
console.log('Serenity score preference updated:', enabled);
}
// Cast demo data to proper type
const typedDemoData = demoData as DemoData;
// Allow switching roles for demo purposes
function switchRole(role: UserRole) {
userRole = role;
function switchDemoRole(role: DashboardView) {
demoRole = role;
isFirstLogin = false;
}
</script>
@@ -44,17 +62,25 @@
<title>Tableau de bord - Classeo</title>
</svelte:head>
<!-- Demo role switcher - TODO: Remove when real authentication is implemented -->
<!-- This will be hidden once we can determine user role from /api/me -->
<div class="demo-controls">
<span class="demo-label">Démo - Changer de rôle :</span>
<button class:active={userRole === 'parent'} onclick={() => switchRole('parent')}>Parent</button>
<button class:active={userRole === 'teacher'} onclick={() => switchRole('teacher')}>Enseignant</button>
<button class:active={userRole === 'student'} onclick={() => switchRole('student')}>Élève</button>
<button class:active={userRole === 'admin'} onclick={() => switchRole('admin')}>Admin</button>
</div>
<!-- Loading state when roles are being fetched -->
{#if isRoleLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>Chargement du tableau de bord...</p>
</div>
{:else if !hasRoleContext && !isRoleLoading}
<!-- Demo role switcher shown when not authenticated (no role context from API) -->
<!-- The RoleSwitcher in the header handles multi-role switching for authenticated users -->
<div class="demo-controls">
<span class="demo-label">Démo - Changer de rôle :</span>
<button class:active={demoRole === 'parent'} onclick={() => switchDemoRole('parent')}>Parent</button>
<button class:active={demoRole === 'teacher'} onclick={() => switchDemoRole('teacher')}>Enseignant</button>
<button class:active={demoRole === 'student'} onclick={() => switchDemoRole('student')}>Élève</button>
<button class:active={demoRole === 'admin'} onclick={() => switchDemoRole('admin')}>Admin</button>
</div>
{/if}
{#if userRole === 'parent'}
{#if dashboardView === 'parent'}
<DashboardParent
demoData={typedDemoData}
{isFirstLogin}
@@ -64,11 +90,11 @@
{childName}
onToggleSerenity={handleToggleSerenity}
/>
{:else if userRole === 'teacher'}
{:else if dashboardView === 'teacher'}
<DashboardTeacher isLoading={false} {hasRealData} />
{:else if userRole === 'student'}
{:else if dashboardView === 'student'}
<DashboardStudent demoData={typedDemoData} isLoading={false} {hasRealData} isMinor={true} />
{:else if userRole === 'admin' || userRole === 'direction'}
{:else if dashboardView === 'admin'}
<DashboardAdmin
isLoading={false}
{hasRealData}
@@ -77,6 +103,30 @@
{/if}
<style>
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.demo-controls {
display: flex;
align-items: center;