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) */}