From 6b6cad7cd6483f725b22591cbe8b1d5fe074842a Mon Sep 17 00:00:00 2001 From: Olivier Date: Tue, 8 Sep 2026 12:33:04 +0200 Subject: [PATCH] Mise en place des workspaces --- backend/src/db/index.js | 75 ++++ backend/src/routes/admin.js | 48 ++- backend/src/routes/adminWorkspaces.js | 93 +++++ backend/src/routes/auth.js | 6 +- backend/src/routes/invitations.js | 6 +- backend/src/routes/preferences.js | 1 + backend/src/routes/workspaces.js | 46 +++ backend/src/server.js | 4 + frontend/src/App.jsx | 59 +++- frontend/src/components/Layout.jsx | 59 ++-- frontend/src/components/UserMenu.jsx | 80 ++++- frontend/src/context/WorkspaceContext.jsx | 87 +++++ frontend/src/main.jsx | 5 +- frontend/src/pages/Admin.jsx | 4 + frontend/src/pages/Settings.jsx | 9 + .../src/pages/admin/ManageRightsModal.jsx | 274 +++++++++++++++ frontend/src/pages/admin/UsersSection.jsx | 26 +- .../src/pages/admin/WorkspacesSection.jsx | 324 ++++++++++++++++++ .../pages/settings/WorkspacesUserSection.jsx | 123 +++++++ 19 files changed, 1273 insertions(+), 56 deletions(-) create mode 100644 backend/src/routes/adminWorkspaces.js create mode 100644 backend/src/routes/workspaces.js create mode 100644 frontend/src/context/WorkspaceContext.jsx create mode 100644 frontend/src/pages/admin/ManageRightsModal.jsx create mode 100644 frontend/src/pages/admin/WorkspacesSection.jsx create mode 100644 frontend/src/pages/settings/WorkspacesUserSection.jsx diff --git a/backend/src/db/index.js b/backend/src/db/index.js index e20c749..61d49af 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -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; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 9bdfbd7..5f90782 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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']), diff --git a/backend/src/routes/adminWorkspaces.js b/backend/src/routes/adminWorkspaces.js new file mode 100644 index 0000000..93f2eca --- /dev/null +++ b/backend/src/routes/adminWorkspaces.js @@ -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; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 214a894..86fbb15 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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'); diff --git a/backend/src/routes/invitations.js b/backend/src/routes/invitations.js index c86badf..3514da5 100644 --- a/backend/src/routes/invitations.js +++ b/backend/src/routes/invitations.js @@ -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 } }); diff --git a/backend/src/routes/preferences.js b/backend/src/routes/preferences.js index d7f9b3f..868d627 100644 --- a/backend/src/routes/preferences.js +++ b/backend/src/routes/preferences.js @@ -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…) ]); diff --git a/backend/src/routes/workspaces.js b/backend/src/routes/workspaces.js new file mode 100644 index 0000000..c3b49c5 --- /dev/null +++ b/backend/src/routes/workspaces.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js index e9aea79..0b24ef0 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 48ea7f2..09cf1a3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ; + 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 ( +
+

{active.nom}

+

+ {active.description || "Cet espace de travail n'est pas encore disponible."} +

+
+ ); + } + return ; +} + export default function App() { const [allowRegistration, setAllowRegistration] = useState(true); @@ -96,29 +129,29 @@ export default function App() { } /> } /> }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 8b8fa89..bd0f33d 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -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() {
- Crowdlending + {activeWorkspace?.libelle_menu || 'Crowdlending'} {/* Réduire (visible seulement en mode étendu) */} + +
+ + )} + {/* Section Vue du profil */}
Vue du profil
+ ))} +
, + document.body + )} + {/* ── Sub-panel Vue du profil (portail) ───────────────────── */} - {open && subOpen && createPortal( + {open && activeSubPanel === 'profile' && createPortal(
{ + 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 ( + 1, + }}> + {children} + + ); +} + +export const useWorkspace = () => useContext(WorkspaceCtx); diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 7199d5f..4376f78 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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( - + + + diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 6b28409..8b355d3 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -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 ; } @@ -19,6 +20,7 @@ function IconSettings() { return ; } function IconDownload() { return ; } function IconSparkle() { return ; } +function IconLayers() { return ; } const NAV = [ { @@ -31,6 +33,7 @@ const NAV = [ { id: 'icons', label: "Bibliothèque d'icônes", icon: }, { id: 'smtp', label: 'Configuration SMTP', icon: }, { id: 'ia', label: 'Assistant IA', icon: , href: '/admin/ia' }, + { id: 'workspaces', label: 'Espaces de travail', icon: }, ], }, { @@ -85,6 +88,7 @@ export default function Admin() { {section === 'icons' && } {section === 'smtp' && } {section === 'export' && } + {section === 'workspaces' && }
); diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 5082838..43e913a 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -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 ; } @@ -22,6 +23,7 @@ function IconMyFiscal() { return ; } function IconBroom() { return ; } function IconUpload() { return ; } +function IconSpaces() { return ; } const NAV = [ { @@ -30,6 +32,12 @@ const NAV = [ { id: 'apparence', label: 'Apparence', icon: }, ], }, + { + group: 'Mon espace de travail', + items: [ + { id: 'workspaces', label: 'Mes espaces de travail', icon: }, + ], + }, { group: 'Mon paramétrage', items: [ @@ -95,6 +103,7 @@ export default function Settings() { {section === 'secteurs-inv' && } {section === 'nettoyage' && } {section === 'imports' && } + {section === 'workspaces' && } ); diff --git a/frontend/src/pages/admin/ManageRightsModal.jsx b/frontend/src/pages/admin/ManageRightsModal.jsx new file mode 100644 index 0000000..87a15d9 --- /dev/null +++ b/frontend/src/pages/admin/ManageRightsModal.jsx @@ -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 ( + + ); +} + +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 ( + + + {stepIndex > 0 && ( + + )} + {stepIndex < STEPS.length - 1 && ( + + )} + + + } + > + {/* ── Indicateur d'étape (même pattern que InvestissementFormModal) ── */} +
+
+ Étape {stepIndex + 1} : {STEPS[stepIndex].label} +
+
+ {STEPS.map((s, i) => ( +
+ ))} +
+
+ + {step === 'general' && ( +
+ {isSelf && ( +

+ Vous ne pouvez pas modifier votre propre rôle ni votre propre statut depuis cet écran. +

+ )} +
+ + +
+
+ + +
+
+ )} + + {step === 'workspaces' && ( +
+

+ Espaces de travail accordés à cet utilisateur. L'utilisateur pourra ensuite les activer ou + les masquer lui-même depuis ses paramètres. +

+ {loadingCat ? ( +

Chargement…

+ ) : catalog.length === 0 ? ( +

Aucun espace de travail défini dans le catalogue.

+ ) : ( +
+ {catalog.map(w => { + const granted = form.workspaceIds.includes(w.id); + return ( +
+
+
{w.nom}
+ {w.description && ( +
{w.description}
+ )} +
+ {!w.actif_global && ( + + indisponible + + )} + toggleWorkspace(w.id)} + title={granted ? 'Retirer cet espace de travail' : 'Accorder cet espace de travail'} + /> +
+ ); + })} +
+ )} + {form.workspaceIds.length === 0 && ( +

+ Aucun espace de travail accordé : l'utilisateur n'aura accès à aucune fonctionnalité. +

+ )} +
+ )} + + {step === 'ia' && ( +
+
+
+
Accès à l'Assistant IA
+
+ {aiAvailable + ? "Permet à l'utilisateur d'utiliser le chat de l'Assistant IA." + : aiUnavailableReason} +
+
+ 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")} + /> +
+
+ )} + + {err &&
{err}
} + + ); +} diff --git a/frontend/src/pages/admin/UsersSection.jsx b/frontend/src/pages/admin/UsersSection.jsx index d94b46b..3891f25 100644 --- a/frontend/src/pages/admin/UsersSection.jsx +++ b/frontend/src/pages/admin/UsersSection.jsx @@ -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 = () => ; const IcoSearch = () => ; const IcoSparkle = () => ; +const IcoRights = () => ; // 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, }}> - } label={openMenu.user.role === 'admin' ? 'Rétrograder en utilisateur' : 'Promouvoir en admin'} onClick={() => { setOpenMenu(null); toggleRole(openMenu.user); }} /> - } label={openMenu.user.ai_enabled ? "Désactiver l'accès IA" : "Activer l'accès IA"} onClick={() => { setOpenMenu(null); toggleAiAccess(openMenu.user); }} /> + } label="Gérer les droits" onClick={() => { setOpenMenu(null); setRightsTarget(openMenu.user); }} /> {!openMenu.user.email_verified && <> } label="Renvoyer l'invitation" onClick={() => { setOpenMenu(null); resendInvitation(openMenu.user); }} /> } label="Vérifier l'email" onClick={() => { setOpenMenu(null); verifyEmail(openMenu.user); }} /> @@ -812,6 +808,20 @@ export default function UsersSection({ currentUserId }) { onCreated={() => load()} /> + setRightsTarget(null)} + onSaved={() => { + setRightsTarget(null); + setBanner({ ok: true, msg: 'Droits mis à jour.' }); + load(); + }} + /> + 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 ; +} +function IconEdit() { + return ; +} +function IconTrash() { + return ; +} + +function Toggle({ checked, onClick, title }) { + return ( + + ); +} + +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 ( + + + + + } + > +
+
+ + setForm(f => ({ ...f, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))} + /> + + lettres minuscules, chiffres, tirets — non modifiable après création + +
+
+ + setForm(f => ({ ...f, nom: e.target.value }))} + /> +
+
+ + setForm(f => ({ ...f, libelleMenu: e.target.value }))} + /> + + Affiché dans la barre latérale lorsque cet espace est actif + +
+
+ +