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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user