ultisuite-client/components/gmail/settings/sections/accounts-settings-section.tsx
R3D347HR4Y 7ee1a66942
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(ai-assistant): enhance AI assistant configuration and integration
- Added support for managing AI models within the AI assistant settings.
- Introduced new hosted mail setup component for streamlined email configuration.
- Updated environment variables for local development and proxy settings.
- Enhanced error handling and user feedback in the chat page for API connectivity issues.
- Improved routing for AI-related API calls in the Next.js configuration.
- Added documentation for local development and agent management in CLAUDE.md.
2026-06-13 20:38:15 +02:00

480 lines
16 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { AddMailAccountForm } from "@/components/gmail/settings/add-mail-account-form"
import { EditMailAccountForm } from "@/components/gmail/settings/edit-mail-account-form"
import { HostedMailSetupCard } from "@/components/gmail/settings/hosted-mail-setup-card"
import { SignatureLibraryCard } from "@/components/gmail/settings/signature-library-card"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import {
useCreateMailAccount,
useDeleteMailAccount,
useResanitizeBodies,
useSyncMailAccount,
} from "@/lib/api/hooks/use-mail-account-mutations"
import { useIdentities } from "@/lib/api/hooks/use-folder-label-queries"
import {
useCreateIdentity,
useUpdateIdentity,
useDeleteIdentity,
} from "@/lib/api/hooks/use-identity-mutations"
import { useMailSignatures } from "@/lib/api/hooks/use-mail-signatures"
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import type { ApiMailAccount, ApiMailSignature } from "@/lib/api/types"
const NONE_SIGNATURE = "__none__"
export function AccountsSettingsSection() {
const router = useRouter()
const searchParams = useSearchParams()
const oauthStatus = searchParams.get("oauth")
const { ready, authenticated } = useAuthReady()
const { data: accounts = [], isFetching, isError, refetch, isPending } = useMailAccounts()
const {
data: signatures = [],
isFetching: signaturesFetching,
isError: signaturesError,
refetch: refetchSignatures,
isPending: signaturesPending,
} = useMailSignatures()
const createAccount = useCreateMailAccount()
const showInitialLoad = ready && authenticated && isPending && accounts.length === 0
const showSignaturesInitialLoad =
ready && authenticated && signaturesPending && signatures.length === 0
useEffect(() => {
if (oauthStatus === "success") {
void refetch()
router.replace("/mail/settings/accounts")
}
}, [oauthStatus, refetch, router])
const syncFetching = isFetching || signaturesFetching
const syncError = isError || signaturesError
function handleRetry() {
void refetch()
void refetchSignatures()
}
return (
<>
<SettingsSectionHeader
title="Comptes mail"
description="Connexions IMAP/SMTP, identités d'envoi et signatures."
/>
{oauthStatus === "success" ? (
<p className="text-sm text-green-600 dark:text-green-500">
Compte mail connecté via OAuth.
</p>
) : null}
{oauthStatus === "error" ? (
<p className="text-sm text-destructive">
Échec de la connexion OAuth
{searchParams.get("code") ? ` (${searchParams.get("code")})` : ""}.
</p>
) : null}
<SettingsSyncBanner
isFetching={syncFetching}
isError={syncError}
onRetry={handleRetry}
/>
<div className="space-y-6">
<HostedMailSetupCard />
<AddMailAccountForm
pending={createAccount.isPending}
onSubmit={(payload) => createAccount.mutate(payload)}
/>
{!ready || showInitialLoad ? null : accounts.length === 0 ? (
<p className="text-sm text-muted-foreground">
Aucun compte mail configuré. Ajoutez votre adresse e-mail ci-dessus pour commencer.
</p>
) : (
accounts.map((account) => (
<AccountCard key={account.id} account={account} signatures={signatures} />
))
)}
<SignatureLibraryCard
signatures={signatures}
showInitialLoad={showSignaturesInitialLoad}
/>
</div>
</>
)
}
function AccountCard({
account,
signatures,
}: {
account: ApiMailAccount
signatures: ApiMailSignature[]
}) {
const deleteAccount = useDeleteMailAccount()
const resanitizeBodies = useResanitizeBodies(account.id)
const syncAccount = useSyncMailAccount(account.id)
const { data: identities = [] } = useIdentities(account.id)
const [editing, setEditing] = useState(false)
const [maintenanceMessage, setMaintenanceMessage] = useState<string | null>(null)
async function runResanitize() {
setMaintenanceMessage(null)
try {
const result = await resanitizeBodies.mutateAsync()
setMaintenanceMessage(
`Corps réimportés depuis IMAP : ${result.updated} message(s) mis à jour sur ${result.scanned} analysé(s).`
)
} catch {
setMaintenanceMessage("Échec de la réimportation des corps depuis IMAP.")
}
}
async function runSync(force = false) {
setMaintenanceMessage(null)
try {
await syncAccount.mutateAsync({ force })
setMaintenanceMessage(
force
? "Re-synchronisation complète IMAP terminée."
: "Synchronisation IMAP terminée."
)
} catch {
setMaintenanceMessage("Échec de la synchronisation IMAP.")
}
}
const maintenancePending = resanitizeBodies.isPending || syncAccount.isPending
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<div>
<CardTitle className="text-base">{account.name}</CardTitle>
<CardDescription>{account.email}</CardDescription>
<p className="mt-1 text-xs text-muted-foreground">
IMAP {account.imap_host} · SMTP {account.smtp_host}
{account.last_sync_at
? ` · Dernière sync : ${new Date(account.last_sync_at).toLocaleString("fr-FR")}`
: null}
</p>
</div>
<div className="flex shrink-0 gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Actions avancées du compte"
disabled={maintenancePending}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={maintenancePending}
onClick={() => void runResanitize()}
>
{resanitizeBodies.isPending
? "Réimportation IMAP…"
: "Réimporter les corps depuis IMAP"}
</DropdownMenuItem>
<DropdownMenuItem
disabled={maintenancePending}
onClick={() => void runSync()}
>
{syncAccount.isPending ? "Synchronisation…" : "Synchroniser IMAP"}
</DropdownMenuItem>
<DropdownMenuItem
disabled={maintenancePending}
onClick={() => void runSync(true)}
>
Forcer re-sync complet
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Modifier le compte"
aria-pressed={editing}
onClick={() => setEditing((v) => !v)}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Supprimer le compte"
onClick={() => deleteAccount.mutate(account.id)}
>
<Trash2 className="size-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{maintenanceMessage ? (
<p className="text-sm text-muted-foreground">{maintenanceMessage}</p>
) : null}
{editing ? (
<EditMailAccountForm account={account} onCancel={() => setEditing(false)} />
) : null}
<IdentitiesBlock
accountId={account.id}
accountEmail={account.email}
identities={identities}
signatures={signatures}
/>
</CardContent>
</Card>
)
}
function IdentitiesBlock({
accountId,
accountEmail,
identities,
signatures,
}: {
accountId: string
accountEmail: string
identities: Array<{
id: string
email: string
name: string
is_default: boolean
signature_html?: string
default_signature_id?: string
reply_to_addrs?: string[]
}>
signatures: ApiMailSignature[]
}) {
const createIdentity = useCreateIdentity(accountId)
const updateIdentity = useUpdateIdentity(accountId)
const deleteIdentity = useDeleteIdentity(accountId)
const [showAddForm, setShowAddForm] = useState(false)
const [newIdentity, setNewIdentity] = useState({ email: accountEmail, name: "" })
const signatureOptions = useMemo(
() => [
{ value: NONE_SIGNATURE, label: "Aucune" },
...signatures.map((s) => ({ value: s.id, label: s.name })),
],
[signatures]
)
useEffect(() => {
if (!showAddForm) {
setNewIdentity({ email: accountEmail, name: "" })
}
}, [accountEmail, showAddForm])
function identityPayload(
identity: (typeof identities)[number],
patch: Partial<{
email: string
name: string
is_default: boolean
default_signature_id: string
}> = {}
) {
return {
identityId: identity.id,
email: patch.email ?? identity.email,
name: patch.name ?? identity.name,
is_default: patch.is_default ?? identity.is_default,
signature_html: identity.signature_html ?? "",
default_signature_id: patch.default_signature_id ?? identity.default_signature_id ?? "",
reply_to_addrs: identity.reply_to_addrs,
}
}
function handleCreateIdentity() {
const email = newIdentity.email.trim()
const name = newIdentity.name.trim()
if (!email) return
createIdentity.mutate(
{
email,
name: name || email.split("@")[0] || "Identité",
is_default: identities.length === 0,
},
{
onSuccess: () => {
setShowAddForm(false)
setNewIdentity({ email: accountEmail, name: "" })
},
}
)
}
return (
<div className="space-y-3">
<h3 className="text-sm font-medium">Identités d&apos;envoi</h3>
{identities.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucune identité configurée.</p>
) : (
<ul className="space-y-3">
{identities.map((identity) => {
const currentSignature =
identity.default_signature_id && identity.default_signature_id !== ""
? identity.default_signature_id
: NONE_SIGNATURE
return (
<li key={identity.id} className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="grid flex-1 gap-2 sm:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">Nom affiché</Label>
<Input
defaultValue={identity.name}
onBlur={(e) => {
const next = e.target.value.trim()
if (!next || next === identity.name) return
updateIdentity.mutate(identityPayload(identity, { name: next }))
}}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Adresse d&apos;envoi</Label>
<Input
type="email"
defaultValue={identity.email}
onBlur={(e) => {
const next = e.target.value.trim()
if (!next || next === identity.email) return
updateIdentity.mutate(identityPayload(identity, { email: next }))
}}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Signature</Label>
<Select
value={currentSignature}
disabled={updateIdentity.isPending}
onValueChange={(value) =>
updateIdentity.mutate(
identityPayload(identity, {
default_signature_id: value === NONE_SIGNATURE ? "" : value,
})
)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Signature" />
</SelectTrigger>
<SelectContent>
{signatureOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{identity.is_default ? (
<p className="text-xs text-muted-foreground sm:col-span-3">
Identité par défaut
</p>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Supprimer l'identité"
onClick={() => deleteIdentity.mutate(identity.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</li>
)
})}
</ul>
)}
{showAddForm ? (
<div className="rounded-lg border border-border p-3 space-y-3 max-w-lg">
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">Nom affiché</Label>
<Input
value={newIdentity.name}
placeholder="Nom visible"
onChange={(e) => setNewIdentity({ ...newIdentity, name: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Adresse d&apos;envoi</Label>
<Input
type="email"
value={newIdentity.email}
onChange={(e) => setNewIdentity({ ...newIdentity, email: e.target.value })}
/>
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
disabled={createIdentity.isPending || !newIdentity.email.trim()}
onClick={handleCreateIdentity}
>
Créer l&apos;identité
</Button>
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAddForm(false)}>
Annuler
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
disabled={createIdentity.isPending}
onClick={() => setShowAddForm(true)}
>
Ajouter une identité
</Button>
)}
</div>
)
}