feat(core) : add usePermissions composable and rbac roles admin front
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user