Mise en place des workspaces
This commit is contained in:
1 parent
be3c93897b
commit
6b6cad7cd6
19 files changed
+1248
-31
No files matched your search
@@ -3028,4 +3028,79 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_ai_messages_thread ON ai_messages(user_i
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Migration : Workspaces (extension multi-workspace, 08/09/26) ─────────
|
||||
// Transforme l'app en socle commun + "workspaces" activables/désactivables
|
||||
// par utilisateur, gouvernés par l'admin — cf. project_workspaces_transformation.md
|
||||
// (mémoire projet) pour le plan complet. Phase 0 : catalogue global
|
||||
// (`workspaces`) + octroi (admin) / activation (utilisateur) par compte
|
||||
// (`user_workspaces`). Le workspace "crowdlending" représente l'app
|
||||
// actuelle et est accordé + activé automatiquement à tous les comptes
|
||||
// existants (backfill ci-dessous) et à tout nouveau compte (cf.
|
||||
// grantDefaultWorkspace, appelée depuis auth.js /register, admin.js POST
|
||||
// /users et invitations.js /:token/register — mêmes 3 points que la
|
||||
// création auto de l'investisseur principal). Volontairement PAS ajouté à
|
||||
// ENV_LOCAL_TABLES : ce sont des données métier normales (comme
|
||||
// investissements), pas des réglages propres à un environnement — une
|
||||
// restauration doit les écraser comme le reste.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
nom TEXT NOT NULL,
|
||||
libelle_menu TEXT NOT NULL,
|
||||
description TEXT,
|
||||
actif_global INTEGER NOT NULL DEFAULT 1,
|
||||
ordre INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_workspaces (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
granted_by_admin INTEGER NOT NULL DEFAULT 0,
|
||||
active_by_user INTEGER NOT NULL DEFAULT 1,
|
||||
granted_at TEXT,
|
||||
PRIMARY KEY (user_id, workspace_id)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_user_workspaces_user ON user_workspaces(user_id)');
|
||||
|
||||
// Seed : workspace "crowdlending" = l'app actuelle. Idempotent (slug UNIQUE).
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO workspaces (slug, nom, libelle_menu, description, actif_global, ordre)
|
||||
VALUES ('crowdlending', 'Crowdlending', 'Crowdlending', 'Suivi de portefeuille crowdlending', 1, 0)
|
||||
`).run();
|
||||
|
||||
/** Accorde + active le workspace par défaut (crowdlending) à un utilisateur.
|
||||
* Appelée à la création d'un compte (auth.js /register, admin.js POST
|
||||
* /users, invitations.js /:token/register) — même logique que la création
|
||||
* auto du profil investisseur principal à ces 3 mêmes endroits, pour
|
||||
* qu'aucun nouveau compte ne se retrouve sans accès à l'app actuelle. */
|
||||
export function grantDefaultWorkspace(userId) {
|
||||
const ws = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (!ws) return;
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at)
|
||||
VALUES (?, ?, 1, 1, datetime('now'))
|
||||
`).run(userId, ws.id);
|
||||
}
|
||||
|
||||
// Backfill : tout utilisateur existant sans accès au workspace crowdlending
|
||||
// (comptes créés avant cette migration) le reçoit automatiquement — ne doit
|
||||
// rien casser du fonctionnement actuel. Idempotent (INSERT OR IGNORE sur la
|
||||
// PK composite user_id/workspace_id).
|
||||
{
|
||||
const wsBackfill = db.prepare("SELECT id FROM workspaces WHERE slug = 'crowdlending'").get();
|
||||
if (wsBackfill) {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at)
|
||||
SELECT id, ?, 1, 1, datetime('now') FROM users
|
||||
`).run(wsBackfill.id);
|
||||
}
|
||||
}
|
||||
|
||||
export default db;
|
||||
@@ -6,7 +6,7 @@ import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import db from '../db/index.js';
|
||||
import db, { grantDefaultWorkspace } from '../db/index.js';
|
||||
import Database from 'better-sqlite3';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { checkStatutsRetard } from '../jobs/autoStatut.js';
|
||||
@@ -79,10 +79,17 @@ router.get('/users', (req, res) => {
|
||||
JOIN investisseurs inv ON inv.id = i.investisseur_id
|
||||
WHERE inv.user_id = users.id) AS nb_investissements,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.user_id = users.id) AS nb_documents,
|
||||
(SELECT COALESCE(SUM(d.taille_octets), 0) FROM documents d WHERE d.user_id = users.id) AS taille_documents
|
||||
(SELECT COALESCE(SUM(d.taille_octets), 0) FROM documents d WHERE d.user_id = users.id) AS taille_documents,
|
||||
(SELECT GROUP_CONCAT(workspace_id) FROM user_workspaces uw WHERE uw.user_id = users.id AND uw.granted_by_admin = 1) AS workspace_ids
|
||||
FROM users
|
||||
ORDER BY id ASC
|
||||
`).all();
|
||||
// workspace_ids : GROUP_CONCAT renvoie une chaîne "1,2" ou null — converti ici
|
||||
// en tableau de nombres pour rester cohérent avec le format attendu côté
|
||||
// frontend (ManageRightsModal), plutôt que de parser la chaîne à chaque endroit.
|
||||
for (const u of users) {
|
||||
u.workspace_ids = u.workspace_ids ? u.workspace_ids.split(',').map(Number) : [];
|
||||
}
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
@@ -131,6 +138,10 @@ router.post('/users', (req, res, next) => {
|
||||
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
|
||||
).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
|
||||
|
||||
// Accorde l'accès au workspace socle (crowdlending), comme tout nouveau compte
|
||||
// — cf. project_workspaces_transformation.md.
|
||||
grantDefaultWorkspace(userId);
|
||||
|
||||
audit(req, { action: 'user_created', category: 'account', actorId: req.user.id, targetUserId: userId, details: { email: body.email, role: body.role, created_by_admin: true } });
|
||||
res.status(201).json({ id: userId, email: body.email, display_name: body.displayName || null, role: body.role });
|
||||
} catch (e) { next(e); }
|
||||
@@ -173,6 +184,39 @@ router.patch('/users/:id/ai-access', (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Définit les workspaces accordés à un utilisateur (octroi admin) — remplace
|
||||
* intégralement l'ensemble accordé (granted_by_admin) par la liste fournie.
|
||||
* L'activation personnelle (active_by_user) n'est jamais touchée pour un
|
||||
* workspace déjà accordé, pour ne pas écraser un choix déjà fait par
|
||||
* l'utilisateur ; elle est mise à 1 par défaut pour un nouvel octroi. */
|
||||
const PatchWorkspacesSchema = z.object({
|
||||
workspaceIds: z.array(z.number().int()),
|
||||
});
|
||||
|
||||
router.patch('/users/:id/workspaces', (req, res, next) => {
|
||||
try {
|
||||
const { workspaceIds } = PatchWorkspacesSchema.parse(req.body);
|
||||
const targetId = Number(req.params.id);
|
||||
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(targetId);
|
||||
if (!target) throw new HttpError(404, 'Utilisateur introuvable');
|
||||
|
||||
const applyGrants = db.transaction(() => {
|
||||
db.prepare('UPDATE user_workspaces SET granted_by_admin = 0 WHERE user_id = ?').run(targetId);
|
||||
for (const wsId of workspaceIds) {
|
||||
db.prepare(`
|
||||
INSERT INTO user_workspaces (user_id, workspace_id, granted_by_admin, active_by_user, granted_at)
|
||||
VALUES (?, ?, 1, 1, datetime('now'))
|
||||
ON CONFLICT(user_id, workspace_id) DO UPDATE SET granted_by_admin = 1, granted_at = datetime('now')
|
||||
`).run(targetId, wsId);
|
||||
}
|
||||
});
|
||||
applyGrants();
|
||||
|
||||
audit(req, { action: 'workspaces_changed', category: 'workspaces', actorId: req.user.id, targetUserId: targetId, details: { workspace_ids: workspaceIds } });
|
||||
res.json({ id: targetId, workspaceIds });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Modifie le rôle d'un utilisateur */
|
||||
const PatchRoleSchema = z.object({
|
||||
role: z.enum(['user', 'admin']),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { audit } from '../utils/audit.js';
|
||||
|
||||
/* ── Catalogue des workspaces (Administration > Espaces de travail) ──────
|
||||
* Gère la liste globale des espaces de travail proposés par l'application
|
||||
* (ex. "Crowdlending", futur "Private Equity"…) — indépendant de l'octroi
|
||||
* par utilisateur, qui se fait via PATCH /admin/users/:id/workspaces (voir
|
||||
* admin.js). Voir project_workspaces_transformation.md (mémoire projet)
|
||||
* pour le plan complet. requireAuth + requireAdmin appliqués dans server.js
|
||||
* avant ce router. */
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT w.*,
|
||||
(SELECT COUNT(*) FROM user_workspaces uw WHERE uw.workspace_id = w.id AND uw.granted_by_admin = 1) AS nb_utilisateurs
|
||||
FROM workspaces w
|
||||
ORDER BY w.ordre ASC, w.id ASC
|
||||
`).all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
const WorkspaceCreateSchema = z.object({
|
||||
slug: z.string().min(1).max(40).regex(SLUG_RE, 'Slug : minuscules, chiffres et tirets uniquement'),
|
||||
nom: z.string().min(1),
|
||||
libelleMenu: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
actifGlobal: z.boolean().default(true),
|
||||
ordre: z.number().int().default(0),
|
||||
});
|
||||
|
||||
router.post('/', (req, res, next) => {
|
||||
try {
|
||||
const body = WorkspaceCreateSchema.parse(req.body);
|
||||
const exists = db.prepare('SELECT id FROM workspaces WHERE slug = ?').get(body.slug);
|
||||
if (exists) throw new HttpError(409, 'Ce slug est déjà utilisé');
|
||||
|
||||
const r = db.prepare(`
|
||||
INSERT INTO workspaces (slug, nom, libelle_menu, description, actif_global, ordre)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
`).run(body.slug, body.nom, body.libelleMenu, body.description || null, body.actifGlobal ? 1 : 0, body.ordre);
|
||||
|
||||
audit(req, { action: 'workspace_created', category: 'workspaces', actorId: req.user.id, details: { slug: body.slug } });
|
||||
res.status(201).json({ id: r.lastInsertRowid, slug: body.slug, nom: body.nom, libelle_menu: body.libelleMenu });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
const WorkspaceUpdateSchema = z.object({
|
||||
nom: z.string().min(1),
|
||||
libelleMenu: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
actifGlobal: z.boolean(),
|
||||
ordre: z.number().int(),
|
||||
});
|
||||
|
||||
router.put('/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db.prepare('SELECT id FROM workspaces WHERE id = ?').get(id);
|
||||
if (!existing) throw new HttpError(404, 'Espace de travail introuvable');
|
||||
|
||||
const body = WorkspaceUpdateSchema.parse(req.body);
|
||||
db.prepare(`
|
||||
UPDATE workspaces SET nom=?, libelle_menu=?, description=?, actif_global=?, ordre=?, updated_at=datetime('now')
|
||||
WHERE id=?
|
||||
`).run(body.nom, body.libelleMenu, body.description || null, body.actifGlobal ? 1 : 0, body.ordre, id);
|
||||
|
||||
audit(req, { action: 'workspace_updated', category: 'workspaces', actorId: req.user.id, details: { id } });
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id);
|
||||
if (!ws) throw new HttpError(404, 'Espace de travail introuvable');
|
||||
// Le workspace socle ne peut pas être supprimé — toute l'app actuelle en dépend.
|
||||
if (ws.slug === 'crowdlending') throw new HttpError(400, 'Le workspace Crowdlending ne peut pas être supprimé');
|
||||
|
||||
db.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
|
||||
audit(req, { action: 'workspace_deleted', category: 'workspaces', actorId: req.user.id, details: { slug: ws.slug } });
|
||||
res.status(204).end();
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import db, { grantDefaultWorkspace } from '../db/index.js';
|
||||
import { signToken, requireAuth } from '../middleware/auth.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js';
|
||||
@@ -60,6 +60,10 @@ router.post('/register', async (req, res, next) => {
|
||||
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
|
||||
).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
|
||||
|
||||
// Accorde l'accès au workspace socle (crowdlending) à tout nouveau compte
|
||||
// — cf. project_workspaces_transformation.md.
|
||||
grantDefaultWorkspace(userId);
|
||||
|
||||
// Si l'utilisateur doit vérifier son email → envoyer l'email de bienvenue
|
||||
if (!autoVerified) {
|
||||
const vToken = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Router } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import db, { grantDefaultWorkspace } from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js';
|
||||
import { audit } from '../utils/audit.js';
|
||||
@@ -191,6 +191,10 @@ router.post('/:token/register', async (req, res, next) => {
|
||||
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
|
||||
).run(pendingUser.id, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
|
||||
|
||||
// Accorde l'accès au workspace socle (crowdlending) à tout nouveau compte
|
||||
// — cf. project_workspaces_transformation.md.
|
||||
grantDefaultWorkspace(pendingUser.id);
|
||||
|
||||
// Marquer l'invitation comme utilisée
|
||||
db.prepare("UPDATE invitations SET used_at = datetime('now') WHERE id = ?").run(inv.id);
|
||||
audit(req, { action: 'invitation_accepted', category: 'invitation', targetUserId: pendingUser.id, details: { email: inv.email, role: inv.role } });
|
||||
|
||||
@@ -11,6 +11,7 @@ const ALLOWED_KEYS = new Set([
|
||||
'pfo_assujetti',
|
||||
'objectifs_actifs',
|
||||
'color_theme',
|
||||
'active_workspace_id',
|
||||
// Extensible : ajouter ici les futures prefs (font_scale, langue, devise, display_mode…)
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
|
||||
/* ── Espaces de travail de l'utilisateur connecté ─────────────────────────
|
||||
* Liste les workspaces accordés par l'admin (granted_by_admin=1) avec leur
|
||||
* état d'activation personnelle (active_by_user) — un workspace non accordé
|
||||
* n'apparaît jamais ici, même désactivé. Voir aussi adminWorkspaces.js
|
||||
* (catalogue global) et admin.js (octroi par utilisateur, PATCH
|
||||
* /admin/users/:id/workspaces). requireAuth appliqué dans server.js. */
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT w.id, w.slug, w.nom, w.libelle_menu, w.description, uw.active_by_user
|
||||
FROM user_workspaces uw
|
||||
JOIN workspaces w ON w.id = uw.workspace_id
|
||||
WHERE uw.user_id = ? AND uw.granted_by_admin = 1 AND w.actif_global = 1
|
||||
ORDER BY w.ordre ASC, w.id ASC
|
||||
`).all(req.user.id);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
const ActiveSchema = z.object({ active: z.boolean() });
|
||||
|
||||
router.patch('/:id/active', (req, res, next) => {
|
||||
try {
|
||||
const { active } = ActiveSchema.parse(req.body);
|
||||
const workspaceId = Number(req.params.id);
|
||||
|
||||
const grant = db.prepare(
|
||||
'SELECT 1 FROM user_workspaces WHERE user_id = ? AND workspace_id = ? AND granted_by_admin = 1'
|
||||
).get(req.user.id, workspaceId);
|
||||
if (!grant) throw new HttpError(403, "Vous n'avez pas accès à cet espace de travail");
|
||||
|
||||
db.prepare(
|
||||
'UPDATE user_workspaces SET active_by_user = ? WHERE user_id = ? AND workspace_id = ?'
|
||||
).run(active ? 1 : 0, req.user.id, workspaceId);
|
||||
|
||||
res.json({ id: workspaceId, active });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -58,6 +58,8 @@ import apiKeysRouter from './routes/apiKeys.js';
|
||||
import v1Router from './routes/v1/index.js';
|
||||
import documentsRouter from './routes/documents.js';
|
||||
import aiRouter from './routes/ai.js';
|
||||
import workspacesRouter from './routes/workspaces.js';
|
||||
import adminWorkspacesRouter from './routes/adminWorkspaces.js';
|
||||
import { requireApiKey } from './middleware/apiKey.js';
|
||||
import { swaggerSpec, swaggerUi } from './swagger.js';
|
||||
import db from './db/index.js';
|
||||
@@ -170,6 +172,8 @@ app.use('/api/tickets', requireAuth, ticketsRouter);
|
||||
app.use('/api/api-keys', requireAuth, apiKeysRouter);
|
||||
app.use('/api/documents', requireAuth, documentsRouter);
|
||||
app.use('/api/ai', requireAuth, aiRouter);
|
||||
app.use('/api/workspaces', requireAuth, workspacesRouter);
|
||||
app.use('/api/admin/workspaces', requireAuth, requireAdmin, adminWorkspacesRouter);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
|
||||
+46
-13
@@ -1,6 +1,7 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from './context/AuthContext.jsx';
|
||||
import { useWorkspace } from './context/WorkspaceContext.jsx';
|
||||
import Login from './pages/Login.jsx';
|
||||
import Register from './pages/Register.jsx';
|
||||
import InvitationRegister from './pages/InvitationRegister.jsx';
|
||||
@@ -53,6 +54,38 @@ function FraisOnly({ children }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// ── Gating de navigation par workspace (Phase 4, étape 0 — 08/09/26) ────
|
||||
// cf. project_workspaces_transformation.md (mémoire projet). N'agit
|
||||
// aujourd'hui sur rien de visible tant qu'un seul workspace (crowdlending)
|
||||
// existe réellement : `active` vaut alors toujours 'crowdlending' pour tout
|
||||
// utilisateur. Volontairement "fail-open" — si `active` n'est pas encore
|
||||
// chargé (null), on laisse passer plutôt que de bloquer, pour ne jamais
|
||||
// afficher un flash de contenu erroné le temps du premier chargement.
|
||||
function RequireWorkspace({ slug, children }) {
|
||||
const { active } = useWorkspace() || {};
|
||||
if (active && active.slug !== slug) return <Navigate to="/" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
// Page d'accueil sensible au workspace actif : le Dashboard crowdlending
|
||||
// tant que ce workspace est actif (comportement actuel, inchangé), sinon un
|
||||
// simple message d'attente — appelé à être remplacé par le vrai contenu du
|
||||
// workspace concerné au fur et à mesure de sa construction (Phase 4+).
|
||||
function WorkspaceHome() {
|
||||
const { active } = useWorkspace() || {};
|
||||
if (active && active.slug !== 'crowdlending') {
|
||||
return (
|
||||
<div style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<h2 style={{ margin: '0 0 8px', color: 'var(--text)' }}>{active.nom}</h2>
|
||||
<p style={{ margin: 0 }}>
|
||||
{active.description || "Cet espace de travail n'est pas encore disponible."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Dashboard />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [allowRegistration, setAllowRegistration] = useState(true);
|
||||
|
||||
@@ -96,29 +129,29 @@ export default function App() {
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route element={<Protected><Layout /></Protected>}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="plateformes" element={<Plateformes />} />
|
||||
<Route path="depots-retraits" element={<DepotsRetraits />} />
|
||||
<Route path="investissements" element={<Investissements />} />
|
||||
<Route path="investissements/:id" element={<InvestissementDetail />} />
|
||||
<Route path="remboursements" element={<Remboursements />} />
|
||||
<Route path="frais" element={<FraisOnly><Frais /></FraisOnly>} />
|
||||
<Route path="simul" element={<SimulRemboursements />} />
|
||||
<Route path="taxreport" element={<TaxReport />} />
|
||||
<Route path="assistant-ia" element={<AssistantIA />} />
|
||||
<Route index element={<WorkspaceHome />} />
|
||||
<Route path="plateformes" element={<RequireWorkspace slug="crowdlending"><Plateformes /></RequireWorkspace>} />
|
||||
<Route path="depots-retraits" element={<RequireWorkspace slug="crowdlending"><DepotsRetraits /></RequireWorkspace>} />
|
||||
<Route path="investissements" element={<RequireWorkspace slug="crowdlending"><Investissements /></RequireWorkspace>} />
|
||||
<Route path="investissements/:id" element={<RequireWorkspace slug="crowdlending"><InvestissementDetail /></RequireWorkspace>} />
|
||||
<Route path="remboursements" element={<RequireWorkspace slug="crowdlending"><Remboursements /></RequireWorkspace>} />
|
||||
<Route path="frais" element={<RequireWorkspace slug="crowdlending"><FraisOnly><Frais /></FraisOnly></RequireWorkspace>} />
|
||||
<Route path="simul" element={<RequireWorkspace slug="crowdlending"><SimulRemboursements /></RequireWorkspace>} />
|
||||
<Route path="taxreport" element={<RequireWorkspace slug="crowdlending"><TaxReport /></RequireWorkspace>} />
|
||||
<Route path="assistant-ia" element={<RequireWorkspace slug="crowdlending"><AssistantIA /></RequireWorkspace>} />
|
||||
<Route path="2778-sd" element={<Navigate to="/taxreport" replace />} />
|
||||
<Route path="imports" element={<Navigate to="/settings?section=imports" replace />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="preferences" element={<Navigate to="/settings?section=apparence" replace />} />
|
||||
<Route path="compte" element={<MonCompte />} />
|
||||
<Route path="admin" element={<AdminOnly><Admin /></AdminOnly>} />
|
||||
<Route path="admin/plateformes" element={<AdminOnly><AdminPlateformes /></AdminOnly>} />
|
||||
<Route path="admin/fiscalite" element={<AdminOnly><AdminFiscalite /></AdminOnly>} />
|
||||
<Route path="admin/plateformes" element={<RequireWorkspace slug="crowdlending"><AdminOnly><AdminPlateformes /></AdminOnly></RequireWorkspace>} />
|
||||
<Route path="admin/fiscalite" element={<RequireWorkspace slug="crowdlending"><AdminOnly><AdminFiscalite /></AdminOnly></RequireWorkspace>} />
|
||||
<Route path="admin/ia" element={<AdminOnly><AdminIA /></AdminOnly>} />
|
||||
<Route path="aide" element={<Aide />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
<Route path="communication" element={<Communication />} />
|
||||
<Route path="referentiel/:id" element={<PlatformeProfile />} />
|
||||
<Route path="referentiel/:id" element={<RequireWorkspace slug="crowdlending"><PlatformeProfile /></RequireWorkspace>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useWorkspace } from '../context/WorkspaceContext.jsx';
|
||||
import { useAuth } from '../context/AuthContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import Logo from './Logo.jsx';
|
||||
@@ -253,6 +254,10 @@ const IconBurger = () => (
|
||||
export default function Layout() {
|
||||
const { sidebarCollapsed, setSidebarCollapsed, toggleSidebar, displayMode, setDisplayMode } = useUi();
|
||||
const { hasFrais } = useAuth();
|
||||
const { active: activeWorkspace } = useWorkspace() || {};
|
||||
// Gating de nav par workspace (Phase 4, étape 0) : fail-open tant que
|
||||
// activeWorkspace n'est pas chargé, cf. App.jsx (RequireWorkspace).
|
||||
const isCrowdlendingActive = !activeWorkspace || activeWorkspace.slug === 'crowdlending';
|
||||
const navigate = useNavigate();
|
||||
const [navIcons, setNavIcons] = useState({});
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
@@ -299,7 +304,7 @@ export default function Layout() {
|
||||
<div className="sidebar-brand-logo">
|
||||
<Logo size={34} />
|
||||
</div>
|
||||
<span className="sidebar-brand-text">Crowdlending</span>
|
||||
<span className="sidebar-brand-text">{activeWorkspace?.libelle_menu || 'Crowdlending'}</span>
|
||||
|
||||
{/* Réduire (visible seulement en mode étendu) */}
|
||||
<button
|
||||
@@ -327,6 +332,8 @@ export default function Layout() {
|
||||
<NavLink to="/" end title="Tableau de bord">
|
||||
<NavIcon libFilename={navIcons.dashboard} Fallback={IconDashboard} /><span className="nav-label">Tableau de bord</span>
|
||||
</NavLink>
|
||||
{isCrowdlendingActive && (
|
||||
<>
|
||||
<NavLink to="/plateformes" title="Plateformes">
|
||||
<NavIcon libFilename={navIcons.plateforme} Fallback={IconInvestments} /><span className="nav-label">Plateformes</span>
|
||||
</NavLink>
|
||||
@@ -352,6 +359,8 @@ export default function Layout() {
|
||||
<NavIcon libFilename={navIcons.assistantIa} Fallback={IconAssistantIa} /><span className="nav-label">Assistant IA</span>
|
||||
</NavLink>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext.jsx';
|
||||
import { useUi } from '../context/UiContext.jsx';
|
||||
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
|
||||
import { useWorkspace } from '../context/WorkspaceContext.jsx';
|
||||
import { memberInitials, memberLabel } from '../utils/format.js';
|
||||
|
||||
/* ── Icons ───────────────────────────────────────────────────── */
|
||||
@@ -47,6 +48,9 @@ function IconCheck() {
|
||||
function IconTeam() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>;
|
||||
}
|
||||
function IconLayers() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>;
|
||||
}
|
||||
|
||||
/* ── Avatars ─────────────────────────────────────────────────── */
|
||||
function UserAvatar({ user, size = 36 }) {
|
||||
@@ -100,15 +104,31 @@ function TeamBadge({ size = 28 }) {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceBadge({ size = 28 }) {
|
||||
return (
|
||||
<div className="team-badge" style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%)',
|
||||
border: '1.5px solid rgba(255,255,255,.25)',
|
||||
color: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<IconLayers />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Composant principal ─────────────────────────────────────── */
|
||||
export default function UserMenu() {
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
const { sidebarCollapsed } = useUi();
|
||||
const { investisseurs, activeView, activeViewMember, setActiveView } = useInvestisseur();
|
||||
const { activeWorkspaces, active: activeWorkspace, setActiveWorkspace, hasMultipleWorkspaces } = useWorkspace() || {};
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [activeSubPanel, setActiveSubPanel] = useState(null); // null | 'profile' | 'workspace'
|
||||
const [popupStyle, setPopupStyle] = useState({});
|
||||
const [subStyle, setSubStyle] = useState({});
|
||||
|
||||
@@ -154,7 +174,7 @@ export default function UserMenu() {
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
const openMenu = useCallback(() => { computePosition(); setOpen(true); }, [computePosition]);
|
||||
const closeMenu = useCallback(() => { setOpen(false); setSubOpen(false); }, []);
|
||||
const closeMenu = useCallback(() => { setOpen(false); setActiveSubPanel(null); }, []);
|
||||
|
||||
const clearClose = () => clearTimeout(closeTimer.current);
|
||||
const scheduleClose = () => { closeTimer.current = setTimeout(closeMenu, 200); };
|
||||
@@ -211,6 +231,11 @@ export default function UserMenu() {
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
const selectWorkspace = (id) => {
|
||||
setActiveWorkspace?.(id);
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
/* ── Libellés ────────────────────────────────────────────── */
|
||||
const viewLabel = activeView === 'all'
|
||||
? 'Famille et entreprises'
|
||||
@@ -248,12 +273,31 @@ export default function UserMenu() {
|
||||
|
||||
<div className="user-menu-sep" />
|
||||
|
||||
{/* Section Espace de travail (visible uniquement si l'utilisateur
|
||||
a plusieurs workspaces actifs) */}
|
||||
{hasMultipleWorkspaces && (
|
||||
<>
|
||||
<div className="user-menu-section-header">Espace de travail</div>
|
||||
<button
|
||||
className={`user-menu-item user-menu-vue-row${activeSubPanel === 'workspace' ? ' active' : ''}`}
|
||||
role="menuitem"
|
||||
onClick={() => setActiveSubPanel(p => p === 'workspace' ? null : 'workspace')}
|
||||
>
|
||||
<WorkspaceBadge size={24} />
|
||||
<span className="user-menu-vue-name">{activeWorkspace?.libelle_menu || activeWorkspace?.nom || 'Espace de travail'}</span>
|
||||
<span style={{ color: '#4a6490', display: 'flex' }}><IconChevronRight /></span>
|
||||
</button>
|
||||
|
||||
<div className="user-menu-sep" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Section Vue du profil */}
|
||||
<div className="user-menu-section-header">Vue du profil</div>
|
||||
<button
|
||||
className={`user-menu-item user-menu-vue-row${subOpen ? ' active' : ''}`}
|
||||
className={`user-menu-item user-menu-vue-row${activeSubPanel === 'profile' ? ' active' : ''}`}
|
||||
role="menuitem"
|
||||
onClick={() => setSubOpen(s => !s)}
|
||||
onClick={() => setActiveSubPanel(p => p === 'profile' ? null : 'profile')}
|
||||
>
|
||||
{activeView === 'all'
|
||||
? <TeamBadge size={24} />
|
||||
@@ -295,8 +339,34 @@ export default function UserMenu() {
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* ── Sub-panel Espace de travail (portail) ───────────────── */}
|
||||
{open && activeSubPanel === 'workspace' && createPortal(
|
||||
<div
|
||||
ref={subRef}
|
||||
className="user-menu-subpanel"
|
||||
style={subStyle}
|
||||
onMouseEnter={clearClose}
|
||||
onMouseLeave={() => { if (sidebarCollapsed) scheduleClose(); }}
|
||||
>
|
||||
<div className="user-menu-subpanel-title">Espace de travail</div>
|
||||
|
||||
{(activeWorkspaces || []).map(ws => (
|
||||
<button
|
||||
key={ws.id}
|
||||
className={`user-menu-profile-item${activeWorkspace?.id === ws.id ? ' selected' : ''}`}
|
||||
onClick={() => selectWorkspace(ws.id)}
|
||||
>
|
||||
<WorkspaceBadge size={26} />
|
||||
<span className="user-menu-profile-name">{ws.libelle_menu || ws.nom}</span>
|
||||
{activeWorkspace?.id === ws.id && <IconCheck />}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* ── Sub-panel Vue du profil (portail) ───────────────────── */}
|
||||
{open && subOpen && createPortal(
|
||||
{open && activeSubPanel === 'profile' && createPortal(
|
||||
<div
|
||||
ref={subRef}
|
||||
className="user-menu-subpanel"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { useAuth } from './AuthContext.jsx';
|
||||
|
||||
/**
|
||||
* Espaces de travail (workspaces) accordés par l'admin à l'utilisateur
|
||||
* connecté — cf. project_workspaces_transformation.md (mémoire projet).
|
||||
* "Crowdlending" est aujourd'hui le seul workspace existant ; ce contexte
|
||||
* ne change donc rien de visible tant qu'un 2e workspace n'a pas été créé
|
||||
* et accordé à au moins un utilisateur (hasMultipleWorkspaces reste false).
|
||||
*
|
||||
* activeWorkspaceId est persisté en DB (table user_preferences, clé
|
||||
* active_workspace_id) selon la même stratégie que UiContext : la DB fait
|
||||
* foi, le localStorage sert de cache immédiat pour éviter un flash au
|
||||
* chargement.
|
||||
*/
|
||||
|
||||
const WorkspaceCtx = createContext(null);
|
||||
|
||||
const KEY_ACTIVE = 'cl_active_workspace_id';
|
||||
|
||||
export function WorkspaceProvider({ children }) {
|
||||
const { token } = useAuth();
|
||||
const [workspaces, setWorkspaces] = useState([]);
|
||||
const [activeId, setActiveIdState] = useState(() => {
|
||||
const saved = localStorage.getItem(KEY_ACTIVE);
|
||||
return saved ? Number(saved) : null;
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await api.get('/workspaces');
|
||||
setWorkspaces(list);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) reload();
|
||||
else setWorkspaces([]);
|
||||
}, [token, reload]);
|
||||
|
||||
// Sync préférence DB → local, une fois authentifié (même stratégie que
|
||||
// UiContext : la DB fait foi, écrase le localStorage si présent).
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
api.get('/preferences').then(prefs => {
|
||||
if (prefs.active_workspace_id) {
|
||||
const id = Number(prefs.active_workspace_id);
|
||||
localStorage.setItem(KEY_ACTIVE, String(id));
|
||||
setActiveIdState(id);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
const setActiveWorkspace = useCallback((id) => {
|
||||
setActiveIdState(id);
|
||||
localStorage.setItem(KEY_ACTIVE, String(id));
|
||||
api.patch('/preferences', { active_workspace_id: String(id) }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Seuls les workspaces activés personnellement (active_by_user) comptent
|
||||
// pour l'affichage — un workspace accordé par l'admin mais désactivé par
|
||||
// l'utilisateur (Settings > Mes espaces de travail) reste invisible.
|
||||
const activeWorkspaces = workspaces.filter(w => w.active_by_user);
|
||||
|
||||
const active =
|
||||
activeWorkspaces.find(w => w.id === activeId) ||
|
||||
activeWorkspaces[0] ||
|
||||
null;
|
||||
|
||||
return (
|
||||
<WorkspaceCtx.Provider value={{
|
||||
workspaces, activeWorkspaces, active, activeId,
|
||||
setActiveWorkspace, reload, loading,
|
||||
hasMultipleWorkspaces: activeWorkspaces.length > 1,
|
||||
}}>
|
||||
{children}
|
||||
</WorkspaceCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useWorkspace = () => useContext(WorkspaceCtx);
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App.jsx';
|
||||
import { AuthProvider } from './context/AuthContext.jsx';
|
||||
import { InvestisseurProvider } from './context/InvestisseurContext.jsx';
|
||||
import { WorkspaceProvider } from './context/WorkspaceContext.jsx';
|
||||
import { ThemeProvider } from './context/ThemeContext.jsx';
|
||||
import { UiProvider } from './context/UiContext.jsx';
|
||||
import './styles.css';
|
||||
@@ -15,7 +16,9 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<InvestisseurProvider>
|
||||
<WorkspaceProvider>
|
||||
<App />
|
||||
</WorkspaceProvider>
|
||||
</InvestisseurProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -7,6 +7,7 @@ import IconsSection from './admin/IconsSection.jsx';
|
||||
import SmtpSection from './admin/SmtpSection.jsx';
|
||||
import GeneralSection from './admin/GeneralSection.jsx';
|
||||
import ExportSection from './admin/ExportSection.jsx';
|
||||
import WorkspacesSection from './admin/WorkspacesSection.jsx';
|
||||
|
||||
/* ── Icônes nav ───────────────────────────────────────────────── */
|
||||
function IconUsers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
|
||||
@@ -19,6 +20,7 @@ function IconSettings() { return <svg width="15" height="15" viewBox="0 0 24 24"
|
||||
function IconMail() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>; }
|
||||
function IconDownload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>; }
|
||||
function IconSparkle() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>; }
|
||||
function IconLayers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>; }
|
||||
|
||||
const NAV = [
|
||||
{
|
||||
@@ -31,6 +33,7 @@ const NAV = [
|
||||
{ id: 'icons', label: "Bibliothèque d'icônes", icon: <IconImage /> },
|
||||
{ id: 'smtp', label: 'Configuration SMTP', icon: <IconMail /> },
|
||||
{ id: 'ia', label: 'Assistant IA', icon: <IconSparkle />, href: '/admin/ia' },
|
||||
{ id: 'workspaces', label: 'Espaces de travail', icon: <IconLayers /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -85,6 +88,7 @@ export default function Admin() {
|
||||
{section === 'icons' && <IconsSection />}
|
||||
{section === 'smtp' && <SmtpSection />}
|
||||
{section === 'export' && <ExportSection />}
|
||||
{section === 'workspaces' && <WorkspacesSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import MaFiscaliteSection from './settings/MaFiscaliteSection.jsx';
|
||||
import ObjectifsSection from './settings/ObjectifsSection.jsx';
|
||||
import DataCleanupSection from './settings/DataCleanupSection.jsx';
|
||||
import ImportsSection from './settings/ImportsSection.jsx';
|
||||
import WorkspacesUserSection from './settings/WorkspacesUserSection.jsx';
|
||||
|
||||
/* ── Icônes nav ───────────────────────────────────────────────── */
|
||||
function IconFamily() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
|
||||
@@ -22,6 +23,7 @@ function IconMyFiscal() { return <svg width="15" height="15" viewBox="0 0 24 24"
|
||||
function IconTarget() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>; }
|
||||
function IconBroom() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>; }
|
||||
function IconUpload() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>; }
|
||||
function IconSpaces() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="4" width="8" height="16" rx="1.5"/><rect x="13" y="4" width="8" height="7" rx="1.5"/><rect x="13" y="13" width="8" height="7" rx="1.5"/></svg>; }
|
||||
|
||||
const NAV = [
|
||||
{
|
||||
@@ -30,6 +32,12 @@ const NAV = [
|
||||
{ id: 'apparence', label: 'Apparence', icon: <IconMonitor /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Mon espace de travail',
|
||||
items: [
|
||||
{ id: 'workspaces', label: 'Mes espaces de travail', icon: <IconSpaces /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Mon paramétrage',
|
||||
items: [
|
||||
@@ -95,6 +103,7 @@ export default function Settings() {
|
||||
{section === 'secteurs-inv' && <SecteursInvSection />}
|
||||
{section === 'nettoyage' && <DataCleanupSection />}
|
||||
{section === 'imports' && <ImportsSection />}
|
||||
{section === 'workspaces' && <WorkspacesUserSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { api } from '../../api.js';
|
||||
import ConfirmModal from '../../components/ConfirmModal.jsx';
|
||||
import ManageRightsModal from './ManageRightsModal.jsx';
|
||||
import Modal from '../../components/Modal.jsx';
|
||||
import ResultBanner from '../../components/ResultBanner.jsx';
|
||||
import PasswordStrength from '../../components/PasswordStrength.jsx';
|
||||
@@ -61,6 +62,7 @@ const IcoTrash = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="no
|
||||
const IcoDots = () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="5" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="19" r="1" fill="currentColor"/></svg>;
|
||||
const IcoSearch = () => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>;
|
||||
const IcoSparkle = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>;
|
||||
const IcoRights = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>;
|
||||
|
||||
// Interrupteur compact — accès individuel à l'Assistant IA (même style visuel
|
||||
// que le Toggle de AdminIA.jsx, dupliqué ici car non partagé entre les deux
|
||||
@@ -426,6 +428,7 @@ export default function UsersSection({ currentUserId }) {
|
||||
const [filterStatus, setFilterStatus] = useState('all');
|
||||
const [filterRole, setFilterRole] = useState('all');
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const [rightsTarget, setRightsTarget] = useState(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
// null tant que non charge (voir l'effet ci-dessous) : l'interrupteur
|
||||
@@ -488,12 +491,6 @@ export default function UsersSection({ currentUserId }) {
|
||||
const confirm = (title, message, onConfirm, confirmLabel) =>
|
||||
setConfirmAction({ title, message, onConfirm, confirmLabel });
|
||||
|
||||
const toggleRole = u => {
|
||||
const newRole = u.role === 'admin' ? 'user' : 'admin';
|
||||
confirm('Changer le rôle',
|
||||
`Changer le rôle de ${u.display_name || u.email} → ${newRole === 'admin' ? 'Administrateur' : 'Utilisateur'} ?`,
|
||||
async () => { await api.patch(`/admin/users/${u.id}/role`, { role: newRole }); load(); }, 'Confirmer');
|
||||
};
|
||||
const verifyEmail = u => confirm("Vérifier l'email",
|
||||
`Marquer l'email de ${u.display_name || u.email} comme vérifié ?`,
|
||||
async () => { await api.patch(`/admin/users/${u.id}/verify-email`, {}); load(); }, 'Confirmer');
|
||||
@@ -773,8 +770,7 @@ export default function UsersSection({ currentUserId }) {
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 200,
|
||||
}}>
|
||||
<MenuBtn icon={<IcoEdit />} label={openMenu.user.role === 'admin' ? 'Rétrograder en utilisateur' : 'Promouvoir en admin'} onClick={() => { setOpenMenu(null); toggleRole(openMenu.user); }} />
|
||||
<MenuBtn icon={<IcoSparkle />} label={openMenu.user.ai_enabled ? "Désactiver l'accès IA" : "Activer l'accès IA"} onClick={() => { setOpenMenu(null); toggleAiAccess(openMenu.user); }} />
|
||||
<MenuBtn icon={<IcoRights />} label="Gérer les droits" onClick={() => { setOpenMenu(null); setRightsTarget(openMenu.user); }} />
|
||||
{!openMenu.user.email_verified && <>
|
||||
<MenuBtn icon={<IcoMail />} label="Renvoyer l'invitation" onClick={() => { setOpenMenu(null); resendInvitation(openMenu.user); }} />
|
||||
<MenuBtn icon={<IcoCheck />} label="Vérifier l'email" onClick={() => { setOpenMenu(null); verifyEmail(openMenu.user); }} />
|
||||
@@ -812,6 +808,20 @@ export default function UsersSection({ currentUserId }) {
|
||||
onCreated={() => load()}
|
||||
/>
|
||||
|
||||
<ManageRightsModal
|
||||
open={!!rightsTarget}
|
||||
user={rightsTarget}
|
||||
currentUserId={currentUserId}
|
||||
aiAvailable={aiAvailable}
|
||||
aiUnavailableReason={aiUnavailableReason}
|
||||
onClose={() => setRightsTarget(null)}
|
||||
onSaved={() => {
|
||||
setRightsTarget(null);
|
||||
setBanner({ ok: true, msg: 'Droits mis à jour.' });
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmAction}
|
||||
title={confirmAction?.title}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
import Modal from '../../components/Modal.jsx';
|
||||
import ConfirmModal from '../../components/ConfirmModal.jsx';
|
||||
import ResultBanner from '../../components/ResultBanner.jsx';
|
||||
|
||||
/* Catalogue des espaces de travail (Administration > Espaces de travail).
|
||||
* CRUD sur la table `workspaces` — indépendant de l'octroi par utilisateur,
|
||||
* qui se fait via la modale "Gérer les droits" (UsersSection.jsx). Voir
|
||||
* project_workspaces_transformation.md (mémoire projet). */
|
||||
|
||||
function IconPlus() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>;
|
||||
}
|
||||
function IconEdit() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>;
|
||||
}
|
||||
function IconTrash() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>;
|
||||
}
|
||||
|
||||
function Toggle({ checked, onClick, title }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: 38, height: 21, borderRadius: 20, border: 'none', flexShrink: 0,
|
||||
background: checked ? 'var(--primary)' : 'var(--border)',
|
||||
position: 'relative', cursor: 'pointer', 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>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyForm = { slug: '', nom: '', libelleMenu: '', description: '', actifGlobal: true, ordre: 0 };
|
||||
|
||||
function WorkspaceFormModal({ open, onClose, onSaved, workspace }) {
|
||||
const isEdit = !!workspace;
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [err, setErr] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setErr(null);
|
||||
setForm(workspace
|
||||
? {
|
||||
slug: workspace.slug,
|
||||
nom: workspace.nom,
|
||||
libelleMenu: workspace.libelle_menu,
|
||||
description: workspace.description || '',
|
||||
actifGlobal: !!workspace.actif_global,
|
||||
ordre: workspace.ordre ?? 0,
|
||||
}
|
||||
: emptyForm);
|
||||
}, [open, workspace]);
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
if (!form.nom.trim() || !form.libelleMenu.trim() || (!isEdit && !form.slug.trim())) {
|
||||
setErr('Merci de renseigner les champs obligatoires (*)');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
nom: form.nom.trim(),
|
||||
libelleMenu: form.libelleMenu.trim(),
|
||||
description: form.description.trim(),
|
||||
actifGlobal: !!form.actifGlobal,
|
||||
ordre: Number(form.ordre) || 0,
|
||||
};
|
||||
if (isEdit) {
|
||||
await api.put(`/admin/workspaces/${workspace.id}`, payload);
|
||||
} else {
|
||||
await api.post('/admin/workspaces', { ...payload, slug: form.slug.trim() });
|
||||
}
|
||||
onSaved();
|
||||
} catch (e2) {
|
||||
setErr(e2.message || 'Erreur lors de l\'enregistrement');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? "Modifier l'espace de travail" : 'Nouvel espace de travail'}
|
||||
onClose={onClose}
|
||||
width={480}
|
||||
footer={
|
||||
<>
|
||||
<button className="ghost" type="button" onClick={onClose}>Annuler</button>
|
||||
<button className="btn btn-primary" type="submit" form="workspace-form" disabled={saving}>
|
||||
{saving ? 'Enregistrement…' : 'Enregistrer'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="workspace-form" onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
|
||||
Identifiant (slug) *
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="ex: private-equity"
|
||||
value={form.slug}
|
||||
disabled={isEdit}
|
||||
onChange={e => setForm(f => ({ ...f, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
lettres minuscules, chiffres, tirets — non modifiable après création
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
|
||||
Nom *
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="ex: Private Equity"
|
||||
value={form.nom}
|
||||
onChange={e => setForm(f => ({ ...f, nom: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
|
||||
Libellé menu *
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="ex: Private Equity"
|
||||
value={form.libelleMenu}
|
||||
onChange={e => setForm(f => ({ ...f, libelleMenu: e.target.value }))}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
Affiché dans la barre latérale lorsque cet espace est actif
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 600, marginBottom: 4 }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={form.description}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 20, alignItems: 'center', marginBottom: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Toggle
|
||||
checked={!!form.actifGlobal}
|
||||
onClick={() => setForm(f => ({ ...f, actifGlobal: !f.actifGlobal }))}
|
||||
title={form.actifGlobal ? 'Désactiver la disponibilité pour attribution' : 'Activer la disponibilité pour attribution'}
|
||||
/>
|
||||
<span style={{ fontSize: 'var(--fs-sm)' }}>Disponible pour attribution</span>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'var(--fs-sm)' }}>
|
||||
Ordre
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: 70 }}
|
||||
value={form.ordre}
|
||||
onChange={e => setForm(f => ({ ...f, ordre: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{err && <div className="error" style={{ marginTop: 8 }}>{err}</div>}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspacesSection() {
|
||||
const [workspaces, setWorkspaces] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [banner, setBanner] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [toDelete, setToDelete] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setWorkspaces(await api.get('/admin/workspaces')); }
|
||||
catch (e) { setBanner({ ok: false, msg: e.message || 'Erreur de chargement' }); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!toDelete) return;
|
||||
try {
|
||||
await api.del(`/admin/workspaces/${toDelete.id}`);
|
||||
setBanner({ ok: true, msg: `Espace de travail « ${toDelete.nom} » supprimé.` });
|
||||
setToDelete(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
setBanner({ ok: false, msg: e.message || 'Erreur lors de la suppression' });
|
||||
setToDelete(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-muted" style={{ padding: 24 }}>Chargement…</p>;
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
{banner && (
|
||||
<ResultBanner
|
||||
result={banner}
|
||||
onDismiss={() => setBanner(null)}
|
||||
style={{ margin: '12px 20px 0', borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 20px 14px', gap: 16, borderBottom: '1px solid var(--border)', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 3px' }}>Espaces de travail</h3>
|
||||
<p style={{ margin: 0, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
|
||||
Catalogue des espaces de travail proposés par l'application (Crowdlending, futurs modules…).
|
||||
L'attribution à un utilisateur se fait depuis sa fiche, onglet « Gérer les droits ».
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => { setEditing(null); setShowForm(true); }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<IconPlus /> Nouvel espace de travail
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{workspaces.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '40px 20px' }}>
|
||||
Aucun espace de travail défini.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ margin: 0 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ paddingLeft: 20 }}>Nom</th>
|
||||
<th>Libellé menu</th>
|
||||
<th>Slug</th>
|
||||
<th style={{ textAlign: 'center' }}>Disponible</th>
|
||||
<th style={{ textAlign: 'right' }}>Ordre</th>
|
||||
<th style={{ textAlign: 'right' }}>Utilisateurs</th>
|
||||
<th style={{ width: 90, paddingRight: 20 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workspaces.map(w => (
|
||||
<tr key={w.id}>
|
||||
<td style={{ paddingLeft: 20, fontWeight: 600 }}>{w.nom}</td>
|
||||
<td>{w.libelle_menu}</td>
|
||||
<td style={{ color: 'var(--text-muted)', fontFamily: 'monospace', fontSize: 12 }}>{w.slug}</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{w.actif_global
|
||||
? <span className="badge badge-success">Oui</span>
|
||||
: <span className="badge">Non</span>}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>{w.ordre}</td>
|
||||
<td style={{ textAlign: 'right' }}>{w.nb_utilisateurs}</td>
|
||||
<td style={{ paddingRight: 20 }}>
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
title="Modifier"
|
||||
onClick={() => { setEditing(w); setShowForm(true); }}
|
||||
>
|
||||
<IconEdit />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
title={w.slug === 'crowdlending' ? 'Le workspace Crowdlending ne peut pas être supprimé' : 'Supprimer'}
|
||||
disabled={w.slug === 'crowdlending'}
|
||||
onClick={() => setToDelete(w)}
|
||||
>
|
||||
<IconTrash />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WorkspaceFormModal
|
||||
open={showForm}
|
||||
workspace={editing}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={() => {
|
||||
setShowForm(false);
|
||||
setBanner({ ok: true, msg: editing ? 'Espace de travail mis à jour.' : 'Espace de travail créé.' });
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!toDelete}
|
||||
title="Supprimer l'espace de travail"
|
||||
message={toDelete ? `Supprimer définitivement « ${toDelete.nom} » ? Les droits accordés aux utilisateurs sur cet espace seront retirés.` : ''}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setToDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
import { useWorkspace } from '../../context/WorkspaceContext.jsx';
|
||||
import ResultBanner from '../../components/ResultBanner.jsx';
|
||||
|
||||
/* Settings > Mes espaces de travail — activation personnelle des espaces
|
||||
* de travail accordés par l'administrateur (active_by_user). L'octroi lui-
|
||||
* même (granted_by_admin) n'est modifiable que par un administrateur, voir
|
||||
* ManageRightsModal.jsx (Administration > Utilisateurs). Voir
|
||||
* project_workspaces_transformation.md (mémoire projet). */
|
||||
|
||||
function Toggle({ checked, onClick, disabled, title }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
width: 40, height: 22, 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 ? 20 : 2,
|
||||
width: 18, height: 18, borderRadius: '50%', background: '#fff',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,.3)', transition: 'left .15s',
|
||||
}} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspacesUserSection() {
|
||||
const { reload: reloadWorkspaceCtx } = useWorkspace() || {};
|
||||
const [workspaces, setWorkspaces] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [banner, setBanner] = useState(null);
|
||||
const [pending, setPending] = useState(null); // id en cours de bascule
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setWorkspaces(await api.get('/workspaces')); }
|
||||
catch (e) { setBanner({ ok: false, msg: e.message || 'Erreur de chargement' }); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const activeCount = workspaces.filter(w => w.active_by_user).length;
|
||||
|
||||
async function toggle(w) {
|
||||
const nextActive = !w.active_by_user;
|
||||
if (!nextActive && activeCount <= 1) {
|
||||
setBanner({ ok: false, msg: 'Au moins un espace de travail doit rester actif.' });
|
||||
return;
|
||||
}
|
||||
setPending(w.id);
|
||||
try {
|
||||
await api.patch(`/workspaces/${w.id}/active`, { active: nextActive });
|
||||
setWorkspaces(ws => ws.map(x => x.id === w.id ? { ...x, active_by_user: nextActive } : x));
|
||||
reloadWorkspaceCtx?.();
|
||||
} catch (e) {
|
||||
setBanner({ ok: false, msg: e.message || 'Erreur lors de la mise à jour' });
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-muted" style={{ padding: 24 }}>Chargement…</p>;
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
{banner && (
|
||||
<ResultBanner
|
||||
result={banner}
|
||||
onDismiss={() => setBanner(null)}
|
||||
style={{ margin: '12px 20px 0', borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ padding: '20px 20px 14px', borderBottom: '1px solid var(--border)' }}>
|
||||
<h3 style={{ margin: '0 0 3px' }}>Mes espaces de travail</h3>
|
||||
<p style={{ margin: 0, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
|
||||
Activez ou masquez les espaces de travail qui vous ont été accordés par un administrateur.
|
||||
Un espace masqué n'apparaît plus dans le sélecteur du menu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{workspaces.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '40px 20px' }}>
|
||||
Aucun espace de travail ne vous a été accordé.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ padding: '8px 20px 20px' }}>
|
||||
{workspaces.map(w => (
|
||||
<div
|
||||
key={w.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 4px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 'var(--fs-sm)' }}>{w.nom}</div>
|
||||
{w.description && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{w.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<Toggle
|
||||
checked={!!w.active_by_user}
|
||||
disabled={pending === w.id}
|
||||
onClick={() => toggle(w)}
|
||||
title={w.active_by_user ? 'Masquer cet espace de travail' : 'Activer cet espace de travail'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user