feat: améliorations SharePoint - test connexion Azure AD, alerte expiration secret, log statut upload dans BapHistory
This commit is contained in:
@@ -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() {
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</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 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 text-orange-600">
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
4
drizzle/0025_unknown_spencer_smythe.sql
Normal file
4
drizzle/0025_unknown_spencer_smythe.sql
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =============
|
||||
|
||||
Reference in New Issue
Block a user