feat: améliorations SharePoint - test connexion Azure AD, alerte expiration secret, log statut upload dans BapHistory

This commit is contained in:
Manus
2026-05-06 07:55:46 -04:00
parent bbee4cd7c9
commit 6553aee8a6
5 changed files with 176 additions and 1 deletions

View File

@@ -18,6 +18,9 @@ import {
User, User,
Download, Download,
RefreshCw, RefreshCw,
Cloud,
CloudOff,
ExternalLink,
} from "lucide-react"; } from "lucide-react";
// Helper : télécharge un PDF annoté BAP depuis son URL de stockage // Helper : télécharge un PDF annoté BAP depuis son URL de stockage
@@ -269,6 +272,31 @@ export default function BapHistory() {
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
) : (entry as any).sharepointUploadStatus === 'success' ? (
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 text-blue-600">
<Cloud className="h-3.5 w-3.5" />
<span className="text-xs">SharePoint</span>
</div>
{(entry as any).sharepointUploadPath && (
<a
href={(entry as any).sharepointUploadPath}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700"
title="Ouvrir dans SharePoint"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
) : (entry as any).sharepointUploadStatus === 'error' ? (
<div className="flex items-center gap-1" title={(entry as any).sharepointUploadError || 'Erreur SharePoint'}>
<div className="flex items-center gap-1 text-red-600">
<CloudOff className="h-3.5 w-3.5" />
<span className="text-xs">SP Erreur</span>
</div>
</div>
) : entry.exportPath ? ( ) : entry.exportPath ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="flex items-center gap-1 text-orange-600"> <div className="flex items-center gap-1 text-orange-600">

View File

@@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { toast } from "sonner"; 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"; import DashboardLayout from "@/components/DashboardLayout";
export default function ImportSettings() { export default function ImportSettings() {
@@ -50,6 +50,8 @@ export default function ImportSettings() {
const [azureTenantId, setAzureTenantId] = useState(""); const [azureTenantId, setAzureTenantId] = useState("");
const [azureClientId, setAzureClientId] = useState(""); const [azureClientId, setAzureClientId] = useState("");
const [azureClientSecret, setAzureClientSecret] = useState(""); const [azureClientSecret, setAzureClientSecret] = useState("");
const [azureSecretExpiresAt, setAzureSecretExpiresAt] = useState("");
const testAzureConnectionMutation = trpc.importSettings.testAzureConnection.useMutation();
// Initialize form with settings from database // Initialize form with settings from database
useEffect(() => { useEffect(() => {
@@ -83,6 +85,8 @@ export default function ImportSettings() {
setAzureTenantId((settings as any).azureTenantId || ""); setAzureTenantId((settings as any).azureTenantId || "");
setAzureClientId((settings as any).azureClientId || ""); setAzureClientId((settings as any).azureClientId || "");
setAzureClientSecret((settings as any).azureClientSecret || ""); setAzureClientSecret((settings as any).azureClientSecret || "");
const expDate = (settings as any).azureSecretExpiresAt;
setAzureSecretExpiresAt(expDate ? new Date(expDate).toISOString().split('T')[0] : "");
} }
}, [settings]); }, [settings]);
@@ -112,6 +116,7 @@ export default function ImportSettings() {
azureTenantId: azureTenantId || null, azureTenantId: azureTenantId || null,
azureClientId: azureClientId || null, azureClientId: azureClientId || null,
azureClientSecret: azureClientSecret || null, azureClientSecret: azureClientSecret || null,
azureSecretExpiresAt: azureSecretExpiresAt ? new Date(azureSecretExpiresAt) : null,
}); });
toast.success("Paramètres enregistrés avec succès"); toast.success("Paramètres enregistrés avec succès");
@@ -680,6 +685,72 @@ export default function ImportSettings() {
/> />
<p className="text-xs text-muted-foreground">Stocké de façon sécurisée. Permissions requises : <code>Files.ReadWrite.All</code> et <code>Sites.ReadWrite.All</code></p> <p className="text-xs text-muted-foreground">Stocké de façon sécurisée. Permissions requises : <code>Files.ReadWrite.All</code> et <code>Sites.ReadWrite.All</code></p>
</div> </div>
{/* Date d'expiration du secret */}
<div className="space-y-1">
<Label htmlFor="azureSecretExpiresAt" className="text-sm font-medium flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
Date d'expiration du secret
</Label>
<Input
id="azureSecretExpiresAt"
type="date"
value={azureSecretExpiresAt}
onChange={(e) => setAzureSecretExpiresAt(e.target.value)}
className="h-10"
/>
<p className="text-xs text-muted-foreground">Renseignez la date d'expiration pour recevoir une alerte avant renouvellement</p>
</div>
{/* Alerte expiration */}
{azureSecretExpiresAt && (() => {
const expDate = new Date(azureSecretExpiresAt);
const daysLeft = Math.ceil((expDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
if (daysLeft <= 0) return (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span><strong>Secret expiré</strong> renouvelez-le immédiatement dans le portail Azure AD</span>
</div>
);
if (daysLeft <= 30) return (
<div className="flex items-center gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span><strong>Expiration dans {daysLeft} jours</strong> pensez à renouveler le secret Azure AD</span>
</div>
);
return (
<div className="flex items-center gap-2 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span>Secret valide encore <strong>{daysLeft} jours</strong> (expire le {expDate.toLocaleDateString('fr-FR')})</span>
</div>
);
})()}
{/* Bouton test connexion */}
<div className="pt-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
const result = await testAzureConnectionMutation.mutateAsync();
if (result.success) {
toast.success(result.message || 'Connexion Azure AD réussie');
} else {
toast.error(result.error || 'Erreur de connexion Azure AD');
}
}}
disabled={testAzureConnectionMutation.isPending || !azureTenantId || !azureClientId || !azureClientSecret}
className="gap-2"
>
{testAzureConnectionMutation.isPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Test en cours...</>
) : (
<><Wifi className="h-4 w-4" />Tester la connexion Azure AD</>
)}
</Button>
<p className="text-xs text-muted-foreground mt-1">Vérifie le token OAuth2 et l'accès au site SharePoint</p>
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -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;

View File

@@ -191,6 +191,7 @@ export const importSettings = mysqlTable("importSettings", {
azureTenantId: varchar("azureTenantId", { length: 100 }), // Azure AD Tenant ID azureTenantId: varchar("azureTenantId", { length: 100 }), // Azure AD Tenant ID
azureClientId: varchar("azureClientId", { length: 100 }), // Azure AD Application (client) ID azureClientId: varchar("azureClientId", { length: 100 }), // Azure AD Application (client) ID
azureClientSecret: text("azureClientSecret"), // Azure AD Client Secret (encrypted) azureClientSecret: text("azureClientSecret"), // Azure AD Client Secret (encrypted)
azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date
// AI Engine settings // AI Engine settings
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus"]).default("mistral").notNull(), // AI provider for invoice extraction 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 exportPath: text("exportPath"), // null for browser mode
pdfUrl: text("pdfUrl"), // S3 URL for browser mode pdfUrl: text("pdfUrl"), // S3 URL for browser mode
signatureName: varchar("signatureName", { length: 255 }), // Signer name if applied 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(), validatedAt: timestamp("validatedAt").defaultNow().notNull(),
}); });
export type BapHistory = typeof bapHistory.$inferSelect; export type BapHistory = typeof bapHistory.$inferSelect;

