feat(permissions) : add role-based UI guards and readonly mode for viewers

- Add usePermissions composable (isAdmin, canEdit, canView)
- Password-protected profile login with modal on profiles page
- Disable all form fields for ROLE_VIEWER across edit/create pages
- Show navigation buttons (Modifier/Consulter) for all roles, hide delete for viewers
- Add readonly prop to ModelTypeForm for category pages
- Disable modal fields (sites, constructeurs) for viewers
- Guard /admin routes in middleware

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-02-26 13:36:42 +01:00
parent 6bed715b7f
commit cc70fe2b29
46 changed files with 946 additions and 423 deletions

View File

@@ -0,0 +1,80 @@
import { ref } from 'vue'
import { useApi } from './useApi'
export interface AdminProfile {
id: string
firstName: string
lastName: string
email: string | null
isActive: boolean
hasPassword: boolean
roles: string[]
createdAt: string
updatedAt: string
}
export function useAdminProfiles() {
const { get, post, put } = useApi()
const profiles = ref<AdminProfile[]>([])
const loading = ref(false)
const fetchAll = async () => {
loading.value = true
try {
const result = await get<AdminProfile[]>('/admin/profiles')
if (result.success && result.data) {
profiles.value = result.data
}
} finally {
loading.value = false
}
}
const createProfile = async (data: {
firstName: string
lastName: string
email?: string
password?: string
role?: string
}) => {
const result = await post<AdminProfile>('/admin/profiles', data)
if (result.success) {
await fetchAll()
}
return result
}
const updateRole = async (id: string, role: string) => {
const result = await put<AdminProfile>(`/admin/profiles/${id}/role`, { role })
if (result.success) {
await fetchAll()
}
return result
}
const setPassword = async (id: string, password: string) => {
const result = await put<AdminProfile>(`/admin/profiles/${id}/password`, { password })
if (result.success) {
await fetchAll()
}
return result
}
const deactivateProfile = async (id: string) => {
const result = await put<AdminProfile>(`/admin/profiles/${id}/deactivate`, {})
if (result.success) {
await fetchAll()
}
return result
}
return {
profiles,
loading,
fetchAll,
createProfile,
updateRole,
setPassword,
deactivateProfile,
}
}

View File

@@ -66,7 +66,9 @@ export function useApi() {
const text = await response.text().catch(() => '')
errorData = text ? { message: text } : {}
}
const errorMessage = (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
const errorMessage = response.status === 403
? 'Permissions insuffisantes pour cette action.'
: (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
showError(errorMessage)
return { success: false, error: errorMessage, status: response.status }
}

View File

@@ -0,0 +1,41 @@
import { computed } from 'vue'
import { useProfileSession } from './useProfileSession'
const ROLE_HIERARCHY: Record<string, string[]> = {
ROLE_ADMIN: ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
ROLE_GESTIONNAIRE: ['ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
ROLE_VIEWER: ['ROLE_VIEWER', 'ROLE_USER'],
ROLE_USER: ['ROLE_USER'],
}
export function usePermissions() {
const { activeProfile } = useProfileSession()
const effectiveRoles = computed<string[]>(() => {
const roles = (activeProfile.value?.roles as string[] | undefined) ?? ['ROLE_USER']
const all = new Set<string>()
for (const role of roles) {
const inherited = ROLE_HIERARCHY[role] ?? [role]
for (const r of inherited) {
all.add(r)
}
}
return [...all]
})
const isGranted = (role: string): boolean => {
return effectiveRoles.value.includes(role)
}
const isAdmin = computed(() => isGranted('ROLE_ADMIN'))
const canEdit = computed(() => isGranted('ROLE_GESTIONNAIRE'))
const canView = computed(() => isGranted('ROLE_VIEWER'))
return {
isAdmin,
canEdit,
canView,
isGranted,
effectiveRoles,
}
}

View File

@@ -1,12 +1,9 @@
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
import { useState, useRuntimeConfig } from '#imports'
import type { Profile } from './useProfiles'
const buildUrl = (path: string): string => {
const config = useRuntimeConfig()
const baseUrl = import.meta.server
? ((config.apiBaseUrl as string) || (config.public.apiBaseUrl as string) || '')
: ((config.public.apiBaseUrl as string) || '')
const base = baseUrl.replace(/\/$/, '')
const base = ((config.public.apiBaseUrl as string) || '').replace(/\/$/, '')
return `${base}${path}`
}
@@ -15,19 +12,12 @@ export function useProfileSession() {
const sessionLoaded = useState<boolean>('profileSession:loaded', () => false)
const loading = useState<boolean>('profileSession:loading', () => false)
const getSessionHeaders = (): Record<string, string> | undefined => {
if (!import.meta.server) { return undefined }
const headers = useRequestHeaders(['cookie'])
return headers?.cookie ? { cookie: headers.cookie } : undefined
}
const fetchCurrentProfile = async (): Promise<Profile | null> => {
loading.value = true
try {
activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), {
method: 'GET',
credentials: 'include',
headers: getSessionHeaders(),
})
} catch (error) {
const err = error as { status?: number }
@@ -51,12 +41,15 @@ export function useProfileSession() {
return Promise.resolve(activeProfile.value)
}
const activateProfile = async (profileId: string): Promise<void> => {
const activateProfile = async (profileId: string, password?: string): Promise<void> => {
const body: Record<string, string> = { profileId }
if (password) {
body.password = password
}
await $fetch(buildUrl('/session/profile'), {
method: 'POST',
credentials: 'include',
body: { profileId },
headers: getSessionHeaders(),
body,
})
await fetchCurrentProfile()
}
@@ -66,7 +59,6 @@ export function useProfileSession() {
await $fetch(buildUrl('/session/profile'), {
method: 'DELETE',
credentials: 'include',
headers: getSessionHeaders(),
})
} finally {
activeProfile.value = null

View File

@@ -1,9 +1,13 @@
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
import { useState, useRuntimeConfig } from '#imports'
export interface Profile {
id: string
firstName: string
lastName: string
email?: string | null
isActive?: boolean
hasPassword?: boolean
roles?: string[]
[key: string]: unknown
}
@@ -18,19 +22,12 @@ export function useProfiles() {
const loadingProfiles = useState<boolean>('profiles:loading', () => false)
const profilesLoaded = useState<boolean>('profiles:loaded', () => false)
const getSessionHeaders = (): Record<string, string> | undefined => {
if (!import.meta.server) { return undefined }
const headers = useRequestHeaders(['cookie'])
return headers?.cookie ? { cookie: headers.cookie } : undefined
}
const fetchProfiles = async (): Promise<Profile[]> => {
loadingProfiles.value = true
try {
profiles.value = await $fetch<Profile[]>(buildUrl('/session/profiles'), {
method: 'GET',
credentials: 'include',
headers: getSessionHeaders(),
})
profilesLoaded.value = true
} catch (error) {
@@ -43,32 +40,10 @@ export function useProfiles() {
return profiles.value
}
const createProfile = async ({ firstName, lastName }: { firstName: string; lastName: string }): Promise<Profile> => {
const profile = await $fetch<Profile>(buildUrl('/session/profiles'), {
method: 'POST',
credentials: 'include',
body: { firstName, lastName },
headers: getSessionHeaders(),
})
await fetchProfiles()
return profile
}
const deleteProfile = async (profileId: string): Promise<void> => {
await $fetch(buildUrl(`/session/profiles/${profileId}`), {
method: 'DELETE',
credentials: 'include',
headers: getSessionHeaders(),
})
await fetchProfiles()
}
return {
profiles,
loadingProfiles,
profilesLoaded,
fetchProfiles,
createProfile,
deleteProfile,
}
}