ultisuite-client/components/gmail/settings/add-mail-account-form.tsx
2026-05-25 13:52:40 +02:00

574 lines
18 KiB
TypeScript

"use client"
import { useEffect, useState } from "react"
import { ChevronDown, ChevronUp, Loader2, Mail } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ProtonBridgeWizard } from "@/components/gmail/settings/proton-bridge-wizard"
import { useDiscoverMailAccount } from "@/lib/api/hooks/use-mail-account-discover"
import { useTestMailAccount } from "@/lib/api/hooks/use-mail-account-test"
import { useStartMailOAuth } from "@/lib/api/hooks/use-mail-oauth"
import { useMailOAuthProviders } from "@/lib/api/hooks/use-mail-oauth-providers"
import type { CreateMailAccountPayload, MailAccountDiscoverResult } from "@/lib/api/types"
import { manualMailDiscoverResult } from "@/lib/mail-settings/manual-account-discover"
type Step = "email" | "proton" | "credentials"
function displayNameFromEmail(email: string): string {
const local = email.split("@")[0] ?? email
return local.replace(/[._-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
function usernameForHint(email: string, hint: string): string {
if (hint === "local") {
return email.split("@")[0] ?? email
}
return email
}
function shouldShowAdvanced(discover: MailAccountDiscoverResult | null): boolean {
if (!discover) return true
if (discover.confidence === "low") return true
if (!discover.imap_host || !discover.smtp_host) return true
if (discover.provider_id === "tutanota") return true
return false
}
function oauthProviderForDiscover(
discover: MailAccountDiscoverResult,
enabled: string[]
): "google" | "microsoft" | null {
if (!discover.auth_methods.includes("oauth2")) return null
const id = discover.provider_id
if ((id === "gmail" || id === "google_workspace") && enabled.includes("google")) {
return "google"
}
if (
(id === "outlook" || id === "microsoft365") &&
enabled.includes("microsoft")
) {
return "microsoft"
}
return null
}
export function AddMailAccountForm({
pending,
onSubmit,
}: {
pending: boolean
onSubmit: (payload: CreateMailAccountPayload) => void
}) {
const [open, setOpen] = useState(false)
const [step, setStep] = useState<Step>("email")
const [showAdvanced, setShowAdvanced] = useState(false)
const [discover, setDiscover] = useState<MailAccountDiscoverResult | null>(null)
const [testOk, setTestOk] = useState<boolean | null>(null)
const discoverMutation = useDiscoverMailAccount()
const testMutation = useTestMailAccount()
const oauthStart = useStartMailOAuth()
const { data: oauthProviders } = useMailOAuthProviders()
const enabledOAuth = oauthProviders?.providers ?? []
const [email, setEmail] = useState("")
const [form, setForm] = useState({
name: "",
imap_host: "127.0.0.1",
imap_port: "1143",
imap_tls: true,
smtp_host: "127.0.0.1",
smtp_port: "1025",
smtp_tls: true,
username: "",
password: "",
})
useEffect(() => {
if (!discover) return
setForm((prev) => ({
...prev,
imap_host: discover.imap_host || prev.imap_host,
imap_port: String(discover.imap_port || 993),
imap_tls: discover.imap_tls,
smtp_host: discover.smtp_host || prev.smtp_host,
smtp_port: String(discover.smtp_port || 587),
smtp_tls: discover.smtp_tls,
username: prev.username || usernameForHint(discover.email, discover.username_hint),
name: prev.name || displayNameFromEmail(discover.email),
}))
setShowAdvanced(shouldShowAdvanced(discover))
setTestOk(null)
}, [discover])
function resetForm() {
setStep("email")
setEmail("")
setDiscover(null)
setShowAdvanced(false)
setTestOk(null)
setForm({
name: "",
imap_host: "127.0.0.1",
imap_port: "1143",
imap_tls: true,
smtp_host: "127.0.0.1",
smtp_port: "1025",
smtp_tls: true,
username: "",
password: "",
})
discoverMutation.reset()
testMutation.reset()
oauthStart.reset()
}
async function handleEmailContinue() {
const trimmed = email.trim()
if (!trimmed) return
const result = await discoverMutation.mutateAsync(trimmed)
goToCredentials(result)
}
function goToCredentials(result: MailAccountDiscoverResult) {
setDiscover(result)
if (result.provider_id === "proton" && result.source !== "manual") {
setStep("proton")
} else {
setStep("credentials")
}
}
function handleManualContinue() {
const trimmed = email.trim()
if (!trimmed || !trimmed.includes("@")) return
goToCredentials(manualMailDiscoverResult(trimmed))
}
async function runConnectionTest() {
const result = await testMutation.mutateAsync({
imap_host: form.imap_host,
imap_port: Number(form.imap_port) || 993,
imap_tls: form.imap_tls,
smtp_host: form.smtp_host,
smtp_port: Number(form.smtp_port) || 587,
smtp_tls: form.smtp_tls,
username: form.username,
password: form.password,
})
setTestOk(result.ok)
return result
}
async function handleProtonContinue() {
setForm((prev) => ({
...prev,
imap_host: "127.0.0.1",
smtp_host: "127.0.0.1",
username: discover?.email ?? prev.username,
password: prev.password,
}))
const result = await runConnectionTest()
if (result.ok) {
setStep("credentials")
}
}
function handleSubmit() {
if (!discover) return
onSubmit({
name: form.name.trim() || displayNameFromEmail(discover.email),
email: discover.email,
provider: discover.provider_id,
imap_host: form.imap_host,
imap_port: Number(form.imap_port) || 993,
imap_tls: form.imap_tls,
smtp_host: form.smtp_host,
smtp_port: Number(form.smtp_port) || 587,
smtp_tls: form.smtp_tls,
username: form.username,
password: form.password,
})
setOpen(false)
resetForm()
}
async function handleOAuth(provider: "google" | "microsoft") {
if (!discover) return
const { authorization_url } = await oauthStart.mutateAsync({
provider,
email: discover.email,
name: form.name.trim() || displayNameFromEmail(discover.email),
provider_id: discover.provider_id,
imap_host: form.imap_host,
imap_port: Number(form.imap_port) || 993,
imap_tls: form.imap_tls,
smtp_host: form.smtp_host,
smtp_port: Number(form.smtp_port) || 587,
smtp_tls: form.smtp_tls,
})
window.location.href = authorization_url
}
const oauthProvider = discover ? oauthProviderForDiscover(discover, enabledOAuth) : null
if (!open) {
return (
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
Ajouter un compte mail
</Button>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Nouveau compte mail</CardTitle>
<CardDescription>
Saisissez votre adresse e-mail : nous détectons le fournisseur et préremplissons IMAP/SMTP.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{step === "email" ? (
<div className="space-y-3 max-w-md">
<Field
label="Adresse e-mail"
value={email}
onChange={setEmail}
type="email"
autoComplete="email"
placeholder="vous@exemple.com"
/>
{discoverMutation.isError ? (
<p className="text-sm text-destructive">
Détection impossible. Utilisez « Configurer manuellement » ou réessayez.
</p>
) : null}
<div className="flex flex-wrap gap-2">
<Button
type="button"
disabled={!email.trim() || discoverMutation.isPending}
onClick={() => void handleEmailContinue()}
>
{discoverMutation.isPending ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Détection
</>
) : (
"Détecter la configuration"
)}
</Button>
<Button
type="button"
variant="secondary"
disabled={!email.trim() || !email.includes("@")}
onClick={handleManualContinue}
>
Configurer manuellement
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false)
resetForm()
}}
>
Annuler
</Button>
</div>
<p className="text-xs text-muted-foreground">
La détection préremplit IMAP/SMTP. Choisissez « Configurer manuellement » pour saisir
vos paramètres sans appel réseau.
</p>
</div>
) : null}
{step === "proton" && discover ? (
<ProtonBridgeWizard
email={discover.email}
imapPort={form.imap_port}
smtpPort={form.smtp_port}
bridgePassword={form.password}
onBridgePasswordChange={(v) => setForm({ ...form, password: v })}
onImapPortChange={(v) => setForm({ ...form, imap_port: v })}
onSmtpPortChange={(v) => setForm({ ...form, smtp_port: v })}
onContinue={() => void handleProtonContinue()}
onBack={() => setStep("email")}
/>
) : null}
{step === "credentials" && discover ? (
<>
<ProviderBanner discover={discover} onBack={() => setStep("email")} />
{discover.notes?.length ? (
<ul className="rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground space-y-1">
{discover.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
) : null}
{oauthProvider ? (
<div className="space-y-2 max-w-md">
<p className="text-sm text-muted-foreground">
Connexion recommandée sans mot de passe :
</p>
<Button
type="button"
variant="secondary"
disabled={oauthStart.isPending}
onClick={() => void handleOAuth(oauthProvider)}
>
{oauthStart.isPending ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}
{oauthProvider === "google"
? "Continuer avec Google"
: "Continuer avec Microsoft"}
</Button>
<p className="text-xs text-muted-foreground">ou identifiant / mot de passe ci-dessous</p>
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2 max-w-2xl">
<Field
label="Nom affiché"
value={form.name}
onChange={(v) => setForm({ ...form, name: v })}
/>
<Field
label="Identifiant"
value={form.username}
onChange={(v) => setForm({ ...form, username: v })}
autoComplete="username"
/>
<Field
label="Mot de passe"
type="password"
value={form.password}
onChange={(v) => {
setTestOk(null)
setForm({ ...form, password: v })
}}
autoComplete="current-password"
/>
</div>
<div>
<button
type="button"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced((v) => !v)}
>
{showAdvanced ? (
<ChevronUp className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
Paramètres serveur {showAdvanced ? "" : "(avancé)"}
</button>
{showAdvanced ? (
<div className="mt-3 grid gap-3 sm:grid-cols-2 max-w-2xl">
<Field
label="IMAP hôte"
value={form.imap_host}
onChange={(v) => setForm({ ...form, imap_host: v })}
/>
<Field
label="IMAP port"
value={form.imap_port}
onChange={(v) => setForm({ ...form, imap_port: v })}
/>
<Field
label="SMTP hôte"
value={form.smtp_host}
onChange={(v) => setForm({ ...form, smtp_host: v })}
/>
<Field
label="SMTP port"
value={form.smtp_port}
onChange={(v) => setForm({ ...form, smtp_port: v })}
/>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input
type="checkbox"
checked={form.imap_tls}
onChange={(e) => setForm({ ...form, imap_tls: e.target.checked })}
/>
IMAP TLS
</label>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input
type="checkbox"
checked={form.smtp_tls}
onChange={(e) => setForm({ ...form, smtp_tls: e.target.checked })}
/>
SMTP TLS / STARTTLS
</label>
</div>
) : (
<p className="mt-1 text-xs text-muted-foreground">
Réception {form.imap_host}:{form.imap_port} · Envoi {form.smtp_host}:{form.smtp_port}
</p>
)}
</div>
<TestResultBanner
testing={testMutation.isPending}
result={testMutation.data}
testOk={testOk}
error={testMutation.isError}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
disabled={
testMutation.isPending ||
!form.password ||
!form.username ||
!form.imap_host ||
!form.smtp_host
}
onClick={() => void runConnectionTest()}
>
{testMutation.isPending ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Test
</>
) : (
"Tester la connexion"
)}
</Button>
<Button
type="button"
disabled={
pending ||
!form.password ||
!form.username ||
!form.imap_host ||
!form.smtp_host ||
testOk === false
}
onClick={handleSubmit}
>
Enregistrer
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false)
resetForm()
}}
>
Annuler
</Button>
</div>
</>
) : null}
</CardContent>
</Card>
)
}
function TestResultBanner({
testing,
result,
testOk,
error,
}: {
testing: boolean
result?: { ok: boolean; imap_ok: boolean; imap_error?: string; smtp_ok: boolean; smtp_error?: string }
testOk: boolean | null
error: boolean
}) {
if (testing || testOk === null) {
if (error) {
return <p className="text-sm text-destructive">Échec du test de connexion.</p>
}
return null
}
if (testOk && result?.ok) {
return (
<p className="text-sm text-green-600 dark:text-green-500">
Connexion IMAP et SMTP validée.
</p>
)
}
return (
<div className="text-sm text-destructive space-y-1">
<p>Échec du test de connexion.</p>
{result?.imap_error ? <p className="text-xs">IMAP : {result.imap_error}</p> : null}
{result?.smtp_error ? <p className="text-xs">SMTP : {result.smtp_error}</p> : null}
</div>
)
}
function ProviderBanner({
discover,
onBack,
}: {
discover: MailAccountDiscoverResult
onBack: () => void
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Mail className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{discover.email}</p>
<p className="text-xs text-muted-foreground">
{discover.provider_name}
{discover.confidence !== "high" ? ` · confiance ${discover.confidence}` : null}
</p>
</div>
</div>
<Button type="button" variant="ghost" size="sm" onClick={onBack}>
Modifier l&apos;adresse
</Button>
</div>
)
}
function Field({
label,
value,
onChange,
type = "text",
autoComplete,
placeholder,
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
autoComplete?: string
placeholder?: string
}) {
return (
<div className="space-y-1.5">
<Label className="text-xs">{label}</Label>
<Input
value={value}
type={type}
autoComplete={autoComplete}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</div>
)
}