feat(onboarding): implement claim and migration pages with OAuth support
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:
R3D347HR4Y 2026-06-13 12:47:03 +02:00
parent ad1370ea7e
commit 6c7278a3aa
13 changed files with 2212 additions and 2 deletions

174
app/onboard/claim/page.tsx Normal file
View 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&apos;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&apos;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&apos;invitation ({invite?.email}). Les
alias SSO reconnus (UPN, adresse alternative) sont acceptés si configurés par
l&apos;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>
)
}

View 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&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>
)
}

View File

@ -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,

View 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>
)
}

View 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&apos;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&apos;a pas encore traité d&apos;items ou la migration
date d&apos;avant l&apos;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 é 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>
)
}

View 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&apos;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&apos;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&apos;administrateur de votre organisation Microsoft 365 doit accorder le consentement
admin à l&apos;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&apos;accès à votre compte{" "}
{project?.source_provider === "microsoft" ? "Microsoft 365" : "Google Workspace"} pour
lancer l&apos;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&apos;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
}

View 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}`))
}

View 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}`))
})
})

View File

@ -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é",

View 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)}`
),
})
}

View File

@ -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
View File

@ -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