Mise en place des workspaces

This commit is contained in:
ocroguennec committed 2026-09-08 12:33:04 +02:00
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>
);
}