Mise en place des workspaces
This commit is contained in:
1 parent
be3c93897b
commit
6b6cad7cd6
19 files changed
+1273
-56
No files matched your search
@@ -0,0 +1,274 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
import Modal from '../../components/Modal.jsx';
|
||||
|
||||
/* Modale en étapes "Gérer les droits" — Administration > Utilisateurs.
|
||||
* Consolide en un seul endroit ce qui était trois actions séparées du menu
|
||||
* ⋮ (rôle, statut, accès IA) + le nouvel octroi de workspaces :
|
||||
* 1. Information générale (rôle, statut du compte)
|
||||
* 2. Espaces de travail (octroi admin — cf. adminWorkspaces.js / PATCH
|
||||
* /admin/users/:id/workspaces)
|
||||
* 3. Assistant IA (accès individuel, décision admin)
|
||||
* Voir project_workspaces_transformation.md (mémoire projet). */
|
||||
|
||||
const STEPS = [
|
||||
{ id: 'general', label: 'Information générale' },
|
||||
{ id: 'workspaces', label: 'Espaces de travail' },
|
||||
{ id: 'ia', label: 'Assistant IA' },
|
||||
];
|
||||
|
||||
const ROLE_OPTIONS = [{ value: 'user', label: 'Utilisateur' }, { value: 'admin', label: 'Administrateur' }];
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'active', label: 'Actif' },
|
||||
{ value: 'deactivated', label: 'Désactivé' },
|
||||
{ value: 'locked', label: 'Verrouillé' },
|
||||
];
|
||||
|
||||
function Toggle({ checked, onClick, disabled, title }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: 38, height: 21, borderRadius: 20, border: 'none', flexShrink: 0,
|
||||
background: checked ? 'var(--primary)' : 'var(--border)',
|
||||
position: 'relative', cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1, transition: 'background .15s',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, left: checked ? 19 : 2,
|
||||
width: 17, height: 17, borderRadius: '50%', background: '#fff',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,.3)', transition: 'left .15s',
|
||||
}} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ManageRightsModal({ open, user, currentUserId, aiAvailable, aiUnavailableReason, onClose, onSaved }) {
|
||||
const isSelf = !!user && user.id === currentUserId;
|
||||
|
||||
const [step, setStep] = useState('general');
|
||||
const [form, setForm] = useState(null);
|
||||
const [catalog, setCatalog] = useState([]);
|
||||
const [loadingCat, setLoadingCat] = useState(false);
|
||||
const [err, setErr] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !user) return;
|
||||
setStep('general');
|
||||
setErr(null);
|
||||
setForm({
|
||||
role: user.role || 'user',
|
||||
status: user.status || 'active',
|
||||
workspaceIds: user.workspace_ids || [],
|
||||
aiEnabled: !!user.ai_enabled,
|
||||
});
|
||||
setLoadingCat(true);
|
||||
api.get('/admin/workspaces')
|
||||
.then(setCatalog)
|
||||
.catch(() => setCatalog([]))
|
||||
.finally(() => setLoadingCat(false));
|
||||
}, [open, user]);
|
||||
|
||||
const toggleWorkspace = useCallback((id) => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
workspaceIds: f.workspaceIds.includes(id)
|
||||
? f.workspaceIds.filter(w => w !== id)
|
||||
: [...f.workspaceIds, id],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
if (!open || !user || !form) return null;
|
||||
|
||||
const stepIndex = STEPS.findIndex(s => s.id === step);
|
||||
|
||||
async function handleSave() {
|
||||
setErr(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
const calls = [];
|
||||
if (!isSelf) {
|
||||
if (form.role !== (user.role || 'user')) {
|
||||
calls.push(api.patch(`/admin/users/${user.id}/role`, { role: form.role }));
|
||||
}
|
||||
if (form.status !== (user.status || 'active')) {
|
||||
calls.push(api.patch(`/admin/users/${user.id}/status`, { status: form.status }));
|
||||
}
|
||||
}
|
||||
const origWs = [...(user.workspace_ids || [])].sort().join(',');
|
||||
const newWs = [...form.workspaceIds].sort().join(',');
|
||||
if (origWs !== newWs) {
|
||||
calls.push(api.patch(`/admin/users/${user.id}/workspaces`, { workspaceIds: form.workspaceIds }));
|
||||
}
|
||||
if (form.aiEnabled !== !!user.ai_enabled) {
|
||||
calls.push(api.patch(`/admin/users/${user.id}/ai-access`, { aiEnabled: form.aiEnabled }));
|
||||
}
|
||||
await Promise.all(calls);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setErr(e.message || "Erreur lors de l'enregistrement");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const identity = user.display_name || user.email;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={`Gérer les droits — ${identity}`}
|
||||
onClose={onClose}
|
||||
width={560}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" type="button" onClick={onClose}>Annuler</button>
|
||||
{stepIndex > 0 && (
|
||||
<button className="btn btn-outline" type="button" onClick={() => setStep(STEPS[stepIndex - 1].id)}>
|
||||
Précédent
|
||||
</button>
|
||||
)}
|
||||
{stepIndex < STEPS.length - 1 && (
|
||||
<button className="btn btn-outline" type="button" onClick={() => setStep(STEPS[stepIndex + 1].id)}>
|
||||
Suivant
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary" type="button" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Enregistrement…' : 'Enregistrer'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* ── Indicateur d'étape (même pattern que InvestissementFormModal) ── */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
||||
Étape {stepIndex + 1} : {STEPS[stepIndex].label}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s.id} style={{
|
||||
flex: 1, height: 5, borderRadius: 4,
|
||||
background: i <= stepIndex ? 'var(--primary)' : 'var(--border)',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step === 'general' && (
|
||||
<div>
|
||||
{isSelf && (
|
||||
<p style={{ fontSize: 12.5, color: 'var(--text-muted)', background: 'var(--surface-2)', padding: '8px 12px', borderRadius: 8, marginTop: 0 }}>
|
||||
Vous ne pouvez pas modifier votre propre rôle ni votre propre statut depuis cet écran.
|
||||
</p>
|
||||
)}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>Rôle</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={form.role}
|
||||
disabled={isSelf}
|
||||
onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
|
||||
>
|
||||
{ROLE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>Statut du compte</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={form.status}
|
||||
disabled={isSelf}
|
||||
onChange={e => setForm(f => ({ ...f, status: e.target.value }))}
|
||||
>
|
||||
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'workspaces' && (
|
||||
<div>
|
||||
<p style={{ margin: '0 0 12px', fontSize: 12.5, color: 'var(--text-muted)' }}>
|
||||
Espaces de travail accordés à cet utilisateur. L'utilisateur pourra ensuite les activer ou
|
||||
les masquer lui-même depuis ses paramètres.
|
||||
</p>
|
||||
{loadingCat ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Chargement…</p>
|
||||
) : catalog.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>Aucun espace de travail défini dans le catalogue.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{catalog.map(w => {
|
||||
const granted = form.workspaceIds.includes(w.id);
|
||||
return (
|
||||
<div
|
||||
key={w.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px',
|
||||
borderRadius: 8, border: '1px solid var(--border)',
|
||||
background: granted ? 'var(--surface-2)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14.5 }}>{w.nom}</div>
|
||||
{w.description && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{w.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{!w.actif_global && (
|
||||
<span style={{ fontSize: 10.5, color: 'var(--text-muted)', border: '1px solid var(--border)', borderRadius: 10, padding: '1px 7px', flexShrink: 0 }}>
|
||||
indisponible
|
||||
</span>
|
||||
)}
|
||||
<Toggle
|
||||
checked={granted}
|
||||
onClick={() => toggleWorkspace(w.id)}
|
||||
title={granted ? 'Retirer cet espace de travail' : 'Accorder cet espace de travail'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{form.workspaceIds.length === 0 && (
|
||||
<p style={{ fontSize: 12, color: 'var(--danger, #dc2626)', marginTop: 10 }}>
|
||||
Aucun espace de travail accordé : l'utilisateur n'aura accès à aucune fonctionnalité.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'ia' && (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px',
|
||||
borderRadius: 8, border: '1px solid var(--border)',
|
||||
background: form.aiEnabled ? 'var(--surface-2)' : 'transparent',
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14.5 }}>Accès à l'Assistant IA</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>
|
||||
{aiAvailable
|
||||
? "Permet à l'utilisateur d'utiliser le chat de l'Assistant IA."
|
||||
: aiUnavailableReason}
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={form.aiEnabled}
|
||||
disabled={!aiAvailable}
|
||||
onClick={() => setForm(f => ({ ...f, aiEnabled: !f.aiEnabled }))}
|
||||
title={!aiAvailable ? aiUnavailableReason : (form.aiEnabled ? "Désactiver l'accès à l'Assistant IA" : "Activer l'accès à l'Assistant IA")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <div className="error" style={{ marginTop: 14 }}>{err}</div>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user