Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
227 lines
8.2 KiB
TypeScript
227 lines
8.2 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
import { Loader2, MailCheck, Server } 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 { ApiRequestError } from "@/lib/api/client"
|
|
import {
|
|
useCheckMailAddress,
|
|
useHostedMailStatus,
|
|
useSetupHostedMailbox,
|
|
} from "@/lib/api/hooks/use-hosted-mail"
|
|
import { useAuthStore } from "@/lib/api/auth-store"
|
|
|
|
function suggestLocalPart(platformEmail: string | undefined, domain: string): string {
|
|
if (!platformEmail?.includes("@")) return ""
|
|
const [local, emailDomain] = platformEmail.split("@")
|
|
if (!local) return ""
|
|
if (emailDomain?.toLowerCase() === domain.toLowerCase()) {
|
|
return local.toLowerCase()
|
|
}
|
|
return local.toLowerCase().replace(/[^a-z0-9._+-]/g, "")
|
|
}
|
|
|
|
function setupErrorMessage(error: unknown): string {
|
|
if (error instanceof ApiRequestError) {
|
|
switch (error.code) {
|
|
case "address_taken":
|
|
return "Cette adresse est déjà utilisée."
|
|
case "domain_not_active":
|
|
return "Le domaine mail n'est pas encore actif."
|
|
}
|
|
return error.message
|
|
}
|
|
if (error instanceof Error) return error.message
|
|
return "Échec de la configuration."
|
|
}
|
|
|
|
export function HostedMailSetupCard() {
|
|
const platformEmail = useAuthStore((s) => s.user?.email)
|
|
const { data: status, isPending } = useHostedMailStatus()
|
|
const setup = useSetupHostedMailbox()
|
|
|
|
const domain = status?.platform_domain ?? ""
|
|
const alreadyConnected = Boolean(
|
|
status?.hosted_mail_account_id || status?.mailbox?.mail_account_id
|
|
)
|
|
const connectedEmail =
|
|
status?.hosted_mail_account_email ?? status?.mailbox?.email ?? ""
|
|
|
|
const [localPart, setLocalPart] = useState("")
|
|
const [displayName, setDisplayName] = useState("")
|
|
const [password, setPassword] = useState("")
|
|
const [confirmPassword, setConfirmPassword] = useState("")
|
|
|
|
useEffect(() => {
|
|
if (!domain || localPart) return
|
|
const suggested = suggestLocalPart(platformEmail, domain)
|
|
if (suggested) setLocalPart(suggested)
|
|
}, [domain, localPart, platformEmail])
|
|
|
|
const fullEmail = useMemo(() => {
|
|
const local = localPart.trim().toLowerCase()
|
|
return local && domain ? `${local}@${domain}` : ""
|
|
}, [localPart, domain])
|
|
|
|
const addressCheck = useCheckMailAddress(localPart.trim(), domain)
|
|
const addressTaken =
|
|
addressCheck.data?.available === false &&
|
|
addressCheck.data.reason !== "hosted_mail_disabled"
|
|
|
|
const passwordsMatch = password === confirmPassword
|
|
const passwordOk = password.length >= 8
|
|
const canSubmit =
|
|
!setup.isPending &&
|
|
localPart.trim().length > 0 &&
|
|
passwordOk &&
|
|
passwordsMatch &&
|
|
addressCheck.data?.available !== false
|
|
|
|
if (isPending || !status?.enabled) return null
|
|
|
|
const endpoints = status.endpoints
|
|
|
|
async function handleSubmit() {
|
|
if (!canSubmit) return
|
|
try {
|
|
await setup.mutateAsync({
|
|
local_part: localPart.trim().toLowerCase(),
|
|
password,
|
|
display_name: displayName.trim() || undefined,
|
|
})
|
|
setPassword("")
|
|
setConfirmPassword("")
|
|
} catch {
|
|
/* message below */
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card className="border-primary/20 bg-primary/[0.03]">
|
|
<CardHeader>
|
|
<div className="flex items-start gap-3">
|
|
<Server className="mt-0.5 size-5 shrink-0 text-primary" />
|
|
<div>
|
|
<CardTitle className="text-base">Mail hébergé Ultimail (Stalwart)</CardTitle>
|
|
<CardDescription>
|
|
Créez ou connectez votre boîte @{domain} — IMAP/SMTP préconfigurés, sans saisie
|
|
manuelle des serveurs.
|
|
</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{endpoints ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
Serveurs : IMAP {endpoints.imap_host}:{endpoints.imap_port}
|
|
{endpoints.imap_tls ? " (TLS)" : ""} · SMTP {endpoints.smtp_host}:{endpoints.smtp_port}
|
|
{endpoints.smtp_tls ? " (TLS)" : ""}
|
|
</p>
|
|
) : null}
|
|
|
|
{alreadyConnected ? (
|
|
<div className="flex items-start gap-2 rounded-lg border border-border bg-background px-3 py-2">
|
|
<MailCheck className="mt-0.5 size-4 shrink-0 text-green-600 dark:text-green-500" />
|
|
<div>
|
|
<p className="text-sm font-medium">Boîte hébergée connectée</p>
|
|
<p className="text-sm text-muted-foreground">{connectedEmail}</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4 max-w-lg">
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<Label htmlFor="hosted-local-part">Adresse mail</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
id="hosted-local-part"
|
|
value={localPart}
|
|
autoComplete="username"
|
|
placeholder="prenom.nom"
|
|
onChange={(e) => setLocalPart(e.target.value)}
|
|
/>
|
|
<span className="shrink-0 text-sm text-muted-foreground">@{domain}</span>
|
|
</div>
|
|
{fullEmail ? (
|
|
<p className="text-xs text-muted-foreground">{fullEmail}</p>
|
|
) : null}
|
|
{addressCheck.isFetching ? (
|
|
<p className="text-xs text-muted-foreground">Vérification de la disponibilité…</p>
|
|
) : addressTaken ? (
|
|
<p className="text-xs text-destructive">Adresse déjà prise.</p>
|
|
) : addressCheck.data?.available === true && localPart.trim() ? (
|
|
<p className="text-xs text-green-600 dark:text-green-500">Adresse disponible.</p>
|
|
) : null}
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="hosted-display-name">Nom affiché</Label>
|
|
<Input
|
|
id="hosted-display-name"
|
|
value={displayName}
|
|
placeholder="Optionnel"
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="hosted-password">Mot de passe boîte</Label>
|
|
<Input
|
|
id="hosted-password"
|
|
type="password"
|
|
value={password}
|
|
autoComplete="new-password"
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5 sm:col-span-2">
|
|
<Label htmlFor="hosted-password-confirm">Confirmer le mot de passe</Label>
|
|
<Input
|
|
id="hosted-password-confirm"
|
|
type="password"
|
|
value={confirmPassword}
|
|
autoComplete="new-password"
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
/>
|
|
{password && !passwordOk ? (
|
|
<p className="text-xs text-destructive">8 caractères minimum.</p>
|
|
) : null}
|
|
{confirmPassword && !passwordsMatch ? (
|
|
<p className="text-xs text-destructive">Les mots de passe ne correspondent pas.</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{setup.isError ? (
|
|
<p className="text-sm text-destructive">{setupErrorMessage(setup.error)}</p>
|
|
) : null}
|
|
{setup.isSuccess ? (
|
|
<p className="text-sm text-green-600 dark:text-green-500">
|
|
Boîte configurée — synchronisation IMAP en cours.
|
|
</p>
|
|
) : null}
|
|
|
|
<Button type="button" disabled={!canSubmit} onClick={() => void handleSubmit()}>
|
|
{setup.isPending ? (
|
|
<>
|
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
Configuration…
|
|
</>
|
|
) : (
|
|
"Activer ma boîte hébergée"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|