diff --git a/PERMISSIONS_MATRIX.md b/PERMISSIONS_MATRIX.md new file mode 100644 index 0000000..885603d --- /dev/null +++ b/PERMISSIONS_MATRIX.md @@ -0,0 +1,354 @@ +# Matrice des Permissions - Espace Paie Odentas + +## Vue d'ensemble + +Le système de permissions de l'Espace Paie Odentas repose sur **5 rôles hiérarchiques** : + +1. **STAFF** - Équipe Odentas (super-administrateurs globaux) +2. **SUPER_ADMIN** - Administrateur principal de l'organisation (1 seul par organisation) +3. **ADMIN** - Administrateurs secondaires (plusieurs possibles) +4. **AGENT** - Opérateurs RH (gestion contrats/paies/salariés uniquement) +5. **COMPTA** - Comptables (accès financier en lecture seule) + +--- + +## Tableau Récapitulatif des Permissions + +| Ressource/Action | STAFF | SUPER_ADMIN | ADMIN | AGENT | COMPTA | +|------------------|-------|-------------|-------|-------|--------| +| **CONTRATS** | +| Voir liste contrats | ✅ | ✅ | ✅ | ✅ | ❌ | +| Créer contrat | ✅ | ✅ | ✅ | ✅ | ❌ | +| Modifier contrat | ✅ | ✅ | ✅ | ✅ | ❌ | +| Supprimer contrat | ✅ | ✅ | ✅ | ✅ | ❌ | +| **PAIES** | +| Voir fiches de paie | ✅ | ✅ | ✅ | ✅ | ❌ | +| Générer fiches de paie | ✅ | ✅ | ✅ | ✅ | ❌ | +| Modifier fiches de paie | ✅ | ✅ | ✅ | ✅ | ❌ | +| **SALARIÉS** | +| Voir liste salariés | ✅ | ✅ | ✅ | ✅ | ❌ | +| Créer salarié | ✅ | ✅ | ✅ | ✅ | ❌ | +| Modifier salarié | ✅ | ✅ | ✅ | ✅ | ❌ | +| Supprimer salarié | ✅ | ✅ | ✅ | ✅ | ❌ | +| **COTISATIONS** | +| Voir cotisations mensuelles | ✅ | ✅ | ✅ | ❌ | ✅ (lecture seule) | +| Modifier cotisations | ✅ | ✅ | ✅ | ❌ | ❌ | +| **VIREMENTS SALAIRES** | +| Voir virements | ✅ | ✅ | ✅ | ❌ | ✅ (lecture seule) | +| Créer/modifier virements | ✅ | ✅ | ✅ | ❌ | ❌ | +| **FACTURATION** | +| Voir factures | ✅ | ✅ | ✅ | ❌ | ✅ (lecture seule) | +| Créer/modifier factures | ✅ | ✅ | ✅ | ❌ | ❌ | +| **DOCUMENTS** | +| Upload documents | ✅ | ✅ | ✅ | ✅ | ❌ | +| Voir tous documents | ✅ | ✅ | ✅ | ✅ | ❌ | +| Voir documents comptables* | ✅ | ✅ | ✅ | ❌ | ✅ | +| **GESTION UTILISATEURS** | +| Voir liste utilisateurs | ✅ | ✅ | ✅ | ❌ | ❌ | +| Créer SUPER_ADMIN | ✅ | ❌ | ❌ | ❌ | ❌ | +| Créer ADMIN/AGENT/COMPTA | ✅ | ✅ | ✅ | ❌ | ❌ | +| Modifier rôle SUPER_ADMIN | ✅ | ❌ | ❌ | ❌ | ❌ | +| Modifier rôle autres | ✅ | ✅ | ✅ | ❌ | ❌ | +| Révoquer SUPER_ADMIN | ✅ | ❌ | ❌ | ❌ | ❌ | +| Révoquer autres | ✅ | ✅ | ✅ | ❌ | ❌ | +| **INFORMATIONS STRUCTURE** | +| Voir informations | ✅ | ✅ | ✅ | ✅ | ✅ | +| Modifier SIRET/SEPA | ✅ | ✅ | ❌ | ❌ | ❌ | +| Modifier autres infos | ✅ | ✅ | ✅ | ❌ | ❌ | + +**Documents comptables inclus** : factures, devis, relevés cotisations, virements salaires, justificatifs bancaires, bilans, documents comptables généraux + +--- + +## Détails par Rôle + +### 🔴 STAFF (Équipe Odentas) +**Accès complet** à toutes les organisations et toutes les fonctionnalités. Ce rôle est réservé à l'équipe technique Odentas. + +**Permissions spécifiques :** +- Peut créer et supprimer des SUPER_ADMIN +- Accès multi-organisations +- Aucune restriction + +--- + +### 🟣 SUPER_ADMIN (Administrateur Principal) + +**Responsable principal de l'organisation**. Il existe **un seul SUPER_ADMIN par organisation**. + +**Permissions :** +- ✅ Accès complet à toutes les données de l'organisation +- ✅ Peut gérer tous les utilisateurs (sauf autres SUPER_ADMIN) +- ✅ Peut créer ADMIN, AGENT, COMPTA +- ✅ Peut modifier les informations structurelles critiques (SIRET, SEPA) +- ✅ Accès en lecture/écriture à facturation, cotisations, virements +- ✅ Gestion complète des contrats, paies, salariés + +**Restrictions :** +- ❌ Ne peut pas être modifié ou révoqué par un ADMIN +- ❌ Seul STAFF peut créer un nouveau SUPER_ADMIN + +**Protection :** +- Les ADMIN ne peuvent pas modifier ou supprimer le SUPER_ADMIN +- C'est le "propriétaire" de l'organisation + +--- + +### 🔵 ADMIN (Administrateur Secondaire) + +**Administrateurs opérationnels**. Plusieurs ADMIN peuvent coexister. + +**Permissions :** +- ✅ Accès complet aux données opérationnelles +- ✅ Peut gérer les utilisateurs (sauf SUPER_ADMIN) +- ✅ Peut créer AGENT et COMPTA +- ✅ Accès en lecture/écriture à facturation, cotisations, virements +- ✅ Gestion complète des contrats, paies, salariés +- ✅ Upload et accès à tous les documents + +**Restrictions :** +- ❌ Ne peut pas modifier les informations structurelles critiques (SIRET/SEPA) +- ❌ Ne peut pas modifier/révoquer un SUPER_ADMIN +- ❌ Ne peut pas créer de SUPER_ADMIN + +--- + +### 🟢 AGENT (Opérateur RH) + +**Rôle opérationnel** pour la gestion quotidienne des contrats, paies et salariés. + +**Permissions :** +- ✅ Gestion complète des contrats (CDDU, RG) +- ✅ Gestion des fiches de paie +- ✅ Gestion des salariés +- ✅ Upload de documents généraux +- ✅ Consultation des informations de structure + +**Restrictions :** +- ❌ Pas d'accès aux données financières (facturation, cotisations, virements) +- ❌ Pas d'accès à la gestion des utilisateurs +- ❌ Pas d'accès aux documents comptables +- ❌ Pas de modification des informations de structure + +**Cas d'usage :** Assistant RH, chargé de production, gestionnaire de paie junior + +--- + +### 🟡 COMPTA (Comptable) + +**Accès financier en lecture seule**. Le COMPTA ne peut ni créer, ni modifier, ni supprimer de données. + +**Permissions :** +- ✅ Consultation des cotisations mensuelles +- ✅ Consultation des virements salaires +- ✅ Consultation des factures +- ✅ Consultation des documents comptables uniquement : + - Factures + - Devis + - Relevés de cotisations + - Virements salaires + - Justificatifs bancaires + - Bilans + - Documents comptables généraux +- ✅ Consultation des informations de structure + +**Restrictions :** +- ❌ Pas d'accès aux contrats +- ❌ Pas d'accès aux fiches de paie +- ❌ Pas d'accès aux salariés +- ❌ Pas d'upload de documents +- ❌ Pas d'accès à la gestion des utilisateurs +- ❌ Pas de modification possible (lecture seule uniquement) + +**Cas d'usage :** Expert-comptable externe, comptable ne nécessitant que les données financières + +--- + +## Hiérarchie et Protection + +### Règle de Hiérarchie + +``` +STAFF + └── SUPER_ADMIN (protégé) + └── ADMIN + ├── AGENT + └── COMPTA +``` + +**Règles de modification :** +1. **STAFF** peut tout modifier +2. **SUPER_ADMIN** peut modifier ADMIN, AGENT, COMPTA (pas d'autres SUPER_ADMIN) +3. **ADMIN** peut modifier AGENT, COMPTA (pas SUPER_ADMIN ni autres ADMIN) +4. **AGENT** et **COMPTA** ne peuvent rien modifier + +### Protection du SUPER_ADMIN + +Le SUPER_ADMIN est **protégé** : +- Un ADMIN ne peut pas changer son rôle +- Un ADMIN ne peut pas le révoquer +- Un ADMIN ne peut pas créer un nouveau SUPER_ADMIN + +Seul le **STAFF** d'Odentas peut modifier un SUPER_ADMIN. + +--- + +## Implémentation Technique + +### Fichier central : `lib/permissions.ts` + +Toutes les vérifications de permissions sont centralisées dans `lib/permissions.ts`. + +**Fonctions principales :** + +```typescript +// Récupérer les permissions d'un utilisateur +getUserPermissions(supabaseClient): Promise + +// Ressources opérationnelles +canAccessContrats(role: UserRole): boolean +canAccessPaies(role: UserRole): boolean +canAccessSalaries(role: UserRole): boolean + +// Ressources financières +canAccessFacturation(role: UserRole): boolean +canAccessCotisations(role: UserRole): boolean +canAccessVirements(role: UserRole): boolean + +// Gestion utilisateurs +canManageUsers(role: UserRole): boolean +canModifyUser(permissions: UserPermissions, targetRole: UserRole): boolean +canCreateUserWithRole(permissions: UserPermissions, newRole: UserRole): boolean + +// Documents +canUploadDocuments(role: UserRole): boolean +canAccessDocumentType(role: UserRole, docType: string): boolean + +// Informations structure +canModifyStructureInfo(role: UserRole): boolean +``` + +### Routes protégées + +**Backend (API Routes) :** +- `/api/contrats/*` - Bloque COMPTA +- `/api/facturation/*` - Bloque AGENT +- `/api/cotisations/*` - Bloque AGENT +- `/api/virements-salaires/*` - Bloque AGENT +- `/api/access/*` - Bloque AGENT et COMPTA +- `/api/access/[memberId]/role/*` - Empêche ADMIN de modifier SUPER_ADMIN +- `/api/access/[memberId]/revoke/*` - Empêche ADMIN de révoquer SUPER_ADMIN +- `/api/access/nouveau/*` - Empêche ADMIN de créer SUPER_ADMIN + +**Frontend (Composants React) :** +- Masquage/désactivation des boutons selon le rôle +- Navigation conditionnelle + +--- + +## Exemples de Cas d'Usage + +### Scénario 1 : Cabinet de Production +**Organisation :** Compagnie de théâtre de 20 salariés + +**Utilisateurs :** +- **1 SUPER_ADMIN** : Directeur administratif (gestion complète + informations critiques) +- **2 ADMIN** : Responsable RH + Responsable production (gestion complète opérationnelle) +- **3 AGENT** : Assistants RH (gestion contrats/paies uniquement) +- **1 COMPTA** : Expert-comptable externe (consultation financière uniquement) + +--- + +### Scénario 2 : Petite Structure Associative +**Organisation :** Association culturelle de 5 salariés + +**Utilisateurs :** +- **1 SUPER_ADMIN** : Président de l'association (tout) +- **1 AGENT** : Secrétaire générale (contrats/paies) +- **1 COMPTA** : Trésorier (consultation financière) + +--- + +### Scénario 3 : Grande Production +**Organisation :** Festival avec 100+ salariés + +**Utilisateurs :** +- **1 SUPER_ADMIN** : Directeur général +- **5 ADMIN** : Équipe de direction +- **10 AGENT** : Équipe RH/production +- **2 COMPTA** : Cabinet comptable externe + +--- + +## Migration et Changement de Rôle + +### Promotion d'utilisateur + +**AGENT → ADMIN** +- Action possible par SUPER_ADMIN ou ADMIN +- Débloque l'accès financier et la gestion utilisateurs + +**ADMIN → SUPER_ADMIN** +- ⚠️ **Action STAFF uniquement** +- L'ancien SUPER_ADMIN doit être rétrogradé en ADMIN d'abord +- Il ne peut y avoir qu'un seul SUPER_ADMIN + +### Rétrogradation d'utilisateur + +**ADMIN → AGENT** +- Action possible par SUPER_ADMIN +- Bloque l'accès financier et gestion utilisateurs + +**SUPER_ADMIN → ADMIN** +- ⚠️ **Action STAFF uniquement** +- Permet de désigner un nouveau SUPER_ADMIN + +--- + +## FAQ + +### Pourquoi un seul SUPER_ADMIN ? + +Pour éviter les conflits de gestion et garantir une responsabilité claire. Le SUPER_ADMIN est le "propriétaire" de l'organisation. + +### Un ADMIN peut-il modifier un autre ADMIN ? + +Non. Les ADMIN peuvent uniquement gérer les AGENT et COMPTA. Seul le SUPER_ADMIN peut modifier un ADMIN. + +### Pourquoi le COMPTA ne peut pas uploader de documents ? + +Le rôle COMPTA est en **lecture seule** pour éviter toute modification des données opérationnelles ou financières. Il peut uniquement consulter. + +### Comment ajouter un nouveau SUPER_ADMIN ? + +Seul le **STAFF Odentas** peut créer un SUPER_ADMIN. Contactez support@odentas.com. + +### Le COMPTA peut-il voir les fiches de paie ? + +Non. Le COMPTA ne voit que les **données financières agrégées** : +- Cotisations mensuelles (montants globaux) +- Virements salaires (montants globaux) +- Factures +- Documents comptables + +Les fiches de paie détaillées sont réservées aux rôles opérationnels (SUPER_ADMIN, ADMIN, AGENT). + +### Un AGENT peut-il créer des factures ? + +Non. Les factures sont considérées comme des **ressources financières**. Seuls SUPER_ADMIN et ADMIN ont accès. + +--- + +## Support Technique + +Pour toute question sur les permissions ou demande de modification de rôle, contactez : + +**Support Odentas** +- Email : support@odentas.com +- Interface : [Espace Paie Odentas](https://espace-paie.odentas.com) + +--- + +**Version :** 1.0 +**Dernière mise à jour :** Janvier 2025 +**Auteur :** Équipe Technique Odentas diff --git a/app/api/access/[memberId]/revoke/route.ts b/app/api/access/[memberId]/revoke/route.ts index d91005e..76e1f6e 100644 --- a/app/api/access/[memberId]/revoke/route.ts +++ b/app/api/access/[memberId]/revoke/route.ts @@ -5,6 +5,7 @@ import { cookies, headers } from "next/headers"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { createClient } from "@supabase/supabase-js"; import { sendAccessRevokedEmail } from "@/lib/emailMigrationHelpers"; +import { getUserPermissions, canModifyUser } from "@/lib/permissions"; type OrgRecord = { id: string }; type MemberRecord = { role: string; revoked: boolean }; @@ -136,6 +137,29 @@ export async function POST(_req: Request, { params }: { params: { memberId: stri { auth: { autoRefreshToken: false, persistSession: false }} ); + // Vérifier le rôle de l'utilisateur ciblé pour empêcher ADMIN de révoquer SUPER_ADMIN + let targetRole: string | null = null; + try { + const { data: targetMember } = await admin + .from('organization_members') + .select('role') + .eq('org_id', org_id) + .eq('user_id', params.memberId) + .is('revoked', false) + .maybeSingle(); + targetRole = (targetMember as any)?.role || null; + } catch {} + + const permissions = await getUserPermissions(sb); + if (!permissions) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + if (targetRole && !canModifyUser(permissions, targetRole as any)) { + console.log("❌ Permission refusée: impossible de révoquer un utilisateur", targetRole); + return new NextResponse("Forbidden - Vous ne pouvez pas révoquer cet utilisateur", { status: 403 }); + } + const { error } = await admin.from("organization_members") .update({ revoked: true, revoked_at: new Date().toISOString() }) .eq("org_id", org_id).eq("user_id", params.memberId); diff --git a/app/api/access/[memberId]/role/route.ts b/app/api/access/[memberId]/role/route.ts index 3853854..e6a4b91 100644 --- a/app/api/access/[memberId]/role/route.ts +++ b/app/api/access/[memberId]/role/route.ts @@ -4,6 +4,7 @@ import { cookies, headers } from "next/headers"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { createClient } from "@supabase/supabase-js"; import { sendAccessUpdatedEmail } from "@/lib/emailMigrationHelpers"; +import { getUserPermissions, canModifyUser } from "@/lib/permissions"; type MemberRecord = { role: string; revoked: boolean }; @@ -134,6 +135,17 @@ export async function PATCH(req: Request, { params }: { params: { memberId: stri oldRole = (existingMember as any)?.role || null; } catch {} + // Vérification des permissions hiérarchiques: empêcher ADMIN de modifier SUPER_ADMIN + const permissions = await getUserPermissions(sb); + if (!permissions) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + if (oldRole && !canModifyUser(permissions, oldRole as any)) { + console.log("❌ Permission refusée: impossible de modifier un utilisateur", oldRole, "avec le rôle", permissions.role); + return new NextResponse("Forbidden - Vous ne pouvez pas modifier cet utilisateur", { status: 403 }); + } + const { error } = await admin.from("organization_members") .update({ role }) .eq("org_id", org_id) diff --git a/app/api/access/nouveau/route.ts b/app/api/access/nouveau/route.ts index 1e4acf5..0a6dc38 100644 --- a/app/api/access/nouveau/route.ts +++ b/app/api/access/nouveau/route.ts @@ -3,6 +3,7 @@ import { createClient } from "@supabase/supabase-js"; import { cookies } from "next/headers"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { sendInvitationWithActivationEmail } from "@/lib/emailMigrationHelpers"; +import { getUserPermissions, canCreateUserWithRole } from "@/lib/permissions"; // Types de rôles autorisés (alignés avec public.role) const ROLES = ["SUPER_ADMIN","ADMIN","AGENT","COMPTA"] as const; @@ -93,6 +94,18 @@ export async function POST(req: Request) { console.log("🏛️ [API] Organisation trouvée:", org.name); + // Vérification des permissions: empêcher AGENT/COMPTA de créer des utilisateurs + // et empêcher ADMIN de créer un SUPER_ADMIN + const permissions = await getUserPermissions(sb); + if (!permissions) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + if (!canCreateUserWithRole(permissions, requestedRole as any)) { + console.log("❌ [API] Permission refusée: impossible de créer un utilisateur", requestedRole); + return NextResponse.json({ error: "forbidden", message: "Vous n'avez pas la permission de créer cet utilisateur" }, { status: 403 }); + } + // Client admin Supabase (service role) const admin = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, diff --git a/app/api/access/route.ts b/app/api/access/route.ts index d0d8501..391e2da 100644 --- a/app/api/access/route.ts +++ b/app/api/access/route.ts @@ -5,6 +5,7 @@ import { cookies, headers } from "next/headers"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { createClient } from "@supabase/supabase-js"; import { sendAccessUpdatedEmail, sendInvitationWithActivationEmail } from "@/lib/emailMigrationHelpers"; +import { getUserPermissions, canManageUsers } from "@/lib/permissions"; // import type { Database } from "@/types/supabase"; // Temporairement commenté type OrgRow = { id: string; name?: string; structure_api?: string | null }; @@ -227,6 +228,18 @@ export async function GET() { console.log("❌ Accès refusé"); return new NextResponse("Forbidden", { status: g.status }); } + + // Vérification des permissions: AGENT et COMPTA ne peuvent pas gérer les utilisateurs + const permissions = await getUserPermissions(sb); + if (!permissions) { + console.log("❌ Permission refusée: utilisateur non authentifié"); + return new NextResponse("Unauthorized", { status: 401 }); + } + + if (!canManageUsers(permissions)) { + console.log("❌ Permission refusée: gestion des utilisateurs non autorisée pour", permissions.role); + return new NextResponse("Forbidden - Accès réservé aux administrateurs", { status: 403 }); + } } console.log("✅ Accès autorisé, récupération des membres..."); diff --git a/app/api/contrats/route.ts b/app/api/contrats/route.ts index 909032e..3315a60 100644 --- a/app/api/contrats/route.ts +++ b/app/api/contrats/route.ts @@ -6,6 +6,7 @@ import { resolveActiveOrg } from "@/lib/resolveActiveOrg"; import { createClient } from '@supabase/supabase-js'; import { detectDemoModeFromHeaders } from "@/lib/demo-detector"; import { DEMO_CONTRACTS, DEMO_ORGANIZATION } from "@/lib/demo-data"; +import { getUserPermissions, canAccessContrats, checkPermissionOrRespond } from "@/lib/permissions"; // Force dynamic rendering and disable revalidation cache for this proxy export const dynamic = 'force-dynamic'; @@ -76,6 +77,14 @@ export async function GET(req: Request) { } try { + const sb = createRouteHandlerClient({ cookies }); + + // 🔒 VÉRIFICATION DES PERMISSIONS (bloquer COMPTA) + const permissions = await getUserPermissions(sb); + const permissionCheck = canAccessContrats(permissions); + const errorResponse = checkPermissionOrRespond(permissionCheck); + if (errorResponse) return errorResponse; + const url = new URL(req.url); const regime = url.searchParams.get("regime"); // Si CDDU, RG, ou absence de regime (= tous), lire depuis Supabase (table cddu_contracts) diff --git a/app/api/cotisations/mensuelles/route.ts b/app/api/cotisations/mensuelles/route.ts index 5f1e900..2bf2980 100644 --- a/app/api/cotisations/mensuelles/route.ts +++ b/app/api/cotisations/mensuelles/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { cookies } from "next/headers"; +import { getUserPermissions, canAccessCotisations, checkPermissionOrRespond } from "@/lib/permissions"; export const dynamic = 'force-dynamic'; export const revalidate = 0; @@ -118,13 +119,20 @@ async function getClientInfoFromSession(session: any, supabase: any) { export async function GET(req: Request) { try { + const supabase = createRouteHandlerClient({ cookies }); + + // 🔒 VÉRIFICATION DES PERMISSIONS + const permissions = await getUserPermissions(supabase); + const permissionCheck = canAccessCotisations(permissions); + const errorResponse = checkPermissionOrRespond(permissionCheck); + if (errorResponse) return errorResponse; + const { searchParams } = new URL(req.url); const year = parseInt(searchParams.get('year') || `${new Date().getFullYear()}`, 10); const period = (searchParams.get('period') || 'toute_annee').toString(); const fromParam = searchParams.get('from') || undefined; // YYYY-MM-DD const toParam = searchParams.get('to') || undefined; // YYYY-MM-DD - const supabase = createRouteHandlerClient({ cookies }); const { data: { session } } = await supabase.auth.getSession(); if (!session) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); diff --git a/app/api/documents/generaux/route.ts b/app/api/documents/generaux/route.ts index c13af1e..e7c28af 100644 --- a/app/api/documents/generaux/route.ts +++ b/app/api/documents/generaux/route.ts @@ -8,6 +8,7 @@ import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { GetObjectCommand } from "@aws-sdk/client-s3"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { cookies } from "next/headers"; +import { getUserPermissions, canUploadDocuments, canAccessDocumentType } from "@/lib/permissions"; const s3Client = new S3Client({ region: process.env.AWS_REGION || "eu-west-3", diff --git a/app/api/facturation/route.ts b/app/api/facturation/route.ts index 89500a3..102eae2 100644 --- a/app/api/facturation/route.ts +++ b/app/api/facturation/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { cookies } from "next/headers"; +import { getUserPermissions, canAccessFacturation, checkPermissionOrRespond } from "@/lib/permissions"; export const dynamic = 'force-dynamic'; export const revalidate = 0; @@ -87,13 +88,20 @@ async function getClientInfoFromSession(session: any, supabase: any) { export async function GET(req: Request) { try { + const supabase = createRouteHandlerClient({ cookies }); + + // 🔒 VÉRIFICATION DES PERMISSIONS + const permissions = await getUserPermissions(supabase); + const permissionCheck = canAccessFacturation(permissions); + const errorResponse = checkPermissionOrRespond(permissionCheck); + if (errorResponse) return errorResponse; + const url = new URL(req.url); const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10)); const limit = Math.max(1, Math.min(50, parseInt(url.searchParams.get('limit') || '25', 10))); const from = (page - 1) * limit; const to = from + limit - 1; - const supabase = createRouteHandlerClient({ cookies }); const { data: { session } } = await supabase.auth.getSession(); if (!session) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); diff --git a/app/api/virements-salaires/route.ts b/app/api/virements-salaires/route.ts index defe09c..f0c7817 100644 --- a/app/api/virements-salaires/route.ts +++ b/app/api/virements-salaires/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { cookies } from "next/headers"; import { detectDemoModeFromHeaders } from "@/lib/demo-detector"; +import { getUserPermissions, canAccessVirements, checkPermissionOrRespond } from "@/lib/permissions"; export async function GET(req: NextRequest) { // 🎭 En mode démo, retourner des données vides (les données fictives sont gérées côté client) @@ -22,6 +23,14 @@ export async function GET(req: NextRequest) { } try { + const sb = createRouteHandlerClient({ cookies }); + + // 🔒 VÉRIFICATION DES PERMISSIONS + const permissions = await getUserPermissions(sb); + const permissionCheck = canAccessVirements(permissions); + const errorResponse = checkPermissionOrRespond(permissionCheck); + if (errorResponse) return errorResponse; + const { searchParams } = new URL(req.url); const year = searchParams.get("year"); const period = searchParams.get("period"); @@ -39,7 +48,6 @@ export async function GET(req: NextRequest) { }); // ✅ AJOUT : Récupération de l'identification du client côté serveur - const sb = createRouteHandlerClient({ cookies }); const { data: { user }, error: userError } = await sb.auth.getUser(); if (!user) { diff --git a/lib/permissions.ts b/lib/permissions.ts new file mode 100644 index 0000000..536c7ee --- /dev/null +++ b/lib/permissions.ts @@ -0,0 +1,473 @@ +/** + * Système de permissions et habilitations pour l'Espace Paie Odentas + * + * Hiérarchie des rôles clients : + * - SUPER_ADMIN : Compte principal protégé (1 seul par org) + * - ADMIN : Accès total aux données + * - AGENT : Opérationnel (contrats, paies, salariés) + * - COMPTA : Lecture seule financière (cotisations, virements, facturation) + * + * STAFF : Accès total toutes organisations (réservé Odentas) + */ + +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +// ============================================================================ +// Types +// ============================================================================ + +export type UserRole = "SUPER_ADMIN" | "ADMIN" | "AGENT" | "COMPTA"; + +export interface UserPermissions { + userId: string; + role: UserRole; + orgId: string; + isStaff: boolean; +} + +export interface PermissionCheck { + allowed: boolean; + reason?: string; +} + +// ============================================================================ +// Type de documents pour COMPTA +// ============================================================================ + +const COMPTA_ALLOWED_DOCUMENT_TYPES = [ + "facture", + "devis", + "releve_cotisations", + "virement_salaires", + "justificatif_bancaire", + "bilan", + "comptable" +]; + +// ============================================================================ +// Récupération des permissions utilisateur +// ============================================================================ + +/** + * Récupère les permissions d'un utilisateur authentifié + * @returns UserPermissions ou null si non authentifié + */ +export async function getUserPermissions( + supabase?: SupabaseClient +): Promise { + const sb = supabase || createRouteHandlerClient({ cookies }); + + // 1. Vérifier l'authentification + const { + data: { user }, + error: authError, + } = await sb.auth.getUser(); + + if (authError || !user) { + return null; + } + + // 2. Vérifier si c'est un staff + const { data: staffData } = await (sb as any) + .from("staff_users") + .select("is_staff") + .eq("user_id", user.id) + .maybeSingle(); + + const isStaff = !!staffData?.is_staff; + + // Si staff, retourner avec rôle SUPER_ADMIN et pas d'orgId + if (isStaff) { + return { + userId: user.id, + role: "SUPER_ADMIN", + orgId: "", // Staff n'a pas d'org spécifique + isStaff: true, + }; + } + + // 3. Récupérer le rôle dans une organisation + const { data: memberData } = await (sb as any) + .from("organization_members") + .select("role, org_id, revoked") + .eq("user_id", user.id) + .eq("revoked", false) + .maybeSingle(); + + if (!memberData) { + return null; + } + + return { + userId: user.id, + role: memberData.role as UserRole, + orgId: memberData.org_id, + isStaff: false, + }; +} + +// ============================================================================ +// Vérifications de permissions par domaine +// ============================================================================ + +/** + * Vérifie si l'utilisateur peut accéder à la facturation + * Autorisé : SUPER_ADMIN, ADMIN, COMPTA + * Bloqué : AGENT + */ +export function canAccessFacturation(permissions: UserPermissions | null): PermissionCheck { + if (!permissions) { + return { allowed: false, reason: "Non authentifié" }; + } + + if (permissions.isStaff) { + return { allowed: true }; + } + + if (permissions.role === "AGENT") { + return { allowed: false, reason: "Les agents n'ont pas accès à la facturation" }; + } + + return { allowed: true }; +} + +/** + * Vérifie si l'utilisateur peut MODIFIER la facturation + * Autorisé : SUPER_ADMIN, ADMIN + * Bloqué : AGENT, COMPTA + */ +export function canModifyFacturation(permissions: UserPermissions | null): PermissionCheck { + if (!permissions) { + return { allowed: false, reason: "Non authentifié" }; + } + + if (permissions.isStaff) { + return { allowed: true }; + } + + if (permissions.role === "COMPTA") { + return { allowed: false, reason: "Le rôle COMPTA est en lecture seule" }; + } + + if (permissions.role === "AGENT") { + return { allowed: false, reason: "Les agents n'ont pas accès à la facturation" }; + } + + return { allowed: true }; // SUPER_ADMIN ou ADMIN +} + +/** + * Vérifie si l'utilisateur peut accéder aux cotisations + * Autorisé : SUPER_ADMIN, ADMIN, COMPTA + * Bloqué : AGENT + */ +export function canAccessCotisations(permissions: UserPermissions | null): PermissionCheck { + if (!permissions) { + return { allowed: false, reason: "Non authentifié" }; + } + + if (permissions.isStaff) { + return { allowed: true }; + } + + if (permissions.role === "AGENT") { + return { allowed: false, reason: "Les agents n'ont pas accès aux cotisations" }; + } + + return { allowed: true }; +} + +/** + * Vérifie si l'utilisateur peut accéder aux virements salaires + * Autorisé : SUPER_ADMIN, ADMIN, COMPTA + * Bloqué : AGENT + */ +export function canAccessVirements(permissions: UserPermissions | null): PermissionCheck { + if (!permissions) { + return { allowed: false, reason: "Non authentifié" }; + } + + if (permissions.isStaff) { + return { allowed: true }; + } + + if (permissions.role === "AGENT") { + return { allowed: false, reason: "Les agents n'ont pas accès aux virements salaires" }; + } + + return { allowed: true }; +} + +/** + * Vérifie si l'utilisateur peut accéder aux contrats + * Autorisé : SUPER_ADMIN, ADMIN, AGENT + * Bloqué : COMPTA + */ +export function canAccessContrats(permissions: UserPermissions | null): PermissionCheck { + if (!permissions) { + return { allowed: false, reason: "Non authentifié" }; + } + + if (permissions.isStaff) { + return { allowed: true }; + } + + if (permissions.role === "COMPTA") { + return { allowed: false, reason: "Le rôle COMPTA n'a pas accès aux contrats" }; + } + + return { allowed: true }; +} + +/** + * Vérifie si l'utilisateur peut MODIFIER les contrats + * Autorisé : SUPER_ADMIN, ADMIN, AGENT + * Bloqué : COMPTA + */ +export function canModifyContrats(permissions: UserPermissions | null): PermissionCheck { + // Même logique que canAccessContrats + return canAccessContrats(permissions); +} + +/** + * Vérifie si l'utilisateur peut accéder aux paies + * Autorisé : SUPER_ADMIN, ADMIN, AGENT + * Bloqué : COMPTA + */ +export function canAccessPaies(permissions: UserPermissions | null): PermissionCheck { + if (!permissions) { + return { allowed: false, reason: "Non authentifié" }; + } + + if (permissions.isStaff) { + return { allowed: true }; + } + + if (permissions.role === "COMPTA") { + return { allowed: false, reason: "Le rôle COMPTA n'a pas accès aux paies" }; + } + + return { allowed: true }; +} + +/** + * Vérifie si l'utilisateur peut accéder aux salariés + * Autorisé : SUPER_ADMIN, ADMIN, AGENT + * Bloqué : COMPTA + */ +export function canAccessSalaries(permissions: UserPermissions | null): PermissionCheck { + if (!permissions) { + return { allowed: false, reason: "Non authentifié" }; + } + + if (permissions.isStaff) { + return { allowed: true }; + } + + if (permissions.role === "COMPTA") { + return { allowed: false, reason: "Le rôle COMPTA n'a pas accès aux salariés" }; + } + + return { allowed: true }; +} + +/** + * Vérifie si l'utilisateur peut gérer les utilisateurs (accès) + * Autorisé : SUPER_ADMIN, ADMIN + * Bloqué : AGENT, COMPTA + */ +export function canManageUsers(permissions: UserPermissions | null): PermissionCheck { + if (!permissions) { + return { allowed: false, reason: "Non authentifié" }; + } + + if (permissions.isStaff) { + return { allowed: true }; + } + + if (!["SUPER_ADMIN", "ADMIN"].includes(permissions.role)) { + return { allowed: false, reason: "Seuls les administrateurs peuvent gérer les utilisateurs" }; + } + + return { allowed: true }; +} + +/** + * Vérifie si l'utilisateur peut modifier un autre utilisateur + * @param permissions Permissions de l'utilisateur actuel + * @param targetRole Rôle de l'utilisateur cible + */ +export function canModifyUser( + permissions: UserPermissions | null, + targetRole: UserRole +): PermissionCheck { + if (!permissions) { + return { allowed: false, reason: "Non authentifié" }; + } + + if (permissions.isStaff) { + return { allowed: true }; + } + + // ADMIN ne peut pas modifier SUPER_ADMIN + if (permissions.role === "ADMIN" && targetRole === "SUPER_ADMIN") { + return { allowed: false, reason: "Seul le staff peut modifier un SUPER_ADMIN" }; + } + + // AGENT et COMPTA ne peuvent pas gérer d'utilisateurs + if (!["SUPER_ADMIN", "ADMIN"].includes(permissions.role)) { + return { allowed: false, reason: "Seuls les administrateurs peuvent modifier les utilisateurs" }; + } + + return { allowed: true }; +} + +/** + * Vérifie si l'utilisateur peut créer un utilisateur avec un rôle donné + * @param permissions Permissions de l'utilisateur actuel + * @param newRole Rôle à assigner au nouvel utilisateur + */ +export function canCreateUserWithRole( + permissions: UserPermissions | null, + newRole: UserRole +): PermissionCheck { + if (!permissions) { + return { allowed: false, reason: "Non authentifié" }; + } + + if (permissions.isStaff) { + return { allowed: true }; + } + + // Seul le staff peut créer un SUPER_ADMIN + if (newRole === "SUPER_ADMIN") { + return { allowed: false, reason: "Seul le staff Odentas peut créer un SUPER_ADMIN" }; + } + + // ADMIN peut créer ADMIN, AGENT, COMPTA + if (permissions.role === "ADMIN") { + return { allowed: true }; + } + + // SUPER_ADMIN peut tout créer (sauf SUPER_ADMIN, déjà vérifié) + if (permissions.role === "SUPER_ADMIN") { + return { allowed: true }; + } + + return { allowed: false, reason: "Seuls les administrateurs peuvent créer des utilisateurs" }; +} + +/** + * Vérifie si l'utilisateur peut modifier les informations de structure (SIRET, coordonnées, SEPA) + * Autorisé : SUPER_ADMIN uniquement (+ STAFF) + * Bloqué : ADMIN, AGENT, COMPTA + */ +export function canModifyStructureInfo(permissions: UserPermissions | null): PermissionCheck { + if (!permissions) { + return { allowed: false, reason: "Non authentifié" }; + } + + if (permissions.isStaff) { + return { allowed: true }; + } + + if (permissions.role !== "SUPER_ADMIN") { + return { + allowed: false, + reason: "Seul le SUPER_ADMIN peut modifier les informations de structure", + }; + } + + return { allowed: true }; +} + +/** + * Vérifie si l'utilisateur peut uploader des documents + * Autorisé : SUPER_ADMIN, ADMIN, AGENT + * Bloqué : COMPTA + */ +export function canUploadDocuments(permissions: UserPermissions | null): PermissionCheck { + if (!permissions) { + return { allowed: false, reason: "Non authentifié" }; + } + + if (permissions.isStaff) { + return { allowed: true }; + } + + if (permissions.role === "COMPTA") { + return { allowed: false, reason: "Le rôle COMPTA ne peut pas uploader de documents" }; + } + + return { allowed: true }; +} + +/** + * Vérifie si l'utilisateur peut accéder à un type de document + * @param permissions Permissions de l'utilisateur + * @param documentType Type du document (ex: "facture", "contrat", etc.) + */ +export function canAccessDocumentType( + permissions: UserPermissions | null, + documentType: string +): PermissionCheck { + if (!permissions) { + return { allowed: false, reason: "Non authentifié" }; + } + + if (permissions.isStaff) { + return { allowed: true }; + } + + // COMPTA ne peut accéder qu'aux documents comptables + if (permissions.role === "COMPTA") { + if (!COMPTA_ALLOWED_DOCUMENT_TYPES.includes(documentType.toLowerCase())) { + return { + allowed: false, + reason: "Le rôle COMPTA n'a accès qu'aux documents comptables et financiers", + }; + } + } + + return { allowed: true }; +} + +// ============================================================================ +// Helpers pour les réponses HTTP +// ============================================================================ + +/** + * Retourne une réponse 401 Unauthorized + */ +export function unauthorizedResponse() { + return new Response( + JSON.stringify({ error: "Non authentifié", message: "Authentification requise" }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); +} + +/** + * Retourne une réponse 403 Forbidden avec un message personnalisé + */ +export function forbiddenResponse(reason?: string) { + return new Response( + JSON.stringify({ + error: "Accès refusé", + message: reason || "Vous n'avez pas les permissions nécessaires pour cette action", + }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); +} + +/** + * Vérifie les permissions et retourne une réponse d'erreur si nécessaire + * @returns null si autorisé, Response d'erreur sinon + */ +export function checkPermissionOrRespond(check: PermissionCheck): Response | null { + if (!check.allowed) { + return forbiddenResponse(check.reason); + } + return null; +}