From 6c7278a3aac27402eeeba0369a62b577829be025 Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Sat, 13 Jun 2026 12:47:03 +0200 Subject: [PATCH] feat(onboarding): implement claim and migration pages with OAuth support - Added OnboardClaimPage and OnboardMigrationPage components for user onboarding. - Integrated OAuth login flow for Google and Microsoft accounts. - Implemented error handling and user feedback for claim and migration processes. - Created MigrationStepList and MigrationOnboardingAlerts components for progress tracking. - Added MailDomainsSection and MigrationProjectsPanel for admin settings. - Introduced e2e tests for onboarding migration scenarios. --- app/onboard/claim/page.tsx | 174 ++++ app/onboard/migration/page.tsx | 232 +++++ .../settings/admin-settings-section-view.tsx | 2 + .../sections/mail-domains-section.tsx | 103 +++ .../sections/migration-projects-panel.tsx | 796 ++++++++++++++++++ components/migration/onboarding-ui.tsx | 146 ++++ e2e/helpers/migration-onboarding.ts | 191 +++++ e2e/onboard-migration.spec.ts | 158 ++++ lib/admin-settings/settings-nav.ts | 9 + lib/api/hooks/use-hosted-mail.ts | 398 +++++++++ lib/auth/public-paths.ts | 1 + next-env.d.ts | 2 +- tsconfig.tsbuildinfo | 2 +- 13 files changed, 2212 insertions(+), 2 deletions(-) create mode 100644 app/onboard/claim/page.tsx create mode 100644 app/onboard/migration/page.tsx create mode 100644 components/admin/settings/sections/mail-domains-section.tsx create mode 100644 components/admin/settings/sections/migration-projects-panel.tsx create mode 100644 components/migration/onboarding-ui.tsx create mode 100644 e2e/helpers/migration-onboarding.ts create mode 100644 e2e/onboard-migration.spec.ts create mode 100644 lib/api/hooks/use-hosted-mail.ts diff --git a/app/onboard/claim/page.tsx b/app/onboard/claim/page.tsx new file mode 100644 index 0000000..5b0a4e5 --- /dev/null +++ b/app/onboard/claim/page.tsx @@ -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 ( + +

Chargement…

+ + } + > + +
+ ) +} + +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 ( +
+

Lien d'invitation invalide

+

Ce lien ne contient pas de jeton de migration.

+
+ ) + } + + if (!authenticated) { + return ( +
+

Revendiquer votre compte

+

+ Connectez-vous avec le même compte que l'adresse invitée ( + {inviteQuery.data?.invite.email ?? "…"}) pour provisionner votre boîte Ultimail. +

+
+ +
+ +
+ ) + } + + if (alreadyClaimed) { + return ( +
+

Redirection vers le suivi de migration…

+
+ ) + } + + 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 ( +
+

Revendiquer votre compte

+

+ Projet {project?.name ?? ""} — {invite?.email} +

+ +
+ +
+ +

+ 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. +

+ +
+
+ + setPassword(e.target.value)} + placeholder="Pour IMAP/SMTP hébergé" + /> +
+ + {claimMutation.isError && ( +

{claimErrorMessage(claimMutation.error)}

+ )} + + +
+
+ ) +} diff --git a/app/onboard/migration/page.tsx b/app/onboard/migration/page.tsx new file mode 100644 index 0000000..5a0ebfb --- /dev/null +++ b/app/onboard/migration/page.tsx @@ -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 = { + 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 ( + +

Chargement…

+ + } + > + +
+ ) +} + +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 ( +
+

Connectez-vous pour suivre votre migration.

+ +
+ ) + } + + 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 ( +
+

Migration en cours

+

+ {project?.name ?? "Votre migration"} — statut {project?.status ?? "…"} + {project?.delta_mode ? " (sync delta post-bascule MX)" : ""} +

+ +
+ +
+ + + + {oauth === "success" && ( +

+ Autorisation enregistrée. L'import démarre en arrière-plan. +

+ )} + {oauth === "error" && ( +

+ Échec de l'autorisation OAuth. Réessayez ci-dessous. +

+ )} + +
+ {jobs.length === 0 ? ( +

+ {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."} +

+ ) : ( + jobs.map((job) => ( +
+
+ {SERVICE_LABELS[job.service] ?? job.service} + {job.status} +
+ + {typeof job.stats_json?.imported === "number" && ( +

+ {Math.round(job.stats_json.imported)} éléments importés + {typeof job.stats_json.estimated_total === "number" + ? ` / ~${Math.round(job.stats_json.estimated_total)}` + : ""} +

+ )} + {job.error &&

{job.error}

} +
+ )) + )} +
+ +
+ {showGoogleOAuth && ( + + )} + {showMicrosoftOAuth && ( + + )} + + +
+
+ ) +} diff --git a/components/admin/settings/admin-settings-section-view.tsx b/components/admin/settings/admin-settings-section-view.tsx index 6ffbb89..3074e8c 100644 --- a/components/admin/settings/admin-settings-section-view.tsx +++ b/components/admin/settings/admin-settings-section-view.tsx @@ -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 = { agenda: AgendaSection, ultimeet: UltimeetSection, mailing: MailingSection, + "mail-domains": MailDomainsSection, onlyoffice: OnlyofficeSection, richtext: RichtextSection, "ai-assistant": AiAssistantSection, diff --git a/components/admin/settings/sections/mail-domains-section.tsx b/components/admin/settings/sections/mail-domains-section.tsx new file mode 100644 index 0000000..babe0ac --- /dev/null +++ b/components/admin/settings/sections/mail-domains-section.tsx @@ -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 ( +
+ +
+
+ + setDomainName(e.target.value)} + placeholder="entreprise.com" + /> +
+
+ +
+
+ +
    + {domains.map((domain) => ( + + ))} +
+
+ + +
+ ) +} + +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 ( +
  • +
    +
    +

    + {domain.name} + {domain.is_platform_domain ? " (plateforme)" : ""} +

    +

    Statut : {domain.status}

    + {domain.verification_token && ( +

    + TXT : _ultisuite-verify.{domain.name} = {domain.verification_token} +

    + )} +
    +
    + + +
    +
    +
  • + ) +} diff --git a/components/admin/settings/sections/migration-projects-panel.tsx b/components/admin/settings/sections/migration-projects-panel.tsx new file mode 100644 index 0000000..4b93ba1 --- /dev/null +++ b/components/admin/settings/sections/migration-projects-panel.tsx @@ -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 = { + mail: "Mail", + contacts: "Contacts", + calendar: "Agenda", + drive: "Drive", +} + +const AUTH_MODE_LABELS: Record = { + 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(null) + const [cutoverReport, setCutoverReport] = useState(null) + const [cutoverError, setCutoverError] = useState(null) + const [dnsSource, setDnsSource] = useState<"cutover" | "persisted" | "live" | null>(null) + const [auditJobId, setAuditJobId] = useState(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 ( + + {consentBanner && ( +

    + {consentBanner} +

    + )} + +
    +
    + + setProjectName(e.target.value)} + placeholder="Migration ACME 2026" + /> +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    + +
      + {projects.map((project) => ( + 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") + } + } + ) + }} + /> + ))} +
    + + {activeProject?.source_provider === "microsoft" && ( +
    +

    Consentement admin Microsoft

    +

    + 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)."} +

    + {activeProject.microsoft_admin_consent_at ? ( +

    + Consentement accordé + {activeProject.microsoft_tenant_id + ? ` — tenant ${activeProject.microsoft_tenant_id}` + : ""} + {activeProject.microsoft_admin_consent_at + ? ` (${new Date(activeProject.microsoft_admin_consent_at).toLocaleString()})` + : ""} +

    + ) : activeProject.microsoft_admin_consent_error ? ( +

    {activeProject.microsoft_admin_consent_error}

    + ) : ( +

    Consentement non enregistré pour ce projet.

    + )} +
    + setMsTenant(e.target.value)} + placeholder="common ou tenant ID" + /> + +
    +
    + )} + + {activeProjectId && ( + <> +
    +
    +

    Pré-vérification DNS (cutover)

    + +
    + {cutoverError && ( +

    + {cutoverError} +

    + )} + {dnsReport && ( + <> + {dnsSource === "persisted" && activeProject?.cutover_at && ( +

    + Rapport enregistré au cutover ( + {new Date(activeProject.cutover_at).toLocaleString()}). +

    + )} + {dnsSource === "live" && ( +

    + Vérification DNS live (non enregistrée — lancez la bascule MX pour persister). +

    + )} + + + )} + {!dnsReport && activeProject?.domain_id && ( +

    + Aucun rapport DNS enregistré — vérifiez live ou lancez la bascule MX. +

    + )} +
    + +
    +
    +

    Jobs de migration

    + +
    + 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 && ( + + )} +
    + +
    +

    Invitations utilisateurs

    +
    + setInviteEmail(e.target.value)} + placeholder="alice@entreprise.com" + /> + +
    +
    + +