From 6553aee8a6e073b00f83658ec233511dc884bb09 Mon Sep 17 00:00:00 2001 From: Manus Date: Wed, 6 May 2026 07:55:46 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20am=C3=A9liorations=20SharePoint=20-=20t?= =?UTF-8?q?est=20connexion=20Azure=20AD,=20alerte=20expiration=20secret,?= =?UTF-8?q?=20log=20statut=20upload=20dans=20BapHistory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/BapHistory.tsx | 28 ++++++++++ client/src/pages/ImportSettings.tsx | 73 ++++++++++++++++++++++++- drizzle/0025_unknown_spencer_smythe.sql | 4 ++ drizzle/schema.ts | 4 ++ server/routers.ts | 68 +++++++++++++++++++++++ 5 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 drizzle/0025_unknown_spencer_smythe.sql diff --git a/client/src/pages/BapHistory.tsx b/client/src/pages/BapHistory.tsx index 484aef3..b11515c 100644 --- a/client/src/pages/BapHistory.tsx +++ b/client/src/pages/BapHistory.tsx @@ -18,6 +18,9 @@ import { User, Download, RefreshCw, + Cloud, + CloudOff, + ExternalLink, } from "lucide-react"; // Helper : télécharge un PDF annoté BAP depuis son URL de stockage @@ -269,6 +272,31 @@ export default function BapHistory() { + ) : (entry as any).sharepointUploadStatus === 'success' ? ( +
+
+ + SharePoint +
+ {(entry as any).sharepointUploadPath && ( + + + + )} +
+ ) : (entry as any).sharepointUploadStatus === 'error' ? ( +
+
+ + SP Erreur +
+
) : entry.exportPath ? (
diff --git a/client/src/pages/ImportSettings.tsx b/client/src/pages/ImportSettings.tsx index 1c1ee62..2298bac 100644 --- a/client/src/pages/ImportSettings.tsx +++ b/client/src/pages/ImportSettings.tsx @@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; -import { Loader2, Save, Upload, FolderOpen, Mail, Play, Square, Download, Inbox, CheckCircle2, Monitor, FolderOutput } from "lucide-react"; +import { Loader2, Save, Upload, FolderOpen, Mail, Play, Square, Download, Inbox, CheckCircle2, Monitor, FolderOutput, Wifi, WifiOff, AlertTriangle, Calendar } from "lucide-react"; import DashboardLayout from "@/components/DashboardLayout"; export default function ImportSettings() { @@ -50,6 +50,8 @@ export default function ImportSettings() { const [azureTenantId, setAzureTenantId] = useState(""); const [azureClientId, setAzureClientId] = useState(""); const [azureClientSecret, setAzureClientSecret] = useState(""); + const [azureSecretExpiresAt, setAzureSecretExpiresAt] = useState(""); + const testAzureConnectionMutation = trpc.importSettings.testAzureConnection.useMutation(); // Initialize form with settings from database useEffect(() => { @@ -83,6 +85,8 @@ export default function ImportSettings() { setAzureTenantId((settings as any).azureTenantId || ""); setAzureClientId((settings as any).azureClientId || ""); setAzureClientSecret((settings as any).azureClientSecret || ""); + const expDate = (settings as any).azureSecretExpiresAt; + setAzureSecretExpiresAt(expDate ? new Date(expDate).toISOString().split('T')[0] : ""); } }, [settings]); @@ -112,6 +116,7 @@ export default function ImportSettings() { azureTenantId: azureTenantId || null, azureClientId: azureClientId || null, azureClientSecret: azureClientSecret || null, + azureSecretExpiresAt: azureSecretExpiresAt ? new Date(azureSecretExpiresAt) : null, }); toast.success("Paramètres enregistrés avec succès"); @@ -680,6 +685,72 @@ export default function ImportSettings() { />

Stocké de façon sécurisée. Permissions requises : Files.ReadWrite.All et Sites.ReadWrite.All

+ + {/* Date d'expiration du secret */} +
+ + setAzureSecretExpiresAt(e.target.value)} + className="h-10" + /> +

Renseignez la date d'expiration pour recevoir une alerte avant renouvellement

+
+ + {/* Alerte expiration */} + {azureSecretExpiresAt && (() => { + const expDate = new Date(azureSecretExpiresAt); + const daysLeft = Math.ceil((expDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + if (daysLeft <= 0) return ( +
+ + Secret expiré — renouvelez-le immédiatement dans le portail Azure AD +
+ ); + if (daysLeft <= 30) return ( +
+ + Expiration dans {daysLeft} jours — pensez à renouveler le secret Azure AD +
+ ); + return ( +
+ + Secret valide encore {daysLeft} jours (expire le {expDate.toLocaleDateString('fr-FR')}) +
+ ); + })()} + + {/* Bouton test connexion */} +
+ +

Vérifie le token OAuth2 et l'accès au site SharePoint

+
)} diff --git a/drizzle/0025_unknown_spencer_smythe.sql b/drizzle/0025_unknown_spencer_smythe.sql new file mode 100644 index 0000000..4861799 --- /dev/null +++ b/drizzle/0025_unknown_spencer_smythe.sql @@ -0,0 +1,4 @@ +ALTER TABLE `bapHistory` ADD `sharepointUploadStatus` enum('success','error','skipped');--> statement-breakpoint +ALTER TABLE `bapHistory` ADD `sharepointUploadPath` text;--> statement-breakpoint +ALTER TABLE `bapHistory` ADD `sharepointUploadError` text;--> statement-breakpoint +ALTER TABLE `importSettings` ADD `azureSecretExpiresAt` timestamp; \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index b0d0eb3..83e1f0a 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -191,6 +191,7 @@ export const importSettings = mysqlTable("importSettings", { azureTenantId: varchar("azureTenantId", { length: 100 }), // Azure AD Tenant ID azureClientId: varchar("azureClientId", { length: 100 }), // Azure AD Application (client) ID azureClientSecret: text("azureClientSecret"), // Azure AD Client Secret (encrypted) + azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date // AI Engine settings aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).default("mistral").notNull(), // AI provider for invoice extraction @@ -386,6 +387,9 @@ export const bapHistory = mysqlTable("bapHistory", { exportPath: text("exportPath"), // null for browser mode pdfUrl: text("pdfUrl"), // S3 URL for browser mode signatureName: varchar("signatureName", { length: 255 }), // Signer name if applied + sharepointUploadStatus: mysqlEnum("sharepointUploadStatus", ["success", "error", "skipped"]), // SharePoint upload result + sharepointUploadPath: text("sharepointUploadPath"), // Path/URL of uploaded file in SharePoint + sharepointUploadError: text("sharepointUploadError"), // Error message if upload failed validatedAt: timestamp("validatedAt").defaultNow().notNull(), }); export type BapHistory = typeof bapHistory.$inferSelect; diff --git a/server/routers.ts b/server/routers.ts index be0233c..3ec35bd 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -476,6 +476,9 @@ export const appRouter = router({ let pdfUrl: string | null = null; let exportPath: string | null = null; let signatureName: string | null = null; + let sharepointUploadStatus: 'success' | 'error' | 'skipped' | null = null; + let sharepointUploadPath: string | null = null; + let sharepointUploadError: string | null = null; try { if (!invoice.fileKey && !invoice.fileUrl) throw new Error('Fichier PDF source introuvable'); @@ -567,8 +570,12 @@ export const appRouter = router({ ); if (spResult.success) { exportPath = spResult.webUrl || exportFolder; + sharepointUploadStatus = 'success'; + sharepointUploadPath = spResult.webUrl || exportFolder; } else { console.warn('[BAP] Erreur upload SharePoint:', spResult.error); + sharepointUploadStatus = 'error'; + sharepointUploadError = spResult.error || 'Erreur inconnue'; // Fallback : stocker localement const bapKey = generateStorageKey(ctx.user.id, bapFilename); const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf'); @@ -615,6 +622,9 @@ export const appRouter = router({ exportPath: exportPath || null, pdfUrl: pdfUrl || null, signatureName: signatureName || null, + sharepointUploadStatus: (sharepointUploadStatus as any) || null, + sharepointUploadPath: sharepointUploadPath || null, + sharepointUploadError: sharepointUploadError || null, validatedAt, }); @@ -662,6 +672,9 @@ export const appRouter = router({ let pdfUrl: string | null = null; let exportPath: string | null = null; let signatureName: string | null = null; + let sharepointUploadStatus: 'success' | 'error' | 'skipped' | null = null; + let sharepointUploadPath: string | null = null; + let sharepointUploadError: string | null = null; try { // ─ Lecture du PDF source ─ let sourcePdfBytes: Buffer; @@ -782,6 +795,9 @@ export const appRouter = router({ exportPath: exportPath || null, pdfUrl: pdfUrl || null, signatureName: signatureName || null, + sharepointUploadStatus: (sharepointUploadStatus as any) || null, + sharepointUploadPath: sharepointUploadPath || null, + sharepointUploadError: sharepointUploadError || null, validatedAt, }); results.push({ id: invoice.id, success: true, pdfUrl, exportPath }); @@ -1489,6 +1505,7 @@ export const appRouter = router({ azureTenantId: z.string().nullable().optional(), azureClientId: z.string().nullable().optional(), azureClientSecret: z.string().nullable().optional(), + azureSecretExpiresAt: z.date().nullable().optional(), })) .mutation(async ({ input, ctx }) => { const settings = await upsertImportSettings({ @@ -1498,6 +1515,57 @@ export const appRouter = router({ return settings; }), + + testAzureConnection: protectedProcedure + .mutation(async ({ ctx }) => { + const settings = await getImportSettingsByUser(ctx.user.id); + const tenantId = (settings as any)?.azureTenantId; + const clientId = (settings as any)?.azureClientId; + const clientSecret = (settings as any)?.azureClientSecret; + const sharepointUrl = settings?.exportFolder; + + if (!tenantId || !clientId || !clientSecret) { + return { success: false, error: 'Credentials Azure AD manquants (Tenant ID, Client ID ou Secret)' }; + } + + try { + const { uploadToSharePoint } = await import('./sharepoint'); + // Test : obtenir un token OAuth2 uniquement (sans upload) + const tokenRes = await fetch( + `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + scope: 'https://graph.microsoft.com/.default', + }), + } + ); + const tokenData = await tokenRes.json() as any; + if (tokenData.error) { + return { success: false, error: `Erreur Azure AD : ${tokenData.error_description || tokenData.error}` }; + } + // Test accès SharePoint si URL configurée + if (sharepointUrl) { + const urlObj = new URL(sharepointUrl); + const hostname = urlObj.hostname; // ex: itinova.sharepoint.com + const siteTestRes = await fetch( + `https://graph.microsoft.com/v1.0/sites/${hostname}`, + { headers: { Authorization: `Bearer ${tokenData.access_token}` } } + ); + if (!siteTestRes.ok) { + const siteErr = await siteTestRes.json() as any; + return { success: false, error: `Token OK mais accès SharePoint refusé : ${siteErr?.error?.message || siteTestRes.status}` }; + } + } + return { success: true, message: 'Connexion Azure AD réussie' + (sharepointUrl ? ' et accès SharePoint vérifié' : '') }; + } catch (err: any) { + return { success: false, error: err.message || 'Erreur inconnue' }; + } + }), }), // ============= DEPARTMENT LIST ROUTES =============