Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Added OnboardClaimPage and OnboardMigrationPage components for user onboarding. - Integrated OAuth login flow for Google and Microsoft accounts. - Implemented error handling and user feedback for claim and migration processes. - Created MigrationStepList and MigrationOnboardingAlerts components for progress tracking. - Added MailDomainsSection and MigrationProjectsPanel for admin settings. - Introduced e2e tests for onboarding migration scenarios.
797 lines
28 KiB
TypeScript
797 lines
28 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState } from "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 {
|
|
type DNSCheckReport,
|
|
type MailDomain,
|
|
type MigrationJob,
|
|
type MigrationJobAuditItem,
|
|
type MigrationProject,
|
|
useActivateMigrationProject,
|
|
useCreateMigrationInvite,
|
|
useCreateMigrationProject,
|
|
useCutoverMigrationProject,
|
|
useImportMigrationInvites,
|
|
useMicrosoftMigrationAdminConsentURL,
|
|
useMigrationJobAudit,
|
|
useMigrationJobAuditSummary,
|
|
useMigrationProjectJobs,
|
|
useMigrationProjects,
|
|
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":
|
|
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 [csvEmails, setCsvEmails] = useState("")
|
|
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 jobsQuery = useMigrationProjectJobs(activeProjectId, Boolean(activeProjectId))
|
|
const retryJob = useRetryMigrationJob(activeProjectId)
|
|
const resetCursor = useResetMigrationJobCursor(activeProjectId)
|
|
const retryFailed = useRetryMigrationFailedJobs(activeProjectId)
|
|
const preflightDNS = usePreflightCutoverDNS(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 />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="google">Google Workspace</SelectItem>
|
|
<SelectItem value="microsoft">Microsoft 365</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Mode d'authentification</Label>
|
|
<Select value={authMode} onValueChange={setAuthMode}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="oauth">OAuth utilisateur</SelectItem>
|
|
<SelectItem value="google_dwd" disabled={sourceProvider !== "google"}>
|
|
Google DWD (service account)
|
|
</SelectItem>
|
|
<SelectItem value="microsoft_app" disabled={sourceProvider !== "microsoft"}>
|
|
Microsoft app-only (client credentials)
|
|
</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>
|
|
|
|
<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={() => {
|
|
void createInvite.mutateAsync({ email: inviteEmail }).then((inv) => {
|
|
setInviteEmail("")
|
|
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="csv-emails">Import CSV (une adresse par 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)}
|
|
/>
|
|
<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 le roster
|
|
</Button>
|
|
</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 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>
|
|
{summaryQuery.isLoading && (
|
|
<span className="text-xs text-muted-foreground">Chargement…</span>
|
|
)}
|
|
</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'a pas encore traité d'items ou la migration
|
|
date d'avant l'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 été 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>
|
|
)
|
|
}
|