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:
@@ -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>
|
||||
86
frontend/src/lib/features/roles/api/roles.ts
Normal file
86
frontend/src/lib/features/roles/api/roles.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
111
frontend/src/lib/features/roles/roleContext.svelte.ts
Normal file
111
frontend/src/lib/features/roles/roleContext.svelte.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user