feat(core) : add usePermissions composable and rbac roles admin front

This commit is contained in:
Matthieu
2026-06-19 17:35:51 +02:00
parent 544d4cf44f
commit 511353c3f5
10 changed files with 437 additions and 3 deletions
+116
View File
@@ -0,0 +1,116 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.roles.title') }}</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('admin.roles.addRole')"
@click="openCreate"
/>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
:empty-message="$t('admin.roles.empty')"
@row-click="openEdit"
>
<template #cell-isSystem="{ item }">
<span
v-if="item.isSystem"
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-600"
>
{{ $t('admin.roles.system') }}
</span>
</template>
<template #cell-permissions="{ item }">
<span class="text-neutral-600">{{ item.permissions.length }}</span>
</template>
<template #actions="{ item }">
<MalioButtonIcon
v-if="!item.isSystem"
icon="mdi:delete-outline"
:aria-label="$t('common.delete')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-red-500"
@click.stop="handleDelete(item.id)"
/>
</template>
</DataTable>
<RoleDrawer
v-model="drawerOpen"
:item="selectedItem"
:permissions="permissions"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
import type { Role } from '~/modules/core/services/roles'
import { useRoleService } from '~/modules/core/services/roles'
import type { Permission } from '~/modules/core/services/permissions'
import { usePermissionService } from '~/modules/core/services/permissions'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n()
const columns = computed<DataTableColumn[]>(() => [
{ key: 'label', label: t('admin.roles.label'), primary: true },
{ key: 'code', label: t('admin.roles.code') },
{ key: 'permissions', label: t('admin.roles.permissions') },
{ key: 'isSystem', label: '' },
])
const roleService = useRoleService()
const permissionService = usePermissionService()
const items = ref<Role[]>([])
const permissions = ref<Permission[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<Role | null>(null)
async function loadItems() {
isLoading.value = true
try {
items.value = await roleService.list()
} finally {
isLoading.value = false
}
}
async function loadPermissions() {
permissions.value = await permissionService.list()
}
function openCreate() {
selectedItem.value = null
drawerOpen.value = true
}
function openEdit(item: Role) {
selectedItem.value = item
drawerOpen.value = true
}
async function handleDelete(id: number) {
await roleService.remove(id)
await loadItems()
}
async function onSaved() {
await loadItems()
}
onMounted(() => {
loadItems()
loadPermissions()
})
</script>
+186
View File
@@ -0,0 +1,186 @@
<template>
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">
{{ isEditing ? $t('admin.roles.editRole') : $t('admin.roles.addRole') }}
</h2>
</template>
<form class="flex flex-col gap-3" @submit.prevent="handleSubmit">
<MalioInputText
v-model="form.code"
:label="$t('admin.roles.code')"
input-class="w-full"
:disabled="isEditing"
:hint="isEditing ? $t('admin.roles.codeImmutable') : $t('admin.roles.codeHint')"
:error="touched.code && !codeValid ? $t('admin.roles.codeInvalid') : ''"
@blur="touched.code = true"
/>
<MalioInputText
v-model="form.label"
:label="$t('admin.roles.label')"
input-class="w-full"
:error="touched.label && !form.label.trim() ? $t('admin.roles.labelRequired') : ''"
@blur="touched.label = true"
/>
<MalioInputTextArea
v-model="form.description"
:label="$t('admin.roles.description')"
input-class="w-full"
/>
<div class="mt-2">
<label class="text-sm font-semibold text-neutral-700">
{{ $t('admin.roles.permissions') }}
</label>
<p v-if="permissions.length === 0" class="mt-2 text-xs text-neutral-400">
{{ $t('admin.roles.noPermissions') }}
</p>
<div
v-for="group in groupedPermissions"
:key="group.module"
class="mt-3 rounded-lg border border-neutral-200 p-3"
>
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-neutral-500">
{{ group.module }}
</p>
<div class="flex flex-col gap-2">
<label
v-for="perm in group.permissions"
:key="perm.id"
class="flex items-start gap-2 text-sm text-neutral-700"
>
<input
v-model="form.permissions"
type="checkbox"
:value="perm['@id']"
class="mt-0.5 rounded border-neutral-300"
/>
<span>
{{ perm.label }}
<span class="block text-xs text-neutral-400">{{ perm.code }}</span>
</span>
</label>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<MalioButton
:label="$t('common.save')"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Role, RoleWrite } from '~/modules/core/services/roles'
import { useRoleService } from '~/modules/core/services/roles'
import type { Permission } from '~/modules/core/services/permissions'
const props = defineProps<{
modelValue: boolean
item: Role | null
permissions: Permission[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false)
const form = reactive({
code: '',
label: '',
description: '',
permissions: [] as string[],
})
const touched = reactive({
code: false,
label: false,
})
const codeValid = computed(() => /^[a-z][a-z0-9_]*$/.test(form.code))
const groupedPermissions = computed(() => {
const byModule = new Map<string, Permission[]>()
for (const perm of props.permissions) {
const list = byModule.get(perm.module) ?? []
list.push(perm)
byModule.set(perm.module, list)
}
return [...byModule.entries()]
.map(([module, permissions]) => ({ module, permissions }))
.sort((a, b) => a.module.localeCompare(b.module))
})
watch(() => props.modelValue, (open) => {
if (open) {
if (props.item) {
form.code = props.item.code
form.label = props.item.label
form.description = props.item.description ?? ''
form.permissions = props.item.permissions
.map((p) => p['@id'])
.filter((iri): iri is string => !!iri)
} else {
form.code = ''
form.label = ''
form.description = ''
form.permissions = []
}
touched.code = false
touched.label = false
}
})
const { create, update } = useRoleService()
async function handleSubmit() {
touched.code = true
touched.label = true
if (!form.label.trim()) {
return
}
if (!isEditing.value && !codeValid.value) {
return
}
isSubmitting.value = true
try {
if (isEditing.value && props.item) {
const payload: Partial<RoleWrite> = {
label: form.label.trim(),
description: form.description.trim() || null,
permissions: form.permissions,
}
await update(props.item.id, payload)
} else {
const payload: RoleWrite = {
code: form.code.trim(),
label: form.label.trim(),
description: form.description.trim() || null,
permissions: form.permissions,
}
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
+21
View File
@@ -195,6 +195,27 @@
"addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur"
},
"admin": {
"roles": {
"title": "Rôles",
"addRole": "Ajouter un rôle",
"editRole": "Modifier un rôle",
"empty": "Aucun rôle trouvé.",
"system": "Système",
"code": "Code",
"codeHint": "Identifiant technique en snake_case (immuable).",
"codeImmutable": "Le code ne peut pas être modifié après création.",
"codeInvalid": "Code invalide (attendu snake_case : minuscules, chiffres et underscores).",
"label": "Libellé",
"labelRequired": "Le libellé est requis.",
"description": "Description",
"permissions": "Permissions",
"noPermissions": "Aucune permission disponible.",
"created": "Rôle créé avec succès.",
"updated": "Rôle mis à jour avec succès.",
"deleted": "Rôle supprimé avec succès."
}
},
"timeEntries": {
"created": "Temps enregistré",
"updated": "Temps modifié",
@@ -0,0 +1,27 @@
export function usePermissions() {
const auth = useAuthStore()
function isAdmin(): boolean {
return auth.user?.roles?.includes('ROLE_ADMIN') ?? false
}
function can(code: string): boolean {
if (!auth.user) {
return false
}
if (isAdmin()) {
return true
}
return auth.user.effectivePermissions?.includes(code) ?? false
}
function canAny(codes: string[]): boolean {
return codes.some((c) => can(c))
}
function canAll(codes: string[]): boolean {
return codes.every((c) => can(c))
}
return { can, canAny, canAll, isAdmin }
}
@@ -0,0 +1,22 @@
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type Permission = {
id: number
'@id'?: string
code: string
label: string
module: string
orphan?: boolean
}
export function usePermissionService() {
const api = useApi()
async function list(): Promise<Permission[]> {
const data = await api.get<HydraCollection<Permission>>('/permissions')
return extractHydraMembers(data)
}
return { list }
}
+50
View File
@@ -0,0 +1,50 @@
import type { Permission } from './permissions'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type Role = {
id: number
'@id'?: string
code: string
label: string
description?: string | null
isSystem: boolean
permissions: Permission[]
}
export type RoleWrite = {
code?: string
label: string
description?: string | null
/** IRIs of the granted permissions (e.g. /api/permissions/3). */
permissions: string[]
}
export function useRoleService() {
const api = useApi()
async function list(): Promise<Role[]> {
const data = await api.get<HydraCollection<Role>>('/roles')
return extractHydraMembers(data)
}
async function create(payload: RoleWrite): Promise<Role> {
return api.post<Role>('/roles', payload as Record<string, unknown>, {
toastSuccessKey: 'admin.roles.created',
})
}
async function update(id: number, payload: Partial<RoleWrite>): Promise<Role> {
return api.patch<Role>(`/roles/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'admin.roles.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/roles/${id}`, {}, {
toastSuccessKey: 'admin.roles.deleted',
})
}
return { list, create, update, remove }
}
+12 -1
View File
@@ -6,7 +6,7 @@
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
<nav class="flex gap-4 sm:gap-6">
<button
v-for="tab in tabs"
v-for="tab in visibleTabs"
:key="tab.key"
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
:class="activeTab === tab.key
@@ -27,6 +27,7 @@
<AdminPriorityTab v-if="activeTab === 'priorities'" />
<AdminTagTab v-if="activeTab === 'tags'" />
<AdminUserTab v-if="activeTab === 'users'" />
<AdminRoleTab v-if="activeTab === 'roles' && canViewRoles" />
<AdminGiteaTab v-if="activeTab === 'gitea'" />
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
@@ -41,6 +42,11 @@
definePageMeta({ middleware: ['admin'] })
useHead({ title: 'Administration' })
const { can } = usePermissions()
const { t } = useI18n()
const canViewRoles = computed(() => can('core.roles.view'))
const tabs = [
{ key: 'clients', label: 'Clients' },
{ key: 'workflows', label: 'Workflows' },
@@ -48,6 +54,7 @@ const tabs = [
{ key: 'priorities', label: 'Priorités' },
{ key: 'tags', label: 'Tags' },
{ key: 'users', label: 'Utilisateurs' },
{ key: 'roles', label: t('admin.roles.title'), permission: 'core.roles.view' },
{ key: 'gitea', label: 'Gitea' },
{ key: 'bookstack', label: 'BookStack' },
{ key: 'zimbra', label: 'Zimbra' },
@@ -58,5 +65,9 @@ const tabs = [
type TabKey = typeof tabs[number]['key']
const visibleTabs = computed(() =>
tabs.filter((tab) => !('permission' in tab) || can(tab.permission)),
)
const activeTab = ref<TabKey>('clients')
</script>
+1
View File
@@ -7,6 +7,7 @@ export type UserData = {
firstName?: string | null
lastName?: string | null
roles: string[]
effectivePermissions?: string[]
avatarUrl?: string | null
apiToken?: string | null
// HR / absence management
+1 -1
View File
@@ -18,7 +18,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
#[ApiResource(
operations: [
new GetCollection(),
new GetCollection(paginationEnabled: false),
new Get(),
],
normalizationContext: ['groups' => ['permission:read']],
+1 -1
View File
@@ -25,7 +25,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('core.roles.view')"),
new GetCollection(security: "is_granted('core.roles.view')", paginationEnabled: false),
new Get(security: "is_granted('core.roles.view')"),
new Post(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
new Patch(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),