574 lines
18 KiB
TypeScript
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'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>
|
|
)
|
|
}
|