275 lines
11 KiB
React
275 lines
11 KiB
React
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>
|
|
);
|
|
}
|