ultisuite-client/components/admin/settings/sections/migration-projects-panel.tsx
R3D347HR4Y 9e9fd208ad
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(admin-settings): enhance admin settings with new components and layout improvements
- Introduced new components for managing admin settings, including AdminListControls, AdminSettingsCard, and TechBrandSelectLabel.
- Implemented dynamic loading for admin settings sections to optimize performance.
- Enhanced the layout of various admin settings sections for better user experience.
- Updated the AiAssistantSection to include LLM provider management and improved model selection.
- Refactored authentication settings to streamline configuration and improve accessibility.
2026-06-15 00:22:20 +02:00

1088 lines
40 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import { Download } from "lucide-react"
import { useSearchParams } from "next/navigation"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { TechBrandSelectLabel } from "@/components/admin/settings/tech-brand-select-label"
import {
type DNSCheckReport,
type MailDomain,
type MigrationSharedDrive,
type MigrationJob,
type MigrationJobAuditItem,
type MigrationProject,
useActivateMigrationProject,
useCreateMigrationInvite,
useCreateMigrationProject,
useCutoverMigrationProject,
useImportMigrationInvites,
useImportMigrationRoster,
useMicrosoftMigrationAdminConsentURL,
useMigrationJobAudit,
useMigrationJobAuditSummary,
useMigrationProjectJobs,
useMigrationProjects,
useMigrationRoster,
useMigrationSharedDrives,
useApproveMigrationSharedDrive,
useRejectMigrationSharedDrive,
useUpdateMigrationSharedDriveMode,
downloadMigrationJobAudit,
usePreflightCutoverDNS,
useResetMigrationJobCursor,
useRetryMigrationFailedJobs,
useRetryMigrationJob,
} from "@/lib/api/hooks/use-hosted-mail"
import { ApiRequestError } from "@/lib/api/client"
const SERVICE_LABELS: Record<string, string> = {
mail: "Mail",
contacts: "Contacts",
calendar: "Agenda",
drive: "Drive",
}
const AUTH_MODE_LABELS: Record<string, string> = {
oauth: "OAuth utilisateur",
google_dwd: "Google DWD (service account)",
microsoft_app: "Microsoft app-only (client credentials)",
}
function statusVariant(status: string): "default" | "secondary" | "destructive" | "outline" {
switch (status) {
case "completed":
case "active":
case "cutover":
case "claimed":
case "invited":
return "default"
case "running":
case "pending":
return "secondary"
case "failed":
return "destructive"
default:
return "outline"
}
}
function jobImportedCount(job: MigrationJob): string {
const imported = job.stats_json?.imported
if (typeof imported !== "number") return "—"
const total = job.stats_json?.estimated_total
if (typeof total === "number" && total > 0) {
return `${Math.round(imported)} / ~${Math.round(total)}`
}
return String(Math.round(imported))
}
export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
const params = useSearchParams()
const projectsQuery = useMigrationProjects()
const createProject = useCreateMigrationProject()
const activateProject = useActivateMigrationProject()
const cutoverProject = useCutoverMigrationProject()
const msAdminConsent = useMicrosoftMigrationAdminConsentURL()
const [projectName, setProjectName] = useState("")
const [sourceProvider, setSourceProvider] = useState("google")
const [authMode, setAuthMode] = useState("oauth")
const [domainId, setDomainId] = useState("")
const [inviteEmail, setInviteEmail] = useState("")
const [alternateEmails, setAlternateEmails] = useState("")
const [csvEmails, setCsvEmails] = useState("")
const [rosterCsv, setRosterCsv] = useState("")
const [rosterImportSummary, setRosterImportSummary] = useState<string | null>(null)
const [selectedProjectId, setSelectedProjectId] = useState("")
const [msTenant, setMsTenant] = useState("common")
const [consentBanner, setConsentBanner] = useState<string | null>(null)
const [cutoverReport, setCutoverReport] = useState<DNSCheckReport | null>(null)
const [cutoverError, setCutoverError] = useState<string | null>(null)
const [dnsSource, setDnsSource] = useState<"cutover" | "persisted" | "live" | null>(null)
const [auditJobId, setAuditJobId] = useState<string | null>(null)
const projects = projectsQuery.data?.projects ?? []
const activeProjectId = selectedProjectId || projects[0]?.id || ""
const activeProject = projects.find((p) => p.id === activeProjectId)
const createInvite = useCreateMigrationInvite(activeProjectId)
const importInvites = useImportMigrationInvites(activeProjectId)
const importRoster = useImportMigrationRoster(activeProjectId)
const rosterQuery = useMigrationRoster(activeProjectId, Boolean(activeProjectId))
const jobsQuery = useMigrationProjectJobs(activeProjectId, Boolean(activeProjectId))
const retryJob = useRetryMigrationJob(activeProjectId)
const resetCursor = useResetMigrationJobCursor(activeProjectId)
const retryFailed = useRetryMigrationFailedJobs(activeProjectId)
const preflightDNS = usePreflightCutoverDNS(activeProjectId)
const updateSharedDriveMode = useUpdateMigrationSharedDriveMode(activeProjectId)
const sharedDrivesQuery = useMigrationSharedDrives(
activeProjectId,
Boolean(activeProjectId && activeProject?.source_provider === "google")
)
const approveSharedDrive = useApproveMigrationSharedDrive(activeProjectId)
const rejectSharedDrive = useRejectMigrationSharedDrive(activeProjectId)
useEffect(() => {
const consent = params.get("microsoft_admin_consent")
if (consent === "success") {
const tenant = params.get("tenant")
const projectId = params.get("project_id")
setConsentBanner(
tenant
? `Consentement admin Microsoft enregistré (tenant ${tenant}${projectId ? `, projet ${projectId.slice(0, 8)}` : ""}).`
: "Consentement admin Microsoft enregistré."
)
void projectsQuery.refetch()
} else if (consent === "error") {
setConsentBanner("Échec du consentement admin Microsoft — vérifiez le tenant et réessayez.")
void projectsQuery.refetch()
}
}, [params, projectsQuery])
useEffect(() => {
setCutoverReport(null)
setCutoverError(null)
setDnsSource(null)
setAuditJobId(null)
}, [activeProjectId])
useEffect(() => {
if (activeProject?.cutover_dns) {
setDnsSource((prev) => (prev === "cutover" || prev === "live" ? prev : "persisted"))
}
}, [activeProject?.cutover_dns, activeProjectId])
const dnsReport =
cutoverReport ?? activeProject?.cutover_dns ?? preflightDNS.data?.dns ?? null
const failedCount = (jobsQuery.data?.jobs ?? []).filter((j) => j.status === "failed").length
return (
<OrgSettingsSection
title="Projets de migration"
description="Import Google Workspace / Microsoft 365, invitations, suivi des jobs et bascule MX."
>
{consentBanner && (
<p
className={`mb-4 rounded-md px-3 py-2 text-sm ${
consentBanner.startsWith("Échec")
? "bg-destructive/10 text-destructive"
: "bg-green-500/10 text-green-700"
}`}
>
{consentBanner}
</p>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="project-name">Nom du projet</Label>
<Input
id="project-name"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="Migration ACME 2026"
/>
</div>
<div className="space-y-2">
<Label>Source</Label>
<Select
value={sourceProvider}
onValueChange={(value) => {
setSourceProvider(value)
if (value !== "google" && authMode === "google_dwd") {
setAuthMode("oauth")
}
if (value !== "microsoft" && authMode === "microsoft_app") {
setAuthMode("oauth")
}
}}
>
<SelectTrigger>
<SelectValue>
<TechBrandSelectLabel brand={sourceProvider}>
{sourceProvider === "google" ? "Google Workspace" : "Microsoft 365"}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="google">
<TechBrandSelectLabel brand="google">Google Workspace</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="microsoft">
<TechBrandSelectLabel brand="microsoft">Microsoft 365</TechBrandSelectLabel>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Mode d&apos;authentification</Label>
<Select value={authMode} onValueChange={setAuthMode}>
<SelectTrigger>
<SelectValue>
<TechBrandSelectLabel brand={authMode === "oauth" ? "oauth" : authMode}>
{AUTH_MODE_LABELS[authMode] ?? authMode}
</TechBrandSelectLabel>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="oauth">
<TechBrandSelectLabel brand="oauth">OAuth utilisateur</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="google_dwd" disabled={sourceProvider !== "google"}>
<TechBrandSelectLabel brand="google">
Google DWD (service account)
</TechBrandSelectLabel>
</SelectItem>
<SelectItem value="microsoft_app" disabled={sourceProvider !== "microsoft"}>
<TechBrandSelectLabel brand="microsoft">
Microsoft app-only (client credentials)
</TechBrandSelectLabel>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Domaine mail (optionnel)</Label>
<Select value={domainId || "__none__"} onValueChange={(v) => setDomainId(v === "__none__" ? "" : v)}>
<SelectTrigger>
<SelectValue placeholder="Aucun" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Aucun</SelectItem>
{domains.map((domain) => (
<SelectItem key={domain.id} value={domain.id}>
{domain.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-4">
<Button
disabled={!projectName || createProject.isPending}
onClick={() => {
void createProject
.mutateAsync({
name: projectName,
source_provider: sourceProvider,
domain_id: domainId || undefined,
auth_mode: authMode,
})
.then((project) => {
setProjectName("")
setSelectedProjectId(project.id)
})
}}
>
Créer le projet
</Button>
</div>
<ul className="mt-6 space-y-3">
{projects.map((project) => (
<ProjectRow
key={project.id}
project={project}
selected={project.id === activeProjectId}
onSelect={() => setSelectedProjectId(project.id)}
onActivate={() => {
setSelectedProjectId(project.id)
void activateProject.mutateAsync(project.id)
}}
onCutover={() => {
setCutoverError(null)
void cutoverProject.mutateAsync(project.id).then(
(res) => {
setCutoverReport(res.dns)
setDnsSource("cutover")
},
(err) => {
setCutoverReport(null)
if (err instanceof ApiRequestError && err.code === "migration_cutover_mx_not_ready") {
const details = err.details as { dns?: DNSCheckReport } | undefined
if (details?.dns) {
setCutoverReport(details.dns)
setDnsSource("cutover")
}
setCutoverError(err.message)
} else {
setCutoverError(err instanceof Error ? err.message : "Cutover failed")
}
}
)
}}
/>
))}
</ul>
{activeProject?.source_provider === "microsoft" && (
<div className="mt-6 space-y-3 rounded-lg border p-4">
<h3 className="font-medium">Consentement admin Microsoft</h3>
<p className="text-sm text-muted-foreground">
Requis pour les migrations Microsoft 365
{activeProject.auth_mode === "microsoft_app"
? " en mode app-only (permissions application : Mail.Read, Calendars.Read, Contacts.Read, Files.Read.All)."
: " en mode organisation (permissions applicatives)."}
</p>
{activeProject.microsoft_admin_consent_at ? (
<p className="text-sm text-green-700">
Consentement accordé
{activeProject.microsoft_tenant_id
? ` — tenant ${activeProject.microsoft_tenant_id}`
: ""}
{activeProject.microsoft_admin_consent_at
? ` (${new Date(activeProject.microsoft_admin_consent_at).toLocaleString()})`
: ""}
</p>
) : activeProject.microsoft_admin_consent_error ? (
<p className="text-sm text-destructive">{activeProject.microsoft_admin_consent_error}</p>
) : (
<p className="text-sm text-amber-700">Consentement non enregistré pour ce projet.</p>
)}
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
<Input
value={msTenant}
onChange={(e) => setMsTenant(e.target.value)}
placeholder="common ou tenant ID"
/>
<Button
variant="outline"
disabled={msAdminConsent.isPending || !activeProjectId}
onClick={() => {
void msAdminConsent
.mutateAsync({ tenant: msTenant, projectId: activeProjectId })
.then((res) => {
window.location.href = res.url
})
}}
>
Consentement admin MS
</Button>
</div>
</div>
)}
{activeProjectId && (
<>
<div className="mt-6 space-y-3 rounded-lg border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-medium">Pré-vérification DNS (cutover)</h3>
<Button
size="sm"
variant="outline"
disabled={preflightDNS.isFetching}
onClick={() => {
void preflightDNS.refetch().then((res) => {
if (res.data?.dns) setDnsSource("live")
})
}}
>
{preflightDNS.isFetching ? "Vérification…" : "Vérifier DNS"}
</Button>
</div>
{cutoverError && (
<p className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{cutoverError}
</p>
)}
{dnsReport && (
<>
{dnsSource === "persisted" && activeProject?.cutover_at && (
<p className="text-xs text-muted-foreground">
Rapport enregistré au cutover (
{new Date(activeProject.cutover_at).toLocaleString()}).
</p>
)}
{dnsSource === "live" && (
<p className="text-xs text-muted-foreground">
Vérification DNS live (non enregistrée lancez la bascule MX pour persister).
</p>
)}
<DNSReportCard report={dnsReport} />
</>
)}
{!dnsReport && activeProject?.domain_id && (
<p className="text-sm text-muted-foreground">
Aucun rapport DNS enregistré vérifiez live ou lancez la bascule MX.
</p>
)}
</div>
{activeProject?.source_provider === "google" && (
<SharedDrivesPanel
project={activeProject}
drives={sharedDrivesQuery.data?.shared_drives ?? []}
loading={sharedDrivesQuery.isLoading}
onModeChange={(mode) => void updateSharedDriveMode.mutateAsync(mode)}
modePending={updateSharedDriveMode.isPending}
onApprove={(driveId) => void approveSharedDrive.mutateAsync(driveId)}
onReject={(driveId) => void rejectSharedDrive.mutateAsync(driveId)}
actionPending={approveSharedDrive.isPending || rejectSharedDrive.isPending}
/>
)}
<div className="mt-8 space-y-4 rounded-lg border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-medium">Jobs de migration</h3>
<Button
size="sm"
variant="outline"
disabled={failedCount === 0 || retryFailed.isPending}
onClick={() => void retryFailed.mutateAsync()}
>
Relancer les échecs ({failedCount})
</Button>
</div>
<JobsTable
jobs={jobsQuery.data?.jobs ?? []}
loading={jobsQuery.isLoading}
auditJobId={auditJobId}
onAudit={(jobId) => setAuditJobId((prev) => (prev === jobId ? null : jobId))}
onRetry={(jobId) => void retryJob.mutateAsync(jobId)}
onResetCursor={(jobId) => void resetCursor.mutateAsync(jobId)}
retryPending={retryJob.isPending}
resetPending={resetCursor.isPending}
/>
{auditJobId && (
<JobAuditPanel projectId={activeProjectId} jobId={auditJobId} jobs={jobsQuery.data?.jobs ?? []} />
)}
</div>
<div className="mt-8 space-y-4 rounded-lg border p-4">
<h3 className="font-medium">Invitations utilisateurs</h3>
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
<Input
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="alice@entreprise.com"
/>
<Button
disabled={!inviteEmail}
onClick={() => {
const alternates = alternateEmails
.split(/[\n,;]+/)
.map((e) => e.trim())
.filter(Boolean)
void createInvite
.mutateAsync({
email: inviteEmail,
alternate_emails: alternates.length > 0 ? alternates : undefined,
})
.then((inv) => {
setInviteEmail("")
setAlternateEmails("")
if (inv.token) {
window.prompt(
"Lien de claim (copier)",
`${window.location.origin}/onboard/claim?token=${inv.token}`
)
}
})
}}
>
Inviter
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="alternate-emails">
Emails alternatifs (SSO / alias, séparés par virgule ou ligne)
</Label>
<textarea
id="alternate-emails"
className="min-h-16 w-full rounded-md border bg-background px-3 py-2 text-sm"
value={alternateEmails}
onChange={(e) => setAlternateEmails(e.target.value)}
placeholder="alice.smith@tenant.onmicrosoft.com"
/>
<p className="text-xs text-muted-foreground">
Adresses OIDC acceptées en plus de l&apos;email d&apos;invitation (UPN Microsoft,
alias Gmail, etc.).
</p>
</div>
<div className="space-y-2">
<Label htmlFor="csv-emails">Import rapide (email seul, une ligne)</Label>
<textarea
id="csv-emails"
className="min-h-24 w-full rounded-md border bg-background px-3 py-2 text-sm"
value={csvEmails}
onChange={(e) => setCsvEmails(e.target.value)}
placeholder={"alice@entreprise.com\nbob@entreprise.com"}
/>
<Button
variant="outline"
disabled={!csvEmails.trim()}
onClick={() => {
const emails = csvEmails
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
void importInvites.mutateAsync(emails).then(() => setCsvEmails(""))
}}
>
Importer les emails
</Button>
</div>
<div className="space-y-3 rounded-md border border-dashed p-4">
<div>
<h4 className="text-sm font-medium">Pré-provision roster (CSV)</h4>
<p className="text-xs text-muted-foreground">
Colonnes : <code>email</code> (requis), <code>display_name</code>,{" "}
<code>alternate_emails</code> (séparés par ;). En-tête optionnel.
</p>
</div>
<textarea
id="roster-csv"
className="min-h-32 w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
value={rosterCsv}
onChange={(e) => setRosterCsv(e.target.value)}
placeholder={
"email,display_name,alternate_emails\n" +
"alice@entreprise.com,Alice Dupont,alice.perso@gmail.com\n" +
"bob@entreprise.com,Bob Martin,"
}
/>
<Button
disabled={!rosterCsv.trim() || importRoster.isPending}
onClick={() => {
void importRoster
.mutateAsync(rosterCsv)
.then((result) => {
setRosterCsv("")
const errCount = result.errors?.length ?? 0
setRosterImportSummary(
`${result.created} créé(s), ${result.skipped_duplicates} doublon(s) ignoré(s)` +
(errCount > 0 ? `, ${errCount} erreur(s)` : "")
)
})
.catch((err: unknown) => {
setRosterImportSummary(
err instanceof ApiRequestError ? err.message : "Import roster échoué"
)
})
}}
>
{importRoster.isPending ? "Import…" : "Importer le roster"}
</Button>
{rosterImportSummary && (
<p className="text-sm text-muted-foreground">{rosterImportSummary}</p>
)}
{(rosterQuery.data?.roster?.length ?? 0) > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b text-muted-foreground">
<th className="py-1 pr-2 font-medium">Email</th>
<th className="py-1 pr-2 font-medium">Nom</th>
<th className="py-1 pr-2 font-medium">Statut</th>
</tr>
</thead>
<tbody>
{rosterQuery.data?.roster.map((entry) => (
<tr key={entry.id} className="border-b border-border/50">
<td className="py-1 pr-2">{entry.email}</td>
<td className="py-1 pr-2">{entry.display_name || "—"}</td>
<td className="py-1 pr-2">
<Badge variant={statusVariant(entry.status)}>{entry.status}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
DNS : TXT <code>_ultisuite-verify.domaine</code>, MX vers Stalwart, SPF/DKIM/DMARC via
Stalwart webadmin.
</p>
</div>
</>
)}
</OrgSettingsSection>
)
}
function SharedDrivesPanel({
project,
drives,
loading,
onModeChange,
modePending,
onApprove,
onReject,
actionPending,
}: {
project: MigrationProject
drives: MigrationSharedDrive[]
loading: boolean
onModeChange: (mode: "auto" | "manual") => void
modePending: boolean
onApprove: (driveId: string) => void
onReject: (driveId: string) => void
actionPending: boolean
}) {
const mode = project.shared_drive_mode ?? "auto"
const pending = drives.filter((d) => d.status === "pending")
return (
<div className="mt-8 space-y-4 rounded-lg border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="font-medium">Shared Drives Google</h3>
<p className="text-xs text-muted-foreground">
Import automatique ou approbation manuelle avant migration. Les fichiers partagés ne
sont importés qu&apos;une fois par projet (déduplication).
</p>
</div>
<Select
value={mode}
disabled={modePending}
onValueChange={(v) => onModeChange(v as "auto" | "manual")}
>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Import auto</SelectItem>
<SelectItem value="manual">Approbation manuelle</SelectItem>
</SelectContent>
</Select>
</div>
{loading && <p className="text-sm text-muted-foreground">Chargement des shared drives</p>}
{!loading && drives.length === 0 && (
<p className="text-sm text-muted-foreground">
Aucun shared drive découvert lancez un job Drive utilisateur pour détecter les drives
accessibles.
</p>
)}
{mode === "manual" && pending.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">En attente d&apos;approbation ({pending.length})</p>
<ul className="space-y-2">
{pending.map((drive) => (
<li
key={drive.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<div>
<span className="font-medium">{drive.name || drive.drive_id}</span>
<code className="ml-2 text-xs text-muted-foreground">{drive.drive_id}</code>
</div>
<div className="flex gap-2">
<Button
size="sm"
disabled={actionPending}
onClick={() => onApprove(drive.drive_id)}
>
Approuver
</Button>
<Button
size="sm"
variant="outline"
disabled={actionPending}
onClick={() => onReject(drive.drive_id)}
>
Rejeter
</Button>
</div>
</li>
))}
</ul>
</div>
)}
{!loading && drives.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">Tous les shared drives ({drives.length})</p>
<ul className="max-h-40 space-y-1 overflow-y-auto text-xs">
{drives.map((drive) => (
<li key={drive.id} className="flex items-center gap-2">
<Badge
variant={
drive.status === "approved"
? "default"
: drive.status === "rejected"
? "destructive"
: "secondary"
}
>
{drive.status}
</Badge>
<span>{drive.name || drive.drive_id}</span>
</li>
))}
</ul>
</div>
)}
</div>
)
}
function DNSReportCard({ report }: { report: DNSCheckReport }) {
return (
<div className="space-y-2 text-sm">
{report.domain && (
<p>
Domaine : <span className="font-medium">{report.domain}</span>
</p>
)}
<div className="flex flex-wrap gap-2">
<Badge variant={report.txt_verified ? "default" : "destructive"}>
TXT {report.txt_verified ? "OK" : "KO"}
</Badge>
<Badge variant={report.mx_verified ? "default" : "destructive"}>
MX {report.mx_verified ? "OK" : "KO"}
</Badge>
</div>
{report.txt_expected && (
<p className="text-muted-foreground">
Attendu TXT : <code>{report.txt_expected}</code>
</p>
)}
{report.expected_mx && report.expected_mx.length > 0 && (
<p className="text-muted-foreground">
MX attendus : {report.expected_mx.join(", ")}
</p>
)}
{report.mx_records && report.mx_records.length > 0 && (
<p className="text-muted-foreground">MX live : {report.mx_records.join(", ")}</p>
)}
{report.warnings?.map((w) => (
<p key={w} className="text-amber-700">
{w}
</p>
))}
{report.errors?.map((e) => (
<p key={e} className="text-destructive">
{e}
</p>
))}
</div>
)
}
function ProjectRow({
project,
selected,
onSelect,
onActivate,
onCutover,
}: {
project: MigrationProject
selected: boolean
onSelect: () => void
onActivate: () => void
onCutover: () => void
}) {
return (
<li
className={`rounded-lg border p-4 ${selected ? "border-primary/50 bg-muted/30" : ""}`}
onClick={onSelect}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onSelect()
}}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="font-medium">{project.name}</p>
<div className="mt-1 flex flex-wrap gap-2">
<Badge variant="outline">{project.source_provider}</Badge>
<Badge variant={statusVariant(project.status)}>{project.status}</Badge>
<Badge variant="outline">{AUTH_MODE_LABELS[project.auth_mode] ?? project.auth_mode}</Badge>
{project.source_provider === "microsoft" && (
<Badge
variant={
project.microsoft_admin_consent_at
? "default"
: project.microsoft_admin_consent_error
? "destructive"
: "outline"
}
>
{project.microsoft_admin_consent_at
? "MS consent OK"
: project.microsoft_admin_consent_error
? "MS consent KO"
: "MS consent ?"}
</Badge>
)}
{project.cutover_at && (
<Badge variant="outline">
cutover {new Date(project.cutover_at).toLocaleDateString()}
</Badge>
)}
{project.cutover_dns && (
<Badge
variant={
project.cutover_dns.mx_verified && project.cutover_dns.txt_verified
? "default"
: "destructive"
}
>
DNS{" "}
{project.cutover_dns.mx_verified && project.cutover_dns.txt_verified ? "OK" : "KO"}
</Badge>
)}
{project.delta_mode && <Badge variant="secondary">delta</Badge>}
</div>
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="outline" onClick={onActivate}>
Activer
</Button>
<Button size="sm" variant="secondary" onClick={onCutover}>
Bascule MX
</Button>
</div>
</div>
</li>
)
}
function JobsTable({
jobs,
loading,
auditJobId,
onAudit,
onRetry,
onResetCursor,
retryPending,
resetPending,
}: {
jobs: MigrationJob[]
loading: boolean
auditJobId: string | null
onAudit: (jobId: string) => void
onRetry: (jobId: string) => void
onResetCursor: (jobId: string) => void
retryPending: boolean
resetPending: boolean
}) {
if (loading) {
return <p className="text-sm text-muted-foreground">Chargement des jobs</p>
}
if (jobs.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Aucun job invitez des utilisateurs et demandez-leur de claim + OAuth.
</p>
)
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-3 font-medium">Utilisateur</th>
<th className="py-2 pr-3 font-medium">Service</th>
<th className="py-2 pr-3 font-medium">Statut</th>
<th className="py-2 pr-3 font-medium">Importés</th>
<th className="py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id} className="border-b last:border-0">
<td className="py-2 pr-3">{job.user_email || job.user_id.slice(0, 8)}</td>
<td className="py-2 pr-3">{SERVICE_LABELS[job.service] ?? job.service}</td>
<td className="py-2 pr-3">
<Badge variant={statusVariant(job.status)}>{job.status}</Badge>
{job.stats_json?.rate_limited === true && (
<span className="ml-2 text-xs text-muted-foreground">(429)</span>
)}
</td>
<td className="py-2 pr-3">{jobImportedCount(job)}</td>
<td className="py-2">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant={auditJobId === job.id ? "secondary" : "outline"}
onClick={() => onAudit(job.id)}
>
Audit
</Button>
{job.status === "failed" && (
<Button
size="sm"
variant="outline"
disabled={retryPending}
onClick={() => onRetry(job.id)}
>
Relancer
</Button>
)}
{job.status !== "running" && (
<Button
size="sm"
variant="ghost"
disabled={resetPending}
onClick={() => onResetCursor(job.id)}
>
Réinitialiser
</Button>
)}
</div>
{job.error && (
<p className="mt-1 max-w-xs truncate text-xs text-destructive" title={job.error}>
{job.error}
</p>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function JobAuditPanel({
projectId,
jobId,
jobs,
}: {
projectId: string
jobId: string
jobs: MigrationJob[]
}) {
const job = jobs.find((j) => j.id === jobId)
const summaryQuery = useMigrationJobAuditSummary(projectId, jobId)
const failedQuery = useMigrationJobAudit(projectId, jobId, "failed")
const skippedQuery = useMigrationJobAudit(projectId, jobId, "skipped")
const [showSkipped, setShowSkipped] = useState(false)
const summary = summaryQuery.data
const failedItems = failedQuery.data?.items ?? []
const skippedItems = skippedQuery.data?.items ?? []
return (
<div className="mt-4 space-y-3 rounded-lg border border-dashed p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h4 className="font-medium">Audit import {job?.user_email ?? jobId.slice(0, 8)}</h4>
<p className="text-xs text-muted-foreground">
{SERVICE_LABELS[job?.service ?? ""] ?? job?.service} · statut job {job?.status ?? "—"}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!summary || summary.total === 0}
onClick={() => void downloadMigrationJobAudit(projectId, jobId, "csv")}
>
<Download className="mr-2 size-4" />
Export CSV
</Button>
<Button
variant="outline"
size="sm"
disabled={!summary || summary.total === 0}
onClick={() => void downloadMigrationJobAudit(projectId, jobId, "ndjson")}
>
<Download className="mr-2 size-4" />
Export NDJSON
</Button>
{summaryQuery.isLoading && (
<span className="text-xs text-muted-foreground">Chargement</span>
)}
</div>
</div>
{summary && (
<div className="flex flex-wrap gap-2">
<Badge variant="default">{summary.imported} importés</Badge>
<Badge variant={summary.failed > 0 ? "destructive" : "outline"}>
{summary.failed} échecs
</Badge>
<Badge variant={summary.skipped > 0 ? "secondary" : "outline"}>
{summary.skipped} ignorés
</Badge>
<Badge variant="outline">{summary.total} total</Badge>
</div>
)}
{summary && summary.total === 0 && (
<p className="text-sm text-muted-foreground">
Aucun élément enregistré le job n&apos;a pas encore traité d&apos;items ou la migration
date d&apos;avant l&apos;audit.
</p>
)}
{failedItems.length > 0 && (
<AuditItemList title="Échecs" items={failedItems} variant="destructive" />
)}
{summary && summary.skipped > 0 && (
<div className="space-y-2">
<Button size="sm" variant="ghost" onClick={() => setShowSkipped((v) => !v)}>
{showSkipped ? "Masquer" : "Afficher"} les ignorés ({summary.skipped})
</Button>
{showSkipped && skippedItems.length > 0 && (
<AuditItemList title="Ignorés" items={skippedItems} variant="secondary" />
)}
</div>
)}
{summary && summary.failed === 0 && summary.skipped === 0 && summary.total > 0 && (
<p className="text-sm text-green-700">Tous les éléments suivis ont é importés avec succès.</p>
)}
</div>
)
}
function AuditItemList({
title,
items,
variant,
}: {
title: string
items: MigrationJobAuditItem[]
variant: "destructive" | "secondary"
}) {
return (
<div className="space-y-2">
<p className="text-sm font-medium">{title}</p>
<ul className="max-h-48 space-y-1 overflow-y-auto text-xs">
{items.map((item) => (
<li key={item.source_id} className="rounded border px-2 py-1.5">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={variant}>{item.status}</Badge>
<code className="truncate">{item.source_id}</code>
{item.rel_path && (
<span className="truncate text-muted-foreground">{item.rel_path}</span>
)}
</div>
{item.reason && (
<p className="mt-1 text-destructive" title={item.reason}>
{item.reason}
</p>
)}
</li>
))}
</ul>
</div>
)
}