Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Replaced hardcoded "Agenda" labels with dynamic ULTICAL_APP_NAME in various components for consistency. - Introduced new AiUsageSection and CompteAiUsageSection components to track AI usage and costs. - Updated settings and metadata to reflect changes in AI cost policies and usage limits. - Enhanced user interface elements for better accessibility and user experience across admin settings.
1086 lines
40 KiB
TypeScript
1086 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 { SettingsField, SettingsGrid } from "@/components/settings/settings-kit"
|
|
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 { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
|
|
|
|
const SERVICE_LABELS: Record<string, string> = {
|
|
mail: "Mail",
|
|
contacts: "Contacts",
|
|
calendar: ULTICAL_APP_NAME,
|
|
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>
|
|
)}
|
|
|
|
<SettingsGrid>
|
|
<SettingsField label="Nom du projet" htmlFor="project-name">
|
|
<Input
|
|
id="project-name"
|
|
className="h-9"
|
|
value={projectName}
|
|
onChange={(e) => setProjectName(e.target.value)}
|
|
placeholder="Migration ACME 2026"
|
|
/>
|
|
</SettingsField>
|
|
<SettingsField label="Source">
|
|
<Select
|
|
value={sourceProvider}
|
|
onValueChange={(value) => {
|
|
setSourceProvider(value)
|
|
if (value !== "google" && authMode === "google_dwd") {
|
|
setAuthMode("oauth")
|
|
}
|
|
if (value !== "microsoft" && authMode === "microsoft_app") {
|
|
setAuthMode("oauth")
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9">
|
|
<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>
|
|
</SettingsField>
|
|
<SettingsField label="Mode d'authentification">
|
|
<Select value={authMode} onValueChange={setAuthMode}>
|
|
<SelectTrigger className="h-9">
|
|
<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>
|
|
</SettingsField>
|
|
<SettingsField label="Domaine mail (optionnel)">
|
|
<Select value={domainId || "__none__"} onValueChange={(v) => setDomainId(v === "__none__" ? "" : v)}>
|
|
<SelectTrigger className="h-9">
|
|
<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>
|
|
</SettingsField>
|
|
</SettingsGrid>
|
|
|
|
<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 border-mail-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 border-mail-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 border-mail-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 border-mail-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'email d'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 border-mail-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'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'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'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>
|
|
)
|
|
}
|