espace-paie-odentas/app/api/documents/generaux/route.ts
odentas 78c43f0bfa feat: Implémentation complète du système de permissions
- Créer lib/permissions.ts avec toutes les fonctions de vérification
- Protéger routes API: facturation, cotisations, virements (bloquer AGENT)
- Protéger routes API: contrats (bloquer COMPTA)
- Protéger routes API: gestion utilisateurs (bloquer AGENT/COMPTA)
- Empêcher ADMIN de modifier/révoquer/créer SUPER_ADMIN
- Ajouter documentation complète dans PERMISSIONS_MATRIX.md

Système à 5 niveaux:
- STAFF (équipe Odentas)
- SUPER_ADMIN (admin principal, 1 par org, protégé)
- ADMIN (admins secondaires)
- AGENT (opérationnel: contrats/paies/salariés)
- COMPTA (financier lecture seule: cotisations/virements/factures)
2025-11-14 20:25:30 +01:00

231 lines
7.6 KiB
TypeScript

export const dynamic = 'force-dynamic';
export const revalidate = 0;
export const runtime = 'nodejs';
import { NextRequest, NextResponse } from "next/server";
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
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",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
},
});
const BUCKET_NAME = (process.env.AWS_S3_BUCKET || "odentas-docs").trim();
// Fonction pour slugifier les noms (enlever accents, espaces, etc.)
function slugify(text: string): string {
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Enlever les accents
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-') // Remplacer les caractères spéciaux par des tirets
.replace(/^-+|-+$/g, ''); // Enlever les tirets en début/fin
}
// Types de documents généraux
const DOC_TYPES = {
"contrat-odentas": "Contrat Odentas",
"licence-spectacles": "Licence de spectacles",
"rib": "RIB",
"kbis-jo": "KBIS / Journal Officiel",
"delegation-signature": "Délégation de signature"
};
interface GeneralDocument {
type: string;
label: string;
available: boolean;
key?: string;
name?: string;
size?: number;
lastModified?: string;
downloadUrl?: string;
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const requestedOrgId = searchParams.get('org_id');
// 🔒 SÉCURITÉ : Authentification obligatoire
const sb = createRouteHandlerClient({ cookies });
const { data: { user }, error: userError } = await sb.auth.getUser();
if (userError || !user) {
console.error('❌ [SÉCURITÉ] Utilisateur non authentifié');
return NextResponse.json({
error: "Non authentifié",
details: "Authentification requise"
}, { status: 401 });
}
// 🔒 SÉCURITÉ : Vérifier si l'utilisateur est staff
const { data: staffUser } = await sb
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
const isStaff = !!staffUser?.is_staff;
console.log('📄 Documents Généraux API - Is staff?', isStaff);
let orgId: string;
if (isStaff) {
// 🔒 STAFF : Peut accéder à n'importe quelle organisation
if (!requestedOrgId) {
console.error('❌ [SÉCURITÉ] Staff sans organisation fournie');
return NextResponse.json({
error: "Organization ID requis pour le staff"
}, { status: 400 });
}
// Vérifier que l'organisation existe
const { data: org, error: orgError } = await sb
.from('organizations')
.select('id')
.eq('id', requestedOrgId)
.maybeSingle();
if (orgError || !org) {
console.error('❌ [SÉCURITÉ] Staff a demandé une organisation inexistante:', requestedOrgId);
return NextResponse.json({
error: "Organisation non trouvée"
}, { status: 404 });
}
orgId = requestedOrgId;
console.log('✅ [SÉCURITÉ] Staff accède à l\'organisation:', orgId);
} else {
// 🔒 CLIENT : Forcé à utiliser son organisation uniquement
const { data: member, error: memberError } = await sb
.from("organization_members")
.select("org_id")
.eq("user_id", user.id)
.eq("revoked", false)
.maybeSingle();
if (!member?.org_id) {
console.error('❌ [SÉCURITÉ] Client sans organisation trouvée');
return NextResponse.json({
error: "Aucune organisation trouvée pour cet utilisateur"
}, { status: 403 });
}
// 🔒 SÉCURITÉ CRITIQUE : Vérifier que l'org_id fourni correspond à celle de l'utilisateur
if (requestedOrgId && requestedOrgId !== member.org_id) {
console.error('❌ [SÉCURITÉ CRITIQUE] Client a tenté d\'accéder à une autre organisation !');
console.error(' - org_id fourni:', requestedOrgId);
console.error(' - org_id utilisateur:', member.org_id);
console.error(' - User ID:', user.id);
console.error(' - User email:', user.email);
return NextResponse.json({
error: "Accès non autorisé à cette organisation",
details: "Vous ne pouvez accéder qu'aux documents de votre organisation"
}, { status: 403 });
}
// Forcer l'utilisation de l'organisation de l'utilisateur
orgId = member.org_id;
console.log('✅ [SÉCURITÉ] Client forcé à son organisation:', orgId);
}
// Récupérer la clé de l'organisation (structure_api)
const { data: org, error: orgError } = await sb
.from('organizations')
.select('structure_api')
.eq('id', orgId)
.single();
if (orgError || !org?.structure_api) {
return NextResponse.json({
error: "Organisation non trouvée"
}, { status: 404 });
}
const orgKey = slugify(org.structure_api);
const prefix = `documents/${orgKey}/docs-generaux/`;
// Lister les fichiers dans S3
const listCommand = new ListObjectsV2Command({
Bucket: BUCKET_NAME,
Prefix: prefix,
});
const listResponse = await s3Client.send(listCommand);
// Initialiser tous les types de documents
const documents: GeneralDocument[] = Object.entries(DOC_TYPES).map(([type, label]) => ({
type,
label,
available: false
}));
// Si des fichiers existent, les associer aux types correspondants
if (listResponse.Contents && listResponse.Contents.length > 0) {
for (const item of listResponse.Contents) {
if (!item.Key) continue;
const fileName = item.Key.split('/').pop() || '';
// Ignorer les fichiers système comme .DS_Store
if (fileName.startsWith('.') || fileName === '.DS_Store') {
continue;
}
// Déterminer le type de document basé sur le préfixe du nom de fichier
for (const [type, label] of Object.entries(DOC_TYPES)) {
if (fileName.toLowerCase().startsWith(type)) {
// 🔒 SÉCURITÉ : Générer une URL pré-signée valide 15 minutes (au lieu de 1 heure)
const getCommand = new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: item.Key,
});
const signedUrl = await getSignedUrl(s3Client, getCommand, {
expiresIn: 900 // 15 minutes (900s)
});
// Trouver le document correspondant et le mettre à jour
const docIndex = documents.findIndex(d => d.type === type);
if (docIndex !== -1) {
documents[docIndex] = {
type,
label,
available: true,
key: item.Key,
name: fileName,
size: item.Size || 0,
lastModified: item.LastModified?.toISOString() || new Date().toISOString(),
downloadUrl: signedUrl
};
}
break;
}
}
}
}
return NextResponse.json({
documents,
orgKey
});
} catch (error) {
console.error("Erreur récupération documents généraux S3:", error);
return NextResponse.json(
{ error: "Erreur lors de la récupération des documents" },
{ status: 500 }
);
}
}