feat(migration): enhance migration projects panel with shared drive management and roster import
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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:
parent
6c7278a3aa
commit
4d31ac294b
@ -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,8 +454,18 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
|
|||||||
<Button
|
<Button
|
||||||
disabled={!inviteEmail}
|
disabled={!inviteEmail}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void createInvite.mutateAsync({ email: inviteEmail }).then((inv) => {
|
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("")
|
setInviteEmail("")
|
||||||
|
setAlternateEmails("")
|
||||||
if (inv.token) {
|
if (inv.token) {
|
||||||
window.prompt(
|
window.prompt(
|
||||||
"Lien de claim (copier)",
|
"Lien de claim (copier)",
|
||||||
@ -433,12 +479,29 @@ export function MigrationProjectsPanel({ domains }: { domains: MailDomain[] }) {
|
|||||||
</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'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
|
<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'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 }) {
|
function DNSReportCard({ report }: { report: DNSCheckReport }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
@ -714,10 +966,30 @@ 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>
|
||||||
|
<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 && (
|
{summaryQuery.isLoading && (
|
||||||
<span className="text-xs text-muted-foreground">Chargement…</span>
|
<span className="text-xs text-muted-foreground">Chargement…</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user