- 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)
231 lines
7.6 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|