feat(onboarding): implement claim and migration pages with OAuth support
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
This commit is contained in:
parent
ad1370ea7e
commit
6c7278a3aa
174
app/onboard/claim/page.tsx
Normal file
174
app/onboard/claim/page.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { MigrationStepList, appOnlyAuthStepLabel } from "@/components/migration/onboarding-ui"
|
||||
import { ApiRequestError } from "@/lib/api/client"
|
||||
import { useClaimMigration, useMigrationInvite } from "@/lib/api/hooks/use-hosted-mail"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
import { buildOidcLoginUrl } from "@/lib/auth/login-url"
|
||||
|
||||
export default function OnboardClaimPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="mx-auto max-w-lg px-4 py-16">
|
||||
<p className="text-muted-foreground">Chargement…</p>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<OnboardClaimContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function claimErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiRequestError) {
|
||||
switch (error.code) {
|
||||
case "email_mismatch":
|
||||
return "L'adresse de connexion ne correspond pas à l'invitation. Utilisez le compte invité ou contactez votre administrateur pour ajouter une adresse SSO alternative."
|
||||
case "invite_already_claimed":
|
||||
return "Cette invitation a déjà été revendiquée."
|
||||
case "invite_not_found":
|
||||
return "Invitation introuvable ou expirée."
|
||||
case "migration_domain_not_active":
|
||||
return "Le domaine mail du projet n'est pas encore actif — contactez votre administrateur."
|
||||
case "migration_domain_mismatch":
|
||||
return "L'adresse invitée ne correspond pas au domaine mail du projet."
|
||||
}
|
||||
return error.message
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return "Échec de la revendication — vérifiez que vous êtes connecté avec la bonne adresse."
|
||||
}
|
||||
|
||||
function OnboardClaimContent() {
|
||||
const params = useSearchParams()
|
||||
const router = useRouter()
|
||||
const token = params.get("token")
|
||||
const { authenticated } = useAuthReady()
|
||||
const inviteQuery = useMigrationInvite(token)
|
||||
const claimMutation = useClaimMigration()
|
||||
const [password, setPassword] = useState("")
|
||||
|
||||
const loginHref = useMemo(() => {
|
||||
const returnTo = token ? `/onboard/claim?token=${encodeURIComponent(token)}` : "/onboard/claim"
|
||||
return buildOidcLoginUrl({ returnTo })
|
||||
}, [token])
|
||||
|
||||
const invite = inviteQuery.data?.invite
|
||||
const project = inviteQuery.data?.project
|
||||
const alreadyClaimed = invite?.status === "claimed"
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !alreadyClaimed) return
|
||||
router.replace(`/onboard/migration?token=${encodeURIComponent(token)}`)
|
||||
}, [token, alreadyClaimed, router])
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<main className="mx-auto max-w-lg px-4 py-16">
|
||||
<h1 className="text-2xl font-semibold">Lien d'invitation invalide</h1>
|
||||
<p className="mt-2 text-muted-foreground">Ce lien ne contient pas de jeton de migration.</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<main className="mx-auto max-w-lg px-4 py-16">
|
||||
<h1 className="text-2xl font-semibold">Revendiquer votre compte</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Connectez-vous avec le même compte que l'adresse invitée (
|
||||
{inviteQuery.data?.invite.email ?? "…"}) pour provisionner votre boîte Ultimail.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<MigrationStepList
|
||||
steps={[
|
||||
{ label: "Connexion (Google / Microsoft / Ultimail)", done: false, current: true },
|
||||
{ label: "Revendiquer la boîte mail", done: false, current: false },
|
||||
{ label: "Autoriser l'import des données", done: false, current: false },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Button asChild className="mt-6">
|
||||
<Link href={loginHref}>Se connecter pour continuer</Link>
|
||||
</Button>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (alreadyClaimed) {
|
||||
return (
|
||||
<main className="mx-auto max-w-lg px-4 py-16">
|
||||
<p className="text-muted-foreground">Redirection vers le suivi de migration…</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ label: "Connexion", done: true, current: false },
|
||||
{ label: "Revendiquer la boîte mail", done: false, current: true },
|
||||
{
|
||||
label: appOnlyAuthStepLabel(project),
|
||||
done: false,
|
||||
current: false,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg px-4 py-16">
|
||||
<h1 className="text-2xl font-semibold">Revendiquer votre compte</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Projet {project?.name ?? ""} — {invite?.email}
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<MigrationStepList steps={steps} />
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
Connectez-vous avec le compte correspondant à l'invitation ({invite?.email}). Les
|
||||
alias SSO reconnus (UPN, adresse alternative) sont acceptés si configurés par
|
||||
l'administrateur.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mailbox-password">Mot de passe boîte mail (optionnel)</Label>
|
||||
<Input
|
||||
id="mailbox-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Pour IMAP/SMTP hébergé"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{claimMutation.isError && (
|
||||
<p className="text-sm text-destructive">{claimErrorMessage(claimMutation.error)}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={claimMutation.isPending}
|
||||
onClick={() => {
|
||||
void claimMutation
|
||||
.mutateAsync({ token, password: password || undefined })
|
||||
.then(() => {
|
||||
window.location.href = `/onboard/migration?token=${encodeURIComponent(token)}`
|
||||
})
|
||||
}}
|
||||
>
|
||||
Revendiquer mon compte
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
232
app/onboard/migration/page.tsx
Normal file
232
app/onboard/migration/page.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
"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'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>
|
||||
)
|
||||
}
|
||||
@ -18,6 +18,7 @@ import { SearchSection } from "@/components/admin/settings/sections/search-secti
|
||||
import { PluginsSection } from "@/components/admin/settings/sections/plugins-section"
|
||||
import { NextcloudSection } from "@/components/admin/settings/sections/nextcloud-section"
|
||||
import { MailingSection } from "@/components/admin/settings/sections/mailing-section"
|
||||
import { MailDomainsSection } from "@/components/admin/settings/sections/mail-domains-section"
|
||||
import { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section"
|
||||
import { RichtextSection } from "@/components/admin/settings/sections/richtext-section"
|
||||
import { AiAssistantSection } from "@/components/admin/settings/sections/ai-assistant-section"
|
||||
@ -41,6 +42,7 @@ const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
|
||||
agenda: AgendaSection,
|
||||
ultimeet: UltimeetSection,
|
||||
mailing: MailingSection,
|
||||
"mail-domains": MailDomainsSection,
|
||||
onlyoffice: OnlyofficeSection,
|
||||
richtext: RichtextSection,
|
||||
"ai-assistant": AiAssistantSection,
|
||||
|
||||
103
components/admin/settings/sections/mail-domains-section.tsx
Normal file
103
components/admin/settings/sections/mail-domains-section.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||
import { MigrationProjectsPanel } from "@/components/admin/settings/sections/migration-projects-panel"
|
||||
import {
|
||||
useCreateMailDomain,
|
||||
useMailDomains,
|
||||
useVerifyMailDomainMX,
|
||||
useVerifyMailDomainTXT,
|
||||
} from "@/lib/api/hooks/use-hosted-mail"
|
||||
|
||||
export function MailDomainsSection() {
|
||||
const domainsQuery = useMailDomains()
|
||||
const createDomain = useCreateMailDomain()
|
||||
const [domainName, setDomainName] = useState("")
|
||||
|
||||
const domains = domainsQuery.data?.domains ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<OrgSettingsSection
|
||||
title="Domaines mail hébergés"
|
||||
description="Stalwart — vérification DNS, DKIM et provisioning des boîtes @domaine."
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_auto]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-domain">Nouveau domaine</Label>
|
||||
<Input
|
||||
id="new-domain"
|
||||
value={domainName}
|
||||
onChange={(e) => setDomainName(e.target.value)}
|
||||
placeholder="entreprise.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
disabled={!domainName || createDomain.isPending}
|
||||
onClick={() => {
|
||||
void createDomain.mutateAsync({ name: domainName }).then(() => setDomainName(""))
|
||||
}}
|
||||
>
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-6 space-y-3">
|
||||
{domains.map((domain) => (
|
||||
<DomainRow key={domain.id} domain={domain} />
|
||||
))}
|
||||
</ul>
|
||||
</OrgSettingsSection>
|
||||
|
||||
<MigrationProjectsPanel domains={domains} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DomainRow({
|
||||
domain,
|
||||
}: {
|
||||
domain: {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
verification_token?: string
|
||||
is_platform_domain: boolean
|
||||
}
|
||||
}) {
|
||||
const verifyTxt = useVerifyMailDomainTXT(domain.id)
|
||||
const verifyMx = useVerifyMailDomainMX(domain.id)
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{domain.name}
|
||||
{domain.is_platform_domain ? " (plateforme)" : ""}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Statut : {domain.status}</p>
|
||||
{domain.verification_token && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
TXT : <code>_ultisuite-verify.{domain.name}</code> = {domain.verification_token}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => void verifyTxt.mutateAsync()}>
|
||||
Vérifier TXT
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => void verifyMx.mutateAsync()}>
|
||||
Vérifier MX
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
796
components/admin/settings/sections/migration-projects-panel.tsx
Normal file
796
components/admin/settings/sections/migration-projects-panel.tsx
Normal file
@ -0,0 +1,796 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "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 {
|
||||
type DNSCheckReport,
|
||||
type MailDomain,
|
||||
type MigrationJob,
|
||||
type MigrationJobAuditItem,
|
||||
type MigrationProject,
|
||||
useActivateMigrationProject,
|
||||
useCreateMigrationInvite,
|
||||
useCreateMigrationProject,
|
||||
useCutoverMigrationProject,
|
||||
useImportMigrationInvites,
|
||||
useMicrosoftMigrationAdminConsentURL,
|
||||
useMigrationJobAudit,
|
||||
useMigrationJobAuditSummary,
|
||||
useMigrationProjectJobs,
|
||||
useMigrationProjects,
|
||||
usePreflightCutoverDNS,
|
||||
useResetMigrationJobCursor,
|
||||
useRetryMigrationFailedJobs,
|
||||
useRetryMigrationJob,
|
||||
} from "@/lib/api/hooks/use-hosted-mail"
|
||||
import { ApiRequestError } from "@/lib/api/client"
|
||||
|
||||
const SERVICE_LABELS: Record<string, string> = {
|
||||
mail: "Mail",
|
||||
contacts: "Contacts",
|
||||
calendar: "Agenda",
|
||||
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":
|
||||
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 [csvEmails, setCsvEmails] = useState("")
|
||||
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 jobsQuery = useMigrationProjectJobs(activeProjectId, Boolean(activeProjectId))
|
||||
const retryJob = useRetryMigrationJob(activeProjectId)
|
||||
const resetCursor = useResetMigrationJobCursor(activeProjectId)
|
||||
const retryFailed = useRetryMigrationFailedJobs(activeProjectId)
|
||||
const preflightDNS = usePreflightCutoverDNS(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>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name">Nom du projet</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="Migration ACME 2026"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Source</Label>
|
||||
<Select
|
||||
value={sourceProvider}
|
||||
onValueChange={(value) => {
|
||||
setSourceProvider(value)
|
||||
if (value !== "google" && authMode === "google_dwd") {
|
||||
setAuthMode("oauth")
|
||||
}
|
||||
if (value !== "microsoft" && authMode === "microsoft_app") {
|
||||
setAuthMode("oauth")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google">Google Workspace</SelectItem>
|
||||
<SelectItem value="microsoft">Microsoft 365</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Mode d'authentification</Label>
|
||||
<Select value={authMode} onValueChange={setAuthMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="oauth">OAuth utilisateur</SelectItem>
|
||||
<SelectItem value="google_dwd" disabled={sourceProvider !== "google"}>
|
||||
Google DWD (service account)
|
||||
</SelectItem>
|
||||
<SelectItem value="microsoft_app" disabled={sourceProvider !== "microsoft"}>
|
||||
Microsoft app-only (client credentials)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Domaine mail (optionnel)</Label>
|
||||
<Select value={domainId || "__none__"} onValueChange={(v) => setDomainId(v === "__none__" ? "" : v)}>
|
||||
<SelectTrigger>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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 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>
|
||||
|
||||
<div className="mt-8 space-y-4 rounded-lg 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 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={() => {
|
||||
void createInvite.mutateAsync({ email: inviteEmail }).then((inv) => {
|
||||
setInviteEmail("")
|
||||
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="csv-emails">Import CSV (une adresse par 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)}
|
||||
/>
|
||||
<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 le roster
|
||||
</Button>
|
||||
</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 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>
|
||||
{summaryQuery.isLoading && (
|
||||
<span className="text-xs text-muted-foreground">Chargement…</span>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
146
components/migration/onboarding-ui.tsx
Normal file
146
components/migration/onboarding-ui.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
"use client"
|
||||
|
||||
import type { MigrationProject } from "@/lib/api/hooks/use-hosted-mail"
|
||||
|
||||
export type MigrationOnboardingHints = {
|
||||
needs_user_oauth: boolean
|
||||
oauth_provider?: string
|
||||
waiting_for_admin: boolean
|
||||
waiting_reason?: string
|
||||
has_migration_credentials?: boolean
|
||||
needs_microsoft_admin_consent?: boolean
|
||||
}
|
||||
|
||||
const WAITING_MESSAGES: Record<string, string> = {
|
||||
project_not_activated:
|
||||
"Votre administrateur doit activer le projet de migration avant que l'import ne démarre. Vos jobs resteront en attente jusqu'à cette activation.",
|
||||
}
|
||||
|
||||
export function usesAppOnlyAuth(project?: Pick<MigrationProject, "auth_mode">): boolean {
|
||||
return project?.auth_mode === "google_dwd" || project?.auth_mode === "microsoft_app"
|
||||
}
|
||||
|
||||
export function appOnlyAuthStepLabel(project?: MigrationProject): string {
|
||||
if (project?.auth_mode === "microsoft_app") {
|
||||
return "Import automatique (app Microsoft)"
|
||||
}
|
||||
if (project?.auth_mode === "google_dwd") {
|
||||
return "Import automatique (compte de service)"
|
||||
}
|
||||
return "Autoriser l'import Google / Microsoft"
|
||||
}
|
||||
|
||||
export function waitingForAdminMessage(reason?: string): string {
|
||||
if (!reason) {
|
||||
return "En attente d'une action administrateur avant le démarrage de l'import."
|
||||
}
|
||||
return WAITING_MESSAGES[reason] ?? "En attente d'une action administrateur."
|
||||
}
|
||||
|
||||
export function MigrationStepList({
|
||||
steps,
|
||||
}: {
|
||||
steps: { label: string; done: boolean; current: boolean }[]
|
||||
}) {
|
||||
return (
|
||||
<ol className="space-y-2 text-sm">
|
||||
{steps.map((step) => (
|
||||
<li
|
||||
key={step.label}
|
||||
className={`flex items-center gap-2 rounded-md px-3 py-2 ${
|
||||
step.current ? "bg-primary/10 font-medium" : step.done ? "text-muted-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs ${
|
||||
step.done
|
||||
? "bg-green-600 text-white"
|
||||
: step.current
|
||||
? "border-2 border-primary text-primary"
|
||||
: "border border-muted-foreground/40 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{step.done ? "✓" : "·"}
|
||||
</span>
|
||||
{step.label}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
export function MigrationOnboardingAlerts({
|
||||
project,
|
||||
onboarding,
|
||||
}: {
|
||||
project?: MigrationProject
|
||||
onboarding?: MigrationOnboardingHints
|
||||
}) {
|
||||
if (!onboarding) return null
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
{onboarding.waiting_for_admin && (
|
||||
<p className="rounded-md bg-amber-500/10 px-3 py-2 text-sm text-amber-800">
|
||||
{waitingForAdminMessage(onboarding.waiting_reason)}
|
||||
</p>
|
||||
)}
|
||||
{usesAppOnlyAuth(project) && !onboarding.waiting_for_admin && (
|
||||
<p className="rounded-md bg-blue-500/10 px-3 py-2 text-sm text-blue-800">
|
||||
{project?.auth_mode === "microsoft_app" ? (
|
||||
<>
|
||||
Import Microsoft 365 configuré par votre administrateur (authentification app-only).
|
||||
Aucune autorisation OAuth personnelle requise — l'import démarre automatiquement
|
||||
une fois le projet actif et le consentement admin accordé.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Import Google configuré par votre administrateur (compte de service). Aucune autorisation
|
||||
OAuth personnelle requise — l'import démarre automatiquement une fois le projet
|
||||
actif.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{onboarding.needs_microsoft_admin_consent && project?.source_provider === "microsoft" && (
|
||||
<p className="rounded-md bg-amber-500/10 px-3 py-2 text-sm text-amber-800">
|
||||
L'administrateur de votre organisation Microsoft 365 doit accorder le consentement
|
||||
admin à l'application Ultimail
|
||||
{project?.auth_mode === "microsoft_app"
|
||||
? " (permissions application : Mail, Calendrier, Contacts, OneDrive)."
|
||||
: "."}{" "}
|
||||
{project?.auth_mode !== "microsoft_app" &&
|
||||
"Vous pouvez autoriser votre compte personnel ci-dessous, mais l'import organisationnel peut échouer tant que ce consentement n'est pas fait."}
|
||||
</p>
|
||||
)}
|
||||
{onboarding.needs_user_oauth &&
|
||||
!onboarding.has_migration_credentials &&
|
||||
!onboarding.waiting_for_admin &&
|
||||
!usesAppOnlyAuth(project) && (
|
||||
<p className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
|
||||
Étape suivante : autorisez l'accès à votre compte{" "}
|
||||
{project?.source_provider === "microsoft" ? "Microsoft 365" : "Google Workspace"} pour
|
||||
lancer l'import de vos données.
|
||||
</p>
|
||||
)}
|
||||
{onboarding.has_migration_credentials && !onboarding.waiting_for_admin && (
|
||||
<p className="rounded-md bg-green-500/10 px-3 py-2 text-sm text-green-700">
|
||||
Autorisation enregistrée — l'import progresse en arrière-plan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldShowOAuthButton(
|
||||
project: MigrationProject | undefined,
|
||||
onboarding: MigrationOnboardingHints | undefined,
|
||||
provider: "google" | "microsoft"
|
||||
): boolean {
|
||||
if (!project || !onboarding) return false
|
||||
if (usesAppOnlyAuth(project)) return false
|
||||
if (onboarding.waiting_for_admin) return false
|
||||
if (project.source_provider !== provider) return false
|
||||
if (onboarding.has_migration_credentials) return false
|
||||
return onboarding.needs_user_oauth
|
||||
}
|
||||
191
e2e/helpers/migration-onboarding.ts
Normal file
191
e2e/helpers/migration-onboarding.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
|
||||
export const E2E_INVITE_TOKEN = "e2e-onboard-invite-token"
|
||||
export const E2E_MIGRATEE_EMAIL = "alice@acme.com"
|
||||
export const E2E_PROJECT_ID = "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
export type MigrationMockState = {
|
||||
projectStatus?: "draft" | "active" | "cutover"
|
||||
authMode?: "oauth" | "google_dwd" | "microsoft_app"
|
||||
sourceProvider?: "google" | "microsoft"
|
||||
inviteStatus?: "invited" | "claimed"
|
||||
hasMigrationCredentials?: boolean
|
||||
waitingForAdmin?: boolean
|
||||
jobs?: Array<{
|
||||
service: string
|
||||
status: string
|
||||
stats_json?: Record<string, number>
|
||||
error?: string
|
||||
}>
|
||||
}
|
||||
|
||||
const DEFAULT_JOBS = [
|
||||
{ service: "mail", status: "pending", stats_json: { imported: 0 } },
|
||||
{ service: "contacts", status: "pending", stats_json: { imported: 0 } },
|
||||
{ service: "calendar", status: "pending", stats_json: { imported: 0 } },
|
||||
{ service: "drive", status: "pending", stats_json: { imported: 0 } },
|
||||
]
|
||||
|
||||
function buildProject(state: MigrationMockState) {
|
||||
return {
|
||||
id: E2E_PROJECT_ID,
|
||||
name: "Migration E2E ACME",
|
||||
source_provider: state.sourceProvider ?? "google",
|
||||
auth_mode: state.authMode ?? "oauth",
|
||||
status: state.projectStatus ?? "draft",
|
||||
delta_mode: state.projectStatus === "cutover",
|
||||
created_at: "2026-01-15T10:00:00Z",
|
||||
}
|
||||
}
|
||||
|
||||
function buildInvite(state: MigrationMockState) {
|
||||
return {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
project_id: E2E_PROJECT_ID,
|
||||
email: E2E_MIGRATEE_EMAIL,
|
||||
status: state.inviteStatus ?? "invited",
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOnboardingHints(state: MigrationMockState) {
|
||||
const projectStatus = state.projectStatus ?? "draft"
|
||||
const waiting =
|
||||
state.waitingForAdmin ??
|
||||
(projectStatus !== "active" && projectStatus !== "cutover")
|
||||
const authMode = state.authMode ?? "oauth"
|
||||
const sourceProvider = state.sourceProvider ?? "google"
|
||||
return {
|
||||
needs_user_oauth: authMode !== "google_dwd" && authMode !== "microsoft_app",
|
||||
oauth_provider: sourceProvider,
|
||||
waiting_for_admin: waiting,
|
||||
waiting_reason: waiting ? "project_not_activated" : undefined,
|
||||
has_migration_credentials: state.hasMigrationCredentials ?? false,
|
||||
needs_microsoft_admin_consent: sourceProvider === "microsoft",
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockAuthSession(
|
||||
page: Page,
|
||||
opts: { authenticated: boolean; email?: string; name?: string } = {
|
||||
authenticated: true,
|
||||
}
|
||||
) {
|
||||
await page.route("**/api/auth/session", async (route) => {
|
||||
if (!opts.authenticated) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ authenticated: false }),
|
||||
})
|
||||
return
|
||||
}
|
||||
const email = opts.email ?? E2E_MIGRATEE_EMAIL
|
||||
const name = opts.name ?? "Alice Test"
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
authenticated: true,
|
||||
accessToken: "e2e-access-token",
|
||||
refreshToken: "e2e-refresh-token",
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
user: {
|
||||
sub: "e2e-user-sub",
|
||||
email,
|
||||
name,
|
||||
firstName: name.split(/\s+/)[0] ?? name,
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function installMigrationApiMocks(
|
||||
page: Page,
|
||||
state: MigrationMockState = {}
|
||||
) {
|
||||
let inviteClaimed = (state.inviteStatus ?? "invited") === "claimed"
|
||||
|
||||
const snapshot = () => {
|
||||
const inviteStatus = inviteClaimed ? "claimed" : "invited"
|
||||
const merged = { ...state, inviteStatus: inviteStatus as "invited" | "claimed" }
|
||||
return {
|
||||
project: buildProject(merged),
|
||||
invite: buildInvite(merged),
|
||||
onboarding: buildOnboardingHints(merged),
|
||||
jobs: state.jobs ?? DEFAULT_JOBS,
|
||||
}
|
||||
}
|
||||
|
||||
await page.route("**/api/v1/migration/invite**", async (route) => {
|
||||
const body = snapshot()
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
invite: body.invite,
|
||||
project: body.project,
|
||||
onboarding: body.onboarding,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route("**/api/v1/migration/status**", async (route) => {
|
||||
const body = snapshot()
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route("**/api/v1/migration/claim**", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
inviteClaimed = true
|
||||
const body = snapshot()
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route("**/api/v1/migration/oauth/start**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
auth_url: `/onboard/migration?token=${E2E_INVITE_TOKEN}&oauth=success`,
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function gotoClaimPage(page: Page, token = E2E_INVITE_TOKEN) {
|
||||
await page.goto(`/onboard/claim?token=${encodeURIComponent(token)}`)
|
||||
await page.waitForResponse((res) => res.url().includes("/migration/invite"))
|
||||
}
|
||||
|
||||
export async function gotoMigrationPage(page: Page, token = E2E_INVITE_TOKEN) {
|
||||
await page.goto(`/onboard/migration?token=${encodeURIComponent(token)}`)
|
||||
}
|
||||
|
||||
export async function expectClaimAuthenticatedStep(page: Page) {
|
||||
await expect(page.getByRole("heading", { name: "Revendiquer votre compte" })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Revendiquer mon compte" })).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(/L'email de connexion doit correspondre exactement/)
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
export async function submitClaim(page: Page) {
|
||||
const claimResponse = page.waitForResponse(
|
||||
(res) => res.url().includes("/migration/claim") && res.request().method() === "POST"
|
||||
)
|
||||
await page.getByRole("button", { name: "Revendiquer mon compte" }).click()
|
||||
await claimResponse
|
||||
await page.waitForURL(new RegExp(`/onboard/migration\\?token=${E2E_INVITE_TOKEN}`))
|
||||
}
|
||||
158
e2e/onboard-migration.spec.ts
Normal file
158
e2e/onboard-migration.spec.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
import {
|
||||
E2E_INVITE_TOKEN,
|
||||
E2E_MIGRATEE_EMAIL,
|
||||
expectClaimAuthenticatedStep,
|
||||
gotoClaimPage,
|
||||
gotoMigrationPage,
|
||||
installMigrationApiMocks,
|
||||
mockAuthSession,
|
||||
submitClaim,
|
||||
} from "./helpers/migration-onboarding"
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.clear()
|
||||
window.sessionStorage.clear()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("onboarding migration (mocked API)", () => {
|
||||
test("claim page rejects invite link without token", async ({ page }) => {
|
||||
await page.goto("/onboard/claim")
|
||||
await expect(page.getByRole("heading", { name: "Lien d'invitation invalide" })).toBeVisible()
|
||||
})
|
||||
|
||||
test("claim page prompts login when session is missing", async ({ page }) => {
|
||||
await mockAuthSession(page, { authenticated: false })
|
||||
await installMigrationApiMocks(page)
|
||||
await gotoClaimPage(page)
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Revendiquer votre compte" })).toBeVisible()
|
||||
await expect(page.getByText(E2E_MIGRATEE_EMAIL)).toBeVisible()
|
||||
await expect(page.getByRole("link", { name: "Se connecter pour continuer" })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Revendiquer mon compte" })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("claim then migration journey for oauth google project", async ({ page }) => {
|
||||
await mockAuthSession(page)
|
||||
await installMigrationApiMocks(page, {
|
||||
projectStatus: "draft",
|
||||
authMode: "oauth",
|
||||
sourceProvider: "google",
|
||||
})
|
||||
await gotoClaimPage(page)
|
||||
await expectClaimAuthenticatedStep(page)
|
||||
await submitClaim(page)
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Migration en cours" })).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(/Votre administrateur doit activer le projet de migration/)
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Autoriser Google" })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Autoriser Microsoft" })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("migration shows oauth when project is active", async ({ page }) => {
|
||||
await mockAuthSession(page)
|
||||
await installMigrationApiMocks(page, {
|
||||
projectStatus: "active",
|
||||
inviteStatus: "claimed",
|
||||
authMode: "oauth",
|
||||
sourceProvider: "google",
|
||||
})
|
||||
await gotoMigrationPage(page)
|
||||
|
||||
await expect(page.getByText(/Étape suivante : autorisez l'accès/)).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Autoriser Google" })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Autoriser Microsoft" })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("migration hides oauth for google domain-wide delegation", async ({ page }) => {
|
||||
await mockAuthSession(page)
|
||||
await installMigrationApiMocks(page, {
|
||||
projectStatus: "active",
|
||||
inviteStatus: "claimed",
|
||||
authMode: "google_dwd",
|
||||
sourceProvider: "google",
|
||||
})
|
||||
await gotoMigrationPage(page)
|
||||
|
||||
await expect(
|
||||
page.getByText(/Aucune autorisation OAuth personnelle requise/)
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Autoriser Google" })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Autoriser Microsoft" })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("migration shows microsoft admin consent warning", async ({ page }) => {
|
||||
await mockAuthSession(page)
|
||||
await installMigrationApiMocks(page, {
|
||||
projectStatus: "active",
|
||||
inviteStatus: "claimed",
|
||||
sourceProvider: "microsoft",
|
||||
})
|
||||
await gotoMigrationPage(page)
|
||||
|
||||
await expect(page.getByText(/consentement admin/)).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Autoriser Microsoft" })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Autoriser Google" })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("migration hides oauth after credentials are stored", async ({ page }) => {
|
||||
await mockAuthSession(page)
|
||||
await installMigrationApiMocks(page, {
|
||||
projectStatus: "active",
|
||||
inviteStatus: "claimed",
|
||||
hasMigrationCredentials: true,
|
||||
jobs: [
|
||||
{ service: "mail", status: "running", stats_json: { imported: 12, estimated_total: 100 } },
|
||||
{ service: "contacts", status: "pending", stats_json: { imported: 0 } },
|
||||
{ service: "calendar", status: "pending", stats_json: { imported: 0 } },
|
||||
{ service: "drive", status: "pending", stats_json: { imported: 0 } },
|
||||
],
|
||||
})
|
||||
await gotoMigrationPage(page)
|
||||
|
||||
await expect(page.getByText(/Autorisation enregistrée/)).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Autoriser Google" })).toHaveCount(0)
|
||||
await expect(page.getByText("12 éléments importés")).toBeVisible()
|
||||
})
|
||||
|
||||
test("migration shows oauth success banner from redirect", async ({ page }) => {
|
||||
await mockAuthSession(page)
|
||||
await installMigrationApiMocks(page, {
|
||||
projectStatus: "active",
|
||||
inviteStatus: "claimed",
|
||||
hasMigrationCredentials: true,
|
||||
})
|
||||
await page.goto(
|
||||
`/onboard/migration?token=${encodeURIComponent(E2E_INVITE_TOKEN)}&oauth=success`
|
||||
)
|
||||
|
||||
await expect(page.getByText(/Autorisation enregistrée. L'import démarre/)).toBeVisible()
|
||||
})
|
||||
|
||||
test("claimed invite redirects claim page to migration", async ({ page }) => {
|
||||
await mockAuthSession(page)
|
||||
await installMigrationApiMocks(page, {
|
||||
projectStatus: "active",
|
||||
inviteStatus: "claimed",
|
||||
})
|
||||
await gotoClaimPage(page)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/onboard/migration\\?token=${E2E_INVITE_TOKEN}`))
|
||||
await expect(page.getByRole("heading", { name: "Migration en cours" })).toBeVisible()
|
||||
})
|
||||
|
||||
test("unclaimed user on migration page returns to claim", async ({ page }) => {
|
||||
await mockAuthSession(page)
|
||||
await installMigrationApiMocks(page, {
|
||||
projectStatus: "draft",
|
||||
inviteStatus: "invited",
|
||||
})
|
||||
await gotoMigrationPage(page)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/onboard/claim\\?token=${E2E_INVITE_TOKEN}`))
|
||||
})
|
||||
})
|
||||
@ -10,6 +10,7 @@ import {
|
||||
Gauge,
|
||||
HardDrive,
|
||||
Link2,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
Puzzle,
|
||||
@ -34,6 +35,7 @@ export type AdminSettingsSectionId =
|
||||
| "plugins"
|
||||
| "nextcloud"
|
||||
| "mailing"
|
||||
| "mail-domains"
|
||||
| "onlyoffice"
|
||||
| "richtext"
|
||||
| "ai-assistant"
|
||||
@ -148,6 +150,13 @@ export const ADMIN_SETTINGS_NAV: AdminSettingsNavItem[] = [
|
||||
href: "/admin/settings/ultimeet",
|
||||
icon: Video,
|
||||
},
|
||||
{
|
||||
id: "mail-domains",
|
||||
label: "Domaines mail",
|
||||
description: "Hébergement Stalwart, DNS et migration",
|
||||
href: "/admin/settings/mail-domains",
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
id: "mailing",
|
||||
label: "Mailing unifié",
|
||||
|
||||
398
lib/api/hooks/use-hosted-mail.ts
Normal file
398
lib/api/hooks/use-hosted-mail.ts
Normal file
@ -0,0 +1,398 @@
|
||||
"use client"
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api/client"
|
||||
|
||||
export type MailDomain = {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
verification_token?: string
|
||||
dkim_selector?: string
|
||||
dkim_public_key?: string
|
||||
is_platform_domain: boolean
|
||||
mx_verified_at?: string | null
|
||||
txt_verified_at?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type MigrationProject = {
|
||||
id: string
|
||||
domain_id?: string
|
||||
name: string
|
||||
source_provider: string
|
||||
auth_mode: string
|
||||
status: string
|
||||
cutover_at?: string | null
|
||||
delta_mode: boolean
|
||||
created_at: string
|
||||
microsoft_tenant_id?: string
|
||||
microsoft_admin_consent_at?: string | null
|
||||
microsoft_admin_consent_error?: string
|
||||
cutover_dns?: DNSCheckReport
|
||||
}
|
||||
|
||||
export type MicrosoftAdminConsent = {
|
||||
tenant_id: string
|
||||
client_id: string
|
||||
project_id?: string
|
||||
granted: boolean
|
||||
error_code?: string
|
||||
error_description?: string
|
||||
consented_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type MigrationJob = {
|
||||
id: string
|
||||
project_id: string
|
||||
user_id: string
|
||||
service: string
|
||||
status: string
|
||||
stats_json?: Record<string, number | boolean | string>
|
||||
error?: string
|
||||
user_email?: string
|
||||
started_at?: string | null
|
||||
completed_at?: string | null
|
||||
}
|
||||
|
||||
export type MigrationJobAuditItem = {
|
||||
source_id: string
|
||||
rel_path?: string
|
||||
status: "imported" | "failed" | "skipped"
|
||||
reason?: string
|
||||
imported_at: string
|
||||
}
|
||||
|
||||
export type MigrationJobAuditSummary = {
|
||||
service: string
|
||||
imported: number
|
||||
failed: number
|
||||
skipped: number
|
||||
total: number
|
||||
by_status?: Record<string, number>
|
||||
}
|
||||
|
||||
export type MigrationOnboardingHints = {
|
||||
needs_user_oauth: boolean
|
||||
oauth_provider?: string
|
||||
waiting_for_admin: boolean
|
||||
waiting_reason?: string
|
||||
has_migration_credentials?: boolean
|
||||
needs_microsoft_admin_consent?: boolean
|
||||
}
|
||||
|
||||
export type MigrationUserStatus = {
|
||||
project?: MigrationProject
|
||||
invite?: MigrationInvite
|
||||
jobs?: MigrationJob[]
|
||||
onboarding?: MigrationOnboardingHints
|
||||
}
|
||||
|
||||
export type MigrationInviteResponse = {
|
||||
invite: MigrationInvite
|
||||
project: MigrationProject
|
||||
onboarding?: MigrationOnboardingHints
|
||||
}
|
||||
|
||||
export type MigrationInvite = {
|
||||
id: string
|
||||
project_id: string
|
||||
email: string
|
||||
alternate_emails?: string[]
|
||||
token?: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export type DNSCheckReport = {
|
||||
domain?: string
|
||||
txt_verified: boolean
|
||||
txt_records?: string[]
|
||||
txt_expected?: string
|
||||
mx_verified: boolean
|
||||
mx_records?: string[]
|
||||
expected_mx?: string[]
|
||||
warnings?: string[]
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
export type MigrationCutoverResult = {
|
||||
project: MigrationProject
|
||||
dns: DNSCheckReport
|
||||
}
|
||||
|
||||
export function useMailDomains() {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "mail", "domains"],
|
||||
queryFn: () => apiClient.get<{ domains: MailDomain[] }>("/admin/mail/domains"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateMailDomain() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: { name: string }) =>
|
||||
apiClient.post<MailDomain>("/admin/mail/domains", body),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "mail", "domains"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useVerifyMailDomainTXT(domainId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.post<MailDomain>(`/admin/mail/domains/${domainId}/verify-txt`),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "mail", "domains"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useVerifyMailDomainMX(domainId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.post<MailDomain>(`/admin/mail/domains/${domainId}/verify-mx`),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "mail", "domains"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useMigrationProjects() {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "migration", "projects"],
|
||||
queryFn: () =>
|
||||
apiClient.get<{ projects: MigrationProject[] }>("/admin/migration/projects"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateMigrationProject() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: {
|
||||
name: string
|
||||
source_provider: string
|
||||
domain_id?: string
|
||||
auth_mode?: string
|
||||
}) => apiClient.post<MigrationProject>("/admin/migration/projects", body),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "migration", "projects"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useActivateMigrationProject() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (projectId: string) =>
|
||||
apiClient.post<MigrationProject>(`/admin/migration/projects/${projectId}/activate`),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "migration", "projects"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCutoverMigrationProject() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (projectId: string) =>
|
||||
apiClient.post<MigrationCutoverResult>(`/admin/migration/projects/${projectId}/cutover`),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "migration", "projects"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePreflightCutoverDNS(projectId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "migration", "projects", projectId, "cutover-dns"],
|
||||
enabled: Boolean(projectId),
|
||||
queryFn: () =>
|
||||
apiClient.get<{ dns: DNSCheckReport }>(
|
||||
`/admin/migration/projects/${projectId}/cutover-dns`
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateMigrationInvite(projectId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (body: { email: string; alternate_emails?: string[] }) =>
|
||||
apiClient.post<MigrationInvite>(`/admin/migration/projects/${projectId}/invites`, body),
|
||||
})
|
||||
}
|
||||
|
||||
export function useImportMigrationInvites(projectId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (emails: string[]) =>
|
||||
apiClient.post<{ imported: number }>(
|
||||
`/admin/migration/projects/${projectId}/invites/import`,
|
||||
{ emails }
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "migration", "projects"] })
|
||||
},
|
||||
meta: { requiresProjectId: projectId },
|
||||
})
|
||||
}
|
||||
|
||||
export function useMigrationProjectJobs(projectId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "migration", "projects", projectId, "jobs"],
|
||||
enabled: Boolean(projectId) && enabled,
|
||||
queryFn: () =>
|
||||
apiClient.get<{ jobs: MigrationJob[] }>(
|
||||
`/admin/migration/projects/${projectId}/jobs`
|
||||
),
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMigrationJobAuditSummary(projectId: string, jobId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "migration", "projects", projectId, "jobs", jobId, "audit", "summary"],
|
||||
enabled: Boolean(projectId && jobId),
|
||||
queryFn: () =>
|
||||
apiClient.get<MigrationJobAuditSummary>(
|
||||
`/admin/migration/projects/${projectId}/jobs/${jobId}/audit/summary`
|
||||
),
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMigrationJobAudit(
|
||||
projectId: string,
|
||||
jobId: string | null,
|
||||
status: "failed" | "skipped" | "imported" | "all" = "failed"
|
||||
) {
|
||||
const statusParam = status === "all" ? "" : status
|
||||
return useQuery({
|
||||
queryKey: ["admin", "migration", "projects", projectId, "jobs", jobId, "audit", status],
|
||||
enabled: Boolean(projectId && jobId),
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page_size: "50" })
|
||||
if (statusParam) params.set("status", statusParam)
|
||||
return apiClient.get<{ items: MigrationJobAuditItem[]; pagination: { total?: number } }>(
|
||||
`/admin/migration/projects/${projectId}/jobs/${jobId}/audit?${params.toString()}`
|
||||
)
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useRetryMigrationJob(projectId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (jobId: string) =>
|
||||
apiClient.post<MigrationJob>(
|
||||
`/admin/migration/projects/${projectId}/jobs/${jobId}/retry`
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["admin", "migration", "projects", projectId, "jobs"],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useResetMigrationJobCursor(projectId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (jobId: string) =>
|
||||
apiClient.post<MigrationJob>(
|
||||
`/admin/migration/projects/${projectId}/jobs/${jobId}/reset-cursor`
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["admin", "migration", "projects", projectId, "jobs"],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRetryMigrationFailedJobs(projectId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiClient.post<{ retried: number }>(
|
||||
`/admin/migration/projects/${projectId}/jobs/retry-failed`
|
||||
),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["admin", "migration", "projects", projectId, "jobs"],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useMicrosoftMigrationAdminConsentURL() {
|
||||
return useMutation({
|
||||
mutationFn: (body: { tenant?: string; projectId?: string }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (body.tenant?.trim()) params.set("tenant", body.tenant.trim())
|
||||
if (body.projectId) params.set("project_id", body.projectId)
|
||||
const query = params.toString()
|
||||
return apiClient.get<{ url: string }>(
|
||||
`/admin/migration/microsoft/admin-consent-url${query ? `?${query}` : ""}`
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useMicrosoftAdminConsents() {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "migration", "microsoft", "admin-consents"],
|
||||
queryFn: () =>
|
||||
apiClient.get<{ consents: MicrosoftAdminConsent[] }>(
|
||||
"/admin/migration/microsoft/admin-consents"
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export function useMigrationInvite(token: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["migration", "invite", token],
|
||||
enabled: Boolean(token),
|
||||
queryFn: () =>
|
||||
apiClient.get<MigrationInviteResponse>(
|
||||
`/migration/invite?token=${encodeURIComponent(token ?? "")}`
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export function useMigrationStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["migration", "status"],
|
||||
queryFn: () => apiClient.get<MigrationUserStatus>("/migration/status"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useClaimMigration() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: { token: string; password?: string }) =>
|
||||
apiClient.post("/migration/claim", body),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["migration"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useStartMigrationOAuth() {
|
||||
return useMutation({
|
||||
mutationFn: (body: { provider: string; invite_token?: string; project_id?: string }) =>
|
||||
apiClient.post<{ auth_url: string }>("/migration/oauth/start", body),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCheckMailAddress(local: string, domain: string) {
|
||||
return useQuery({
|
||||
queryKey: ["mail", "address-check", local, domain],
|
||||
enabled: local.length > 0 && domain.length > 0,
|
||||
queryFn: () =>
|
||||
apiClient.get<{ available: boolean; reason?: string }>(
|
||||
`/mail/addresses/check?local=${encodeURIComponent(local)}&domain=${encodeURIComponent(domain)}`
|
||||
),
|
||||
})
|
||||
}
|
||||
@ -5,6 +5,7 @@ export function isAuthPublicPath(pathname: string): boolean {
|
||||
if (pathname === "/") return true
|
||||
if (pathname.startsWith("/drive/s/")) return true
|
||||
if (pathname === "/demo" || pathname.startsWith("/demo/")) return true
|
||||
if (pathname.startsWith("/onboard/")) return true
|
||||
return AUTH_PUBLIC_PREFIXES.some(
|
||||
(prefix) => pathname === prefix || pathname.startsWith(prefix)
|
||||
)
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user