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

@@ -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;
}