ultisuite-client/components/admin/settings/sections/migration-projects-panel.tsx
R3D347HR4Y 6c7278a3aa
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(onboarding): implement claim and migration pages with OAuth support
- 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.
2026-06-13 12:47:03 +02:00

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&apos;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&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>
)
}