feat(migration): enhance migration projects panel with shared drive management and roster import
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

- Added support for managing shared drives within the MigrationProjectsPanel.
- Implemented functionality to approve and reject shared drives.
- Introduced roster import feature for bulk email invitations with alternate email support.
- Updated state management to handle new roster and shared drive functionalities.
- Enhanced UI components for better user experience during migration processes.
This commit is contained in:
R3D347HR4Y 2026-06-13 13:11:28 +02:00
parent 6c7278a3aa
commit 4d31ac294b
3 changed files with 435 additions and 15 deletions

View File

@ -1,6 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Download } from "lucide-react"
import { useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -17,6 +18,7 @@ import { OrgSettingsSection } from "@/components/admin/settings/org-settings-for
import { import {
type DNSCheckReport, type DNSCheckReport,
type MailDomain, type MailDomain,
type MigrationSharedDrive,
type MigrationJob, type MigrationJob,
type MigrationJobAuditItem, type MigrationJobAuditItem,
type MigrationProject, type MigrationProject,
@ -25,11 +27,18 @@ import {
useCreateMigrationProject, useCreateMigrationProject,
useCutoverMigrationProject, useCutoverMigrationProject,
useImportMigrationInvites, useImportMigrationInvites,
useImportMigrationRoster,
useMicrosoftMigrationAdminConsentURL, useMicrosoftMigrationAdminConsentURL,
useMigrationJobAudit, useMigrationJobAudit,
useMigrationJobAuditSummary, useMigrationJobAuditSummary,
useMigrationProjectJobs, useMigrationProjectJobs,
useMigrationProjects, useMigrationProjects,
useMigrationRoster,
useMigrationSharedDrives,
useApproveMigrationSharedDrive,
useRejectMigrationSharedDrive,
useUpdateMigrationSharedDriveMode,
downloadMigrationJobAudit,
usePreflightCutoverDNS, usePreflightCutoverDNS,
useResetMigrationJobCursor, useResetMigrationJobCursor,
useRetryMigrationFailedJobs, useRetryMigrationFailedJobs,
@ -55,6 +64,8 @@ function statusVariant(status: string): "default" | "secondary" | "destructive"
case "completed": case "completed":
case "active": case "active":
case "cutover": case "cutover":
case "claimed":
case "invited":
return "default" return "default"
case "running": case "running":
case "pending": case "pending":
@ -89,7 +100,10 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
const [authMode, setAuthMode] = useState("oauth") const [authMode, setAuthMode] = useState("oauth")
const [domainId, setDomainId] = useState("") const [domainId, setDomainId] = useState("")
const [inviteEmail, setInviteEmail] = useState("") const [inviteEmail, setInviteEmail] = useState("")
const [alternateEmails, setAlternateEmails] = useState("")
const [csvEmails, setCsvEmails] = useState("") const [csvEmails, setCsvEmails] = useState("")
const [rosterCsv, setRosterCsv] = useState("")
const [rosterImportSummary, setRosterImportSummary] = useState<string | null>(null)
const [selectedProjectId, setSelectedProjectId] = useState("") const [selectedProjectId, setSelectedProjectId] = useState("")
const [msTenant, setMsTenant] = useState("common") const [msTenant, setMsTenant] = useState("common")
const [consentBanner, setConsentBanner] = useState<string | null>(null) const [consentBanner, setConsentBanner] = useState<string | null>(null)
@ -103,11 +117,20 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
const activeProject = projects.find((p) => p.id === activeProjectId) const activeProject = projects.find((p) => p.id === activeProjectId)
const createInvite = useCreateMigrationInvite(activeProjectId) const createInvite = useCreateMigrationInvite(activeProjectId)
const importInvites = useImportMigrationInvites(activeProjectId) const importInvites = useImportMigrationInvites(activeProjectId)
const importRoster = useImportMigrationRoster(activeProjectId)
const rosterQuery = useMigrationRoster(activeProjectId, Boolean(activeProjectId))
const jobsQuery = useMigrationProjectJobs(activeProjectId, Boolean(activeProjectId)) const jobsQuery = useMigrationProjectJobs(activeProjectId, Boolean(activeProjectId))
const retryJob = useRetryMigrationJob(activeProjectId) const retryJob = useRetryMigrationJob(activeProjectId)
const resetCursor = useResetMigrationJobCursor(activeProjectId) const resetCursor = useResetMigrationJobCursor(activeProjectId)
const retryFailed = useRetryMigrationFailedJobs(activeProjectId) const retryFailed = useRetryMigrationFailedJobs(activeProjectId)
const preflightDNS = usePreflightCutoverDNS(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(() => { useEffect(() => {
const consent = params.get("microsoft_admin_consent") const consent = params.get("microsoft_admin_consent")
@ -380,6 +403,19 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
)} )}
</div> </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="mt-8 space-y-4 rounded-lg border p-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-medium">Jobs de migration</h3> <h3 className="font-medium">Jobs de migration</h3>
@ -418,27 +454,54 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
<Button <Button
disabled={!inviteEmail} disabled={!inviteEmail}
onClick={() => { onClick={() => {
void createInvite.mutateAsync({ email: inviteEmail }).then((inv) => { const alternates = alternateEmails
setInviteEmail("") .split(/[\n,;]+/)
if (inv.token) { .map((e) => e.trim())
window.prompt( .filter(Boolean)
"Lien de claim (copier)", void createInvite
`${window.location.origin}/onboard/claim?token=${inv.token}` .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 Inviter
</Button> </Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="csv-emails">Import CSV (une adresse par ligne)</Label> <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 <textarea
id="csv-emails" id="csv-emails"
className="min-h-24 w-full rounded-md border bg-background px-3 py-2 text-sm" className="min-h-24 w-full rounded-md border bg-background px-3 py-2 text-sm"
value={csvEmails} value={csvEmails}
onChange={(e) => setCsvEmails(e.target.value)} onChange={(e) => setCsvEmails(e.target.value)}
placeholder={"alice@entreprise.com\nbob@entreprise.com"}
/> />
<Button <Button
variant="outline" variant="outline"
@ -451,9 +514,79 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
void importInvites.mutateAsync(emails).then(() => setCsvEmails("")) void importInvites.mutateAsync(emails).then(() => setCsvEmails(""))
}} }}
> >
Importer le roster Importer les emails
</Button> </Button>
</div> </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"> <p className="text-xs text-muted-foreground">
DNS : TXT <code>_ultisuite-verify.domaine</code>, MX vers Stalwart, SPF/DKIM/DMARC via DNS : TXT <code>_ultisuite-verify.domaine</code>, MX vers Stalwart, SPF/DKIM/DMARC via
Stalwart webadmin. Stalwart webadmin.
@ -465,6 +598,125 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
) )
} }
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 }) { function DNSReportCard({ report }: { report: DNSCheckReport }) {
return ( return (
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
@ -714,9 +966,29 @@ function JobAuditPanel({
{SERVICE_LABELS[job?.service ?? ""] ?? job?.service} · statut job {job?.status ?? "—"} {SERVICE_LABELS[job?.service ?? ""] ?? job?.service} · statut job {job?.status ?? "—"}
</p> </p>
</div> </div>
{summaryQuery.isLoading && ( <div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">Chargement</span> <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> </div>
{summary && ( {summary && (

View File

@ -25,6 +25,7 @@ export type MigrationProject = {
status: string status: string
cutover_at?: string | null cutover_at?: string | null
delta_mode: boolean delta_mode: boolean
shared_drive_mode?: "auto" | "manual"
created_at: string created_at: string
microsoft_tenant_id?: string microsoft_tenant_id?: string
microsoft_admin_consent_at?: string | null microsoft_admin_consent_at?: string | null
@ -32,6 +33,17 @@ export type MigrationProject = {
cutover_dns?: DNSCheckReport cutover_dns?: DNSCheckReport
} }
export type MigrationSharedDrive = {
id: string
project_id: string
drive_id: string
name: string
status: "pending" | "approved" | "rejected"
discovered_by_user_id?: string | null
created_at: string
updated_at: string
}
export type MicrosoftAdminConsent = { export type MicrosoftAdminConsent = {
tenant_id: string tenant_id: string
client_id: string client_id: string
@ -104,6 +116,24 @@ export type MigrationInvite = {
status: string status: string
} }
export type MigrationRosterEntry = {
id: string
project_id: string
email: string
display_name?: string
alternate_emails?: string[]
status: "pending" | "invited" | "claimed"
invite_id?: string
created_at: string
updated_at: string
}
export type MigrationRosterImportResult = {
created: number
skipped_duplicates: number
errors?: Array<{ row: number; email?: string; message: string }>
}
export type DNSCheckReport = { export type DNSCheckReport = {
domain?: string domain?: string
txt_verified: boolean txt_verified: boolean
@ -237,6 +267,34 @@ export function useImportMigrationInvites(projectId: string) {
}) })
} }
export function useMigrationRoster(projectId: string, enabled = true) {
return useQuery({
queryKey: ["admin", "migration", "projects", projectId, "roster"],
enabled: Boolean(projectId) && enabled,
queryFn: () =>
apiClient.get<{ roster: MigrationRosterEntry[] }>(
`/admin/migration/projects/${projectId}/roster`
),
})
}
export function useImportMigrationRoster(projectId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (csv: string) =>
apiClient.post<MigrationRosterImportResult>(
`/admin/migration/projects/${projectId}/roster`,
{ csv }
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["admin", "migration", "projects", projectId, "roster"],
})
},
meta: { requiresProjectId: projectId },
})
}
export function useMigrationProjectJobs(projectId: string, enabled = true) { export function useMigrationProjectJobs(projectId: string, enabled = true) {
return useQuery({ return useQuery({
queryKey: ["admin", "migration", "projects", projectId, "jobs"], queryKey: ["admin", "migration", "projects", projectId, "jobs"],
@ -281,6 +339,96 @@ export function useMigrationJobAudit(
}) })
} }
export async function downloadMigrationJobAudit(
projectId: string,
jobId: string,
format: "csv" | "ndjson"
) {
const blob = await apiClient.getBlob(
`/admin/migration/projects/${projectId}/jobs/${jobId}/audit/export?format=${format}`
)
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = `migration-job-audit-${jobId.slice(0, 8)}.${format === "csv" ? "csv" : "ndjson"}`
anchor.click()
URL.revokeObjectURL(url)
}
export async function downloadMigrationProjectAudit(
projectId: string,
format: "csv" | "ndjson"
) {
const blob = await apiClient.getBlob(
`/admin/migration/projects/${projectId}/audit/export?format=${format}`
)
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = `migration-project-audit-${projectId.slice(0, 8)}.${format === "csv" ? "csv" : "ndjson"}`
anchor.click()
URL.revokeObjectURL(url)
}
export function useUpdateMigrationSharedDriveMode(projectId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (shared_drive_mode: "auto" | "manual") =>
apiClient.patch<MigrationProject>(
`/admin/migration/projects/${projectId}/shared-drive-mode`,
{ shared_drive_mode }
),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["admin", "migration", "projects"] })
void queryClient.invalidateQueries({
queryKey: ["admin", "migration", "projects", projectId, "shared-drives"],
})
},
})
}
export function useMigrationSharedDrives(projectId: string, enabled = true) {
return useQuery({
queryKey: ["admin", "migration", "projects", projectId, "shared-drives"],
enabled: Boolean(projectId) && enabled,
queryFn: () =>
apiClient.get<{ shared_drives: MigrationSharedDrive[] }>(
`/admin/migration/projects/${projectId}/shared-drives`
),
refetchInterval: 15_000,
})
}
export function useApproveMigrationSharedDrive(projectId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (driveId: string) =>
apiClient.post<MigrationSharedDrive>(
`/admin/migration/projects/${projectId}/shared-drives/${encodeURIComponent(driveId)}/approve`
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["admin", "migration", "projects", projectId, "shared-drives"],
})
},
})
}
export function useRejectMigrationSharedDrive(projectId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (driveId: string) =>
apiClient.post<MigrationSharedDrive>(
`/admin/migration/projects/${projectId}/shared-drives/${encodeURIComponent(driveId)}/reject`
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["admin", "migration", "projects", projectId, "shared-drives"],
})
},
})
}
export function useRetryMigrationJob(projectId: string) { export function useRetryMigrationJob(projectId: string) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({

File diff suppressed because one or more lines are too long