View File

@@ -476,6 +476,9 @@ export const appRouter = router({
let pdfUrl: string | null = null; let pdfUrl: string | null = null;
let exportPath: string | null = null; let exportPath: string | null = null;
let signatureName: 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 { try {
if (!invoice.fileKey && !invoice.fileUrl) throw new Error('Fichier PDF source introuvable'); if (!invoice.fileKey && !invoice.fileUrl) throw new Error('Fichier PDF source introuvable');
@@ -567,8 +570,12 @@ export const appRouter = router({
); );
if (spResult.success) { if (spResult.success) {
exportPath = spResult.webUrl || exportFolder; exportPath = spResult.webUrl || exportFolder;
sharepointUploadStatus = 'success';
sharepointUploadPath = spResult.webUrl || exportFolder;
} else { } else {
console.warn('[BAP] Erreur upload SharePoint:', spResult.error); console.warn('[BAP] Erreur upload SharePoint:', spResult.error);
sharepointUploadStatus = 'error';
sharepointUploadError = spResult.error || 'Erreur inconnue';
// Fallback : stocker localement // Fallback : stocker localement
const bapKey = generateStorageKey(ctx.user.id, bapFilename); const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf'); const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
@@ -615,6 +622,9 @@ export const appRouter = router({
exportPath: exportPath || null, exportPath: exportPath || null,
pdfUrl: pdfUrl || null, pdfUrl: pdfUrl || null,
signatureName: signatureName || null, signatureName: signatureName || null,
sharepointUploadStatus: (sharepointUploadStatus as any) || null,
sharepointUploadPath: sharepointUploadPath || null,
sharepointUploadError: sharepointUploadError || null,
validatedAt, validatedAt,
}); });
@@ -662,6 +672,9 @@ export const appRouter = router({
let pdfUrl: string | null = null; let pdfUrl: string | null = null;
let exportPath: string | null = null; let exportPath: string | null = null;
let signatureName: 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 { try {
// ─ Lecture du PDF source ─ // ─ Lecture du PDF source ─
let sourcePdfBytes: Buffer; let sourcePdfBytes: Buffer;
@@ -782,6 +795,9 @@ export const appRouter = router({
exportPath: exportPath || null, exportPath: exportPath || null,
pdfUrl: pdfUrl || null, pdfUrl: pdfUrl || null,
signatureName: signatureName || null, signatureName: signatureName || null,
sharepointUploadStatus: (sharepointUploadStatus as any) || null,
sharepointUploadPath: sharepointUploadPath || null,
sharepointUploadError: sharepointUploadError || null,
validatedAt, validatedAt,
}); });
results.push({ id: invoice.id, success: true, pdfUrl, exportPath }); results.push({ id: invoice.id, success: true, pdfUrl, exportPath });
@@ -1489,6 +1505,7 @@ export const appRouter = router({
azureTenantId: z.string().nullable().optional(), azureTenantId: z.string().nullable().optional(),
azureClientId: z.string().nullable().optional(), azureClientId: z.string().nullable().optional(),
azureClientSecret: z.string().nullable().optional(), azureClientSecret: z.string().nullable().optional(),
azureSecretExpiresAt: z.date().nullable().optional(),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const settings = await upsertImportSettings({ const settings = await upsertImportSettings({
@@ -1498,6 +1515,57 @@ export const appRouter = router({
return settings; 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 ============= // ============= DEPARTMENT LIST ROUTES =============