Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Added SessionGuard component to manage session expiration and online status. - Updated AuthProvider to streamline session fetching and handling. - Introduced IdentityProvidersSection for managing OAuth, SAML, and LDAP identity providers. - Implemented identity provider guides for easier configuration. - Enhanced mail settings with infinite scroll option for improved user experience. - Updated global styles and layout components for better consistency across the application.
477 lines
16 KiB
TypeScript
477 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 { 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">
|
|
<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'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'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'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'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>
|
|
)
|
|
}
|