Files
core/src/features/admin/components/RoleEditModal.client.js
T
hykocx a3aff9fa49 feat(modules): add external module system with auto-discovery and public pages support
- add `src/core/modules/` with registry, discovery (server), and public index
- add `src/core/public-pages/` with registry, server component, and public index
- add `src/core/users/permissions-registry.js` for runtime permission registration
- expose `./modules`, `./public-pages`, and `./public-pages/server` package exports
- rename `registerFeatureRoutes` to `registerApiRoutes` with backward-compatible alias
- extend `seedDefaultRolesAndPermissions` to include module-registered permissions
- update `initializeZen` and shared init to wire module discovery and registration
- add `docs/MODULES.md` documenting the `@zen/module-*` authoring contract
- update `docs/DEV.md` with references to module system docs
2026-04-25 10:50:13 -04:00

190 lines
7.5 KiB
JavaScript

'use client';
import { useState, useEffect } from 'react';
import { Input, Textarea, Switch, Modal, ColorPicker } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
const toast = useToast();
const isNew = !roleId || roleId === 'new';
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [isSystem, setIsSystem] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [color, setColor] = useState('#6b7280');
const [selectedPerms, setSelectedPerms] = useState([]);
// Catalogue dynamique des permissions (core + modules), récupéré via l'API.
const [permissionGroups, setPermissionGroups] = useState({});
useEffect(() => {
if (!isOpen) return;
fetchPermissions();
if (isNew) {
setName('');
setDescription('');
setColor('#6b7280');
setSelectedPerms([]);
setIsSystem(false);
return;
}
fetchRole();
}, [isOpen, roleId]);
const fetchPermissions = async () => {
try {
const response = await fetch('/zen/api/permissions', { credentials: 'include' });
if (!response.ok) return;
const data = await response.json();
setPermissionGroups(data.groups || {});
} catch {
// Si le catalogue n'est pas joignable, on laisse l'utilisateur sauvegarder
// ses changements ; les permissions invalides sont filtrées côté serveur.
}
};
const fetchRole = async () => {
try {
setLoading(true);
const response = await fetch(`/zen/api/roles/${roleId}`, { credentials: 'include' });
if (!response.ok) {
toast.error('Rôle introuvable');
onClose();
return;
}
const data = await response.json();
const role = data.role;
setName(role.name || '');
setDescription(role.description || '');
setColor(role.color || '#6b7280');
setSelectedPerms(role.permission_keys || []);
setIsSystem(role.is_system || false);
} catch {
toast.error('Impossible de charger ce rôle');
onClose();
} finally {
setLoading(false);
}
};
const togglePerm = (key) => {
setSelectedPerms(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
const handleSubmit = async () => {
if (!name.trim()) {
toast.error('Le nom du rôle est requis');
return;
}
try {
setSaving(true);
const url = isNew ? '/zen/api/roles' : `/zen/api/roles/${roleId}`;
const method = isNew ? 'POST' : 'PUT';
const response = await fetch(url, {
method,
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
description: description.trim() || null,
color,
permissionKeys: selectedPerms,
}),
});
const data = await response.json();
if (!response.ok) {
toast.error(data.message || 'Impossible de sauvegarder ce rôle');
return;
}
toast.success(isNew ? 'Rôle créé' : 'Rôle mis à jour');
onSaved?.();
onClose();
} catch {
toast.error('Impossible de sauvegarder ce rôle');
} finally {
setSaving(false);
}
};
const title = isNew ? 'Nouveau rôle' : `Modifier "${name}"`;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={title}
onSubmit={handleSubmit}
submitLabel={isNew ? 'Créer le rôle' : 'Sauvegarder'}
loading={saving}
disabled={loading}
size="lg"
>
{loading ? (
<div className="flex flex-col gap-4 animate-pulse">
<div className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
<div className="h-20 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
<div className="h-40 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
</div>
) : (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<Input
label="Nom du rôle"
value={name}
onChange={setName}
placeholder="Éditeur, Modérateur..."
required
/>
<Textarea
label="Description"
value={description}
onChange={setDescription}
rows={2}
placeholder="Description optionnelle..."
/>
<ColorPicker
label="Couleur du rôle"
description="Les membres utilisent la couleur du rôle le plus élevé qu'ils possèdent."
value={color}
onChange={setColor}
required
/>
</div>
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Permissions</p>
{Object.entries(permissionGroups).map(([group, perms]) => (
<div key={group} className="rounded-xl border border-neutral-200 dark:border-neutral-700/60 overflow-hidden">
<div className="px-4 py-2.5 bg-neutral-50 dark:bg-neutral-800/60 border-b border-neutral-200 dark:border-neutral-700/60">
<p className="text-[11px] font-medium font-ibm-plex-mono text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">
{group}
</p>
</div>
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-700/40">
{perms.map((perm) => (
<Switch
key={perm.key}
checked={selectedPerms.includes(perm.key)}
onChange={() => togglePerm(perm.key)}
label={perm.name}
description={perm.description}
disabled={isSystem}
/>
))}
</div>
</div>
))}
</div>
</div>
)}
</Modal>
);
};
export default RoleEditModal;