ultisuite-client/app/onboard/migration/page.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

233 lines
7.6 KiB
TypeScript

"use client"
import { Suspense, useEffect } from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import {
MigrationOnboardingAlerts,
MigrationStepList,
appOnlyAuthStepLabel,
shouldShowOAuthButton,
usesAppOnlyAuth,
} from "@/components/migration/onboarding-ui"
import {
type MigrationJob,
useMigrationStatus,
useStartMigrationOAuth,
} from "@/lib/api/hooks/use-hosted-mail"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { buildOidcLoginUrl } from "@/lib/auth/login-url"
const SERVICE_LABELS: Record<string, string> = {
mail: "Mail",
contacts: "Contacts",
calendar: "Agenda",
drive: "Drive",
}
function jobProgress(job: MigrationJob): number {
const imported = job.stats_json?.imported ?? 0
const total = job.stats_json?.estimated_total ?? imported
if (typeof total === "number" && total <= 0) {
return job.status === "completed" ? 100 : 10
}
if (typeof imported !== "number" || typeof total !== "number") {
return job.status === "completed" ? 100 : 10
}
return Math.min(100, Math.round((imported / total) * 100))
}
export default function OnboardMigrationPage() {
return (
<Suspense
fallback={
<main className="mx-auto max-w-lg px-4 py-16">
<p className="text-muted-foreground">Chargement</p>
</main>
}
>
<OnboardMigrationContent />
</Suspense>
)
}
function OnboardMigrationContent() {
const params = useSearchParams()
const router = useRouter()
const token = params.get("token")
const oauth = params.get("oauth")
const { authenticated } = useAuthReady()
const statusQuery = useMigrationStatus()
const oauthMutation = useStartMigrationOAuth()
const data = statusQuery.data
const project = data?.project
const onboarding = data?.onboarding
const jobs = data?.jobs ?? []
useEffect(() => {
if (!statusQuery.data) return
const interval = setInterval(() => {
void statusQuery.refetch()
}, 5000)
return () => clearInterval(interval)
}, [statusQuery])
useEffect(() => {
if (!authenticated || !token) return
if (data?.invite?.status === "invited") {
router.replace(`/onboard/claim?token=${encodeURIComponent(token)}`)
}
}, [authenticated, token, data?.invite?.status, router])
if (!authenticated) {
const returnTo = token
? `/onboard/migration?token=${encodeURIComponent(token)}`
: "/onboard/migration"
return (
<main className="mx-auto max-w-lg px-4 py-16">
<p className="text-muted-foreground">Connectez-vous pour suivre votre migration.</p>
<Button asChild className="mt-4">
<Link href={buildOidcLoginUrl({ returnTo })}>Se connecter</Link>
</Button>
</main>
)
}
const claimed = data?.invite?.status === "claimed"
const oauthDone = Boolean(onboarding?.has_migration_credentials)
const importRunning =
claimed &&
!onboarding?.waiting_for_admin &&
(oauthDone || usesAppOnlyAuth(project) || jobs.some((j) => j.status === "running"))
const steps = [
{ label: "Compte revendiqué", done: claimed, current: !claimed },
{
label: "Projet activé par l'admin",
done: claimed && !onboarding?.waiting_for_admin,
current: claimed && Boolean(onboarding?.waiting_for_admin),
},
{
label: usesAppOnlyAuth(project)
? appOnlyAuthStepLabel(project)
: "Autorisation Google / Microsoft",
done: usesAppOnlyAuth(project)
? !onboarding?.waiting_for_admin && claimed
: oauthDone,
current:
claimed &&
!onboarding?.waiting_for_admin &&
!usesAppOnlyAuth(project) &&
!oauthDone,
},
{
label: "Import en cours",
done: jobs.length > 0 && jobs.every((j) => j.status === "completed"),
current: importRunning,
},
]
const showGoogleOAuth = shouldShowOAuthButton(project, onboarding, "google")
const showMicrosoftOAuth = shouldShowOAuthButton(project, onboarding, "microsoft")
return (
<main className="mx-auto max-w-2xl px-4 py-16">
<h1 className="text-2xl font-semibold">Migration en cours</h1>
<p className="mt-2 text-muted-foreground">
{project?.name ?? "Votre migration"} statut {project?.status ?? "…"}
{project?.delta_mode ? " (sync delta post-bascule MX)" : ""}
</p>
<div className="mt-6">
<MigrationStepList steps={steps} />
</div>
<MigrationOnboardingAlerts project={project} onboarding={onboarding} />
{oauth === "success" && (
<p className="mt-4 rounded-md bg-green-500/10 px-3 py-2 text-sm text-green-700">
Autorisation enregistrée. L&apos;import démarre en arrière-plan.
</p>
)}
{oauth === "error" && (
<p className="mt-4 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
Échec de l&apos;autorisation OAuth. Réessayez ci-dessous.
</p>
)}
<div className="mt-8 space-y-6">
{jobs.length === 0 ? (
<p className="text-sm text-muted-foreground">
{onboarding?.waiting_for_admin
? "Les jobs seront créés après activation du projet par votre administrateur."
: "Aucun job de migration actif pour le moment."}
</p>
) : (
jobs.map((job) => (
<div key={job.service} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{SERVICE_LABELS[job.service] ?? job.service}</span>
<span className="text-muted-foreground">{job.status}</span>
</div>
<Progress value={jobProgress(job)} />
{typeof job.stats_json?.imported === "number" && (
<p className="text-xs text-muted-foreground">
{Math.round(job.stats_json.imported)} éléments importés
{typeof job.stats_json.estimated_total === "number"
? ` / ~${Math.round(job.stats_json.estimated_total)}`
: ""}
</p>
)}
{job.error && <p className="text-xs text-destructive">{job.error}</p>}
</div>
))
)}
</div>
<div className="mt-8 flex flex-wrap gap-3">
{showGoogleOAuth && (
<Button
variant="outline"
disabled={oauthMutation.isPending}
onClick={() => {
void oauthMutation
.mutateAsync({ provider: "google", invite_token: token ?? undefined })
.then((res) => {
window.location.href = res.auth_url
})
}}
>
Autoriser Google
</Button>
)}
{showMicrosoftOAuth && (
<Button
variant="outline"
disabled={oauthMutation.isPending}
onClick={() => {
void oauthMutation
.mutateAsync({ provider: "microsoft", invite_token: token ?? undefined })
.then((res) => {
window.location.href = res.auth_url
})
}}
>
Autoriser Microsoft
</Button>
)}
<Button asChild variant="secondary">
<a href="/auth/if/flow/ulti-post-migration-security/" target="_blank" rel="noreferrer">
Sécuriser le compte (WebAuthn / TOTP)
</a>
</Button>
<Button asChild>
<Link href="/mail/inbox">Ouvrir Ultimail</Link>
</Button>
</div>
</main>
)
}