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,
|
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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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
|
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;
|
||||||
|
|||||||
@@ -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 =============
|
||||||
|
|||||||
Reference in New Issue
Block a user