refactor(api): add granular permission enforcement on admin routes

- add optional `permission` field to route definitions with type validation in `define.js`
- check `hasPermission()` in router after `requireAdmin()` and return 403 if denied
- document `permission` and `skipRateLimit` optional fields in api README
- load user permissions in `AdminPage.server.js` and pass them to client via `user` prop
- use `user.permissions` in `RolesPage` and `UsersPage` to conditionally render actions
- expose permission-gated API routes in `auth/api.js`
This commit is contained in:
2026-04-25 09:21:07 -04:00
parent 188e1d82f8
commit c959b16db5
7 changed files with 91 additions and 53 deletions
+22 -17
View File
@@ -8,7 +8,7 @@ import { useToast } from '@zen/core/toast';
import AdminHeader from '../components/AdminHeader.js';
import RoleEditModal from '../components/RoleEditModal.client.js';
const RolesPageClient = () => {
const RolesPageClient = ({ canManage }) => {
const toast = useToast();
const [roles, setRoles] = useState([]);
const [loading, setLoading] = useState(true);
@@ -73,7 +73,7 @@ const RolesPageClient = () => {
render: (role) => role.is_system ? <Badge variant="default" size="sm">système</Badge> : null,
skeleton: { height: 'h-4', width: '60px' },
},
{
...(canManage ? [{
key: 'actions',
label: '',
sortable: false,
@@ -99,7 +99,7 @@ const RolesPageClient = () => {
</div>
),
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
},
}] : []),
];
const fetchRoles = async () => {
@@ -161,14 +161,17 @@ const RolesPageClient = () => {
);
};
const RolesPage = () => (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<RolesPageHeader />
<RolesPageClient />
</div>
);
const RolesPage = ({ user }) => {
const canManage = user?.permissions?.includes('roles.manage');
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<RolesPageHeader canManage={canManage} />
<RolesPageClient canManage={canManage} />
</div>
);
};
const RolesPageHeader = () => {
const RolesPageHeader = ({ canManage }) => {
const [modalOpen, setModalOpen] = useState(false);
return (
@@ -176,17 +179,19 @@ const RolesPageHeader = () => {
<AdminHeader
title="Rôles"
description="Gérez les rôles et leurs permissions"
action={
action={canManage && (
<Button variant="primary" onClick={() => setModalOpen(true)}>
Nouveau rôle
</Button>
}
/>
<RoleEditModal
roleId="new"
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
)}
/>
{canManage && (
<RoleEditModal
roleId="new"
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
/>
)}
</>
);
};
+23 -18
View File
@@ -9,7 +9,7 @@ import AdminHeader from '../components/AdminHeader.js';
import UserEditModal from '../components/UserEditModal.client.js';
import UserCreateModal from '../components/UserCreateModal.client.js';
const UsersPageClient = ({ currentUserId, refreshKey }) => {
const UsersPageClient = ({ currentUserId, refreshKey, canEdit }) => {
const toast = useToast();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -78,7 +78,7 @@ const UsersPageClient = ({ currentUserId, refreshKey }) => {
render: (user) => <RelativeDate date={user.created_at} />,
skeleton: { height: 'h-4', width: '70%' },
},
{
...(canEdit ? [{
key: 'actions',
label: '',
sortable: false,
@@ -94,7 +94,7 @@ const UsersPageClient = ({ currentUserId, refreshKey }) => {
</Button>
),
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
},
}] : []),
];
const fetchUsers = async () => {
@@ -158,13 +158,15 @@ const UsersPageClient = ({ currentUserId, refreshKey }) => {
/>
</Card>
<UserEditModal
userId={editingUserId}
currentUserId={currentUserId}
isOpen={!!editingUserId}
onClose={() => setEditingUserId(null)}
onSaved={fetchUsers}
/>
{canEdit && (
<UserEditModal
userId={editingUserId}
currentUserId={currentUserId}
isOpen={!!editingUserId}
onClose={() => setEditingUserId(null)}
onSaved={fetchUsers}
/>
)}
</>
);
};
@@ -172,24 +174,27 @@ const UsersPageClient = ({ currentUserId, refreshKey }) => {
const UsersPage = ({ user }) => {
const [createModalOpen, setCreateModalOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const canEdit = user?.permissions?.includes('users.edit');
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<AdminHeader
title="Utilisateurs"
description="Gérez les comptes utilisateurs"
action={
action={canEdit && (
<Button variant="primary" onClick={() => setCreateModalOpen(true)}>
Nouvel utilisateur
</Button>
}
/>
<UsersPageClient currentUserId={user?.id} refreshKey={refreshKey} />
<UserCreateModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
onSaved={() => setRefreshKey(k => k + 1)}
)}
/>
<UsersPageClient currentUserId={user?.id} refreshKey={refreshKey} canEdit={canEdit} />
{canEdit && (
<UserCreateModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
onSaved={() => setRefreshKey(k => k + 1)}
/>
)}
</div>
);
};