diff --git a/frontend/components/admin/AdminRoleTab.vue b/frontend/components/admin/AdminRoleTab.vue new file mode 100644 index 0000000..c36a706 --- /dev/null +++ b/frontend/components/admin/AdminRoleTab.vue @@ -0,0 +1,116 @@ + + + + {{ $t('admin.roles.title') }} + + + + + + + {{ $t('admin.roles.system') }} + + + + {{ item.permissions.length }} + + + + + + + + + + + diff --git a/frontend/components/admin/RoleDrawer.vue b/frontend/components/admin/RoleDrawer.vue new file mode 100644 index 0000000..4db0dc5 --- /dev/null +++ b/frontend/components/admin/RoleDrawer.vue @@ -0,0 +1,186 @@ + + + + + {{ isEditing ? $t('admin.roles.editRole') : $t('admin.roles.addRole') }} + + + + + + + + + + {{ $t('admin.roles.permissions') }} + + + {{ $t('admin.roles.noPermissions') }} + + + + {{ group.module }} + + + + + + {{ perm.label }} + {{ perm.code }} + + + + + + + + + + + + + + diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 5711583..321bfed 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -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é", diff --git a/frontend/modules/core/composables/usePermissions.ts b/frontend/modules/core/composables/usePermissions.ts new file mode 100644 index 0000000..0a9425a --- /dev/null +++ b/frontend/modules/core/composables/usePermissions.ts @@ -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 } +} diff --git a/frontend/modules/core/services/permissions.ts b/frontend/modules/core/services/permissions.ts new file mode 100644 index 0000000..bcad3ea --- /dev/null +++ b/frontend/modules/core/services/permissions.ts @@ -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 { + const data = await api.get>('/permissions') + return extractHydraMembers(data) + } + + return { list } +} diff --git a/frontend/modules/core/services/roles.ts b/frontend/modules/core/services/roles.ts new file mode 100644 index 0000000..24bcdea --- /dev/null +++ b/frontend/modules/core/services/roles.ts @@ -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 { + const data = await api.get>('/roles') + return extractHydraMembers(data) + } + + async function create(payload: RoleWrite): Promise { + return api.post('/roles', payload as Record, { + toastSuccessKey: 'admin.roles.created', + }) + } + + async function update(id: number, payload: Partial): Promise { + return api.patch(`/roles/${id}`, payload as Record, { + toastSuccessKey: 'admin.roles.updated', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/roles/${id}`, {}, { + toastSuccessKey: 'admin.roles.deleted', + }) + } + + return { list, create, update, remove } +} diff --git a/frontend/pages/admin.vue b/frontend/pages/admin.vue index 998deac..f371f09 100644 --- a/frontend/pages/admin.vue +++ b/frontend/pages/admin.vue @@ -6,7 +6,7 @@ + @@ -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('clients') diff --git a/frontend/services/dto/user-data.ts b/frontend/services/dto/user-data.ts index 3f5f5df..148bac0 100644 --- a/frontend/services/dto/user-data.ts +++ b/frontend/services/dto/user-data.ts @@ -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 diff --git a/src/Module/Core/Domain/Entity/Permission.php b/src/Module/Core/Domain/Entity/Permission.php index 24e4047..bbd70a5 100644 --- a/src/Module/Core/Domain/Entity/Permission.php +++ b/src/Module/Core/Domain/Entity/Permission.php @@ -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']], diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index 7954dad..e837008 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -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),
+ {{ $t('admin.roles.noPermissions') }} +
+ {{ group.module }} +