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.
233 lines
7.6 KiB
TypeScript
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 { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
|
|
|
|
const SERVICE_LABELS: Record<string, string> = {
|
|
mail: "Mail",
|
|
contacts: "Contacts",
|
|
calendar: ULTICAL_APP_NAME,
|
|
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'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'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>
|
|
)
|
|
}
|