'use client' import { useCallback, useEffect, useState } from 'react' import { useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { Skeleton } from '@/components/ui/skeleton' import { useToast } from '@/components/ui/use-toast' import { Check, Copy, Loader2, RefreshCw, Trash2 } from 'lucide-react' import { SettingsGroup, SettingsRow, SettingsRowNote, } from '@/components/settings/SettingsRows' import type { CompanySendingDomain, SendingDomainDnsRecord } from '@/types' import { getErrorMessage as getUserErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { useFormat } from '@/lib/hooks/use-format' import { useCompany } from '@/contexts/CompanyContext' import { copyToClipboard } from '@/lib/browser/copy-to-clipboard' const BASE = '/api/extensions/ext/email/sending-domain' const STATUS_VARIANT: Record = { pending: 'secondary', verified: 'success', failed: 'destructive', } /** * Opt-in "send invoice email from our own domain" section. Rendered only * when the company holds the capability grant: the GET answers 403 * capability_blocked otherwise and the section renders nothing, so every * other company keeps the unchanged invoicing settings page. * * Three states: no domain (claim form), pending (DNS records + re-check), * verified (sender address/name, pause toggle). Everything that touches the * From header is decided server-side; this surface only manages the claim. */ export function InvoiceSenderDomainSettings({ companyName }: { companyName: string | null }) { const t = useTranslations('settings_invoice_sender_domain') const { toast } = useToast() const { locale, formatDateLong } = useFormat() const errorLocale = locale as ErrorLocale const { role } = useCompany() const canManage = role === 'owner' || role === 'admin' const [available, setAvailable] = useState(false) const [isLoading, setIsLoading] = useState(true) const [loadFailed, setLoadFailed] = useState(false) const [domain, setDomain] = useState(null) const [domainInput, setDomainInput] = useState('') const [localPart, setLocalPart] = useState('faktura') const [senderName, setSenderName] = useState('') const [isClaiming, setIsClaiming] = useState(false) const [isChecking, setIsChecking] = useState(false) const [isSaving, setIsSaving] = useState(false) const [isRemoving, setIsRemoving] = useState(false) const applyRow = useCallback((row: CompanySendingDomain | null) => { setDomain(row) setLocalPart(row?.sender_local_part ?? 'faktura') setSenderName(row?.sender_name ?? '') }, []) const fetchDomain = useCallback(async () => { setIsLoading(true) setLoadFailed(false) try { const res = await fetch(BASE) if (res.status === 403 || res.status === 404) { // Not opted in (no capability grant) or extension not mounted: // stay invisible rather than advertise a feature the company lacks. setAvailable(false) return } if (!res.ok) { setAvailable(true) setLoadFailed(true) return } const json = await res.json() setAvailable(true) applyRow(json.data ?? null) } catch { setAvailable(true) setLoadFailed(true) } finally { setIsLoading(false) } }, [applyRow]) useEffect(() => { // Only owners/admins can ever see the section: skip the request (and its // capability lookups) for everyone else. if (!canManage) return void fetchDomain() }, [canManage, fetchDomain]) const fail = useCallback( (title: string, err: unknown) => { toast({ title, description: err instanceof Error ? getUserErrorMessage(err, { locale: errorLocale }) : t('try_again'), variant: 'destructive', }) }, [errorLocale, t, toast], ) const handleClaim = useCallback(async () => { if (!domainInput.trim()) return setIsClaiming(true) try { const res = await fetch(BASE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain: domainInput }), }) const json = await res.json() if (!res.ok) throw new Error(json.error ?? t('claim_error_title')) applyRow(json.data) setDomainInput('') toast({ title: t('claim_success_title'), description: t('claim_success_description') }) } catch (err) { fail(t('claim_error_title'), err) } finally { setIsClaiming(false) } }, [applyRow, domainInput, fail, t, toast]) const handleVerify = useCallback(async () => { setIsChecking(true) try { const res = await fetch(`${BASE}/verify`, { method: 'POST' }) const json = await res.json() if (!res.ok) throw new Error(json.error ?? t('verify_error_title')) applyRow(json.data) toast( json.data.status === 'verified' ? { title: t('verify_success_title'), description: t('verify_success_description') } : { title: t('verify_pending_title'), description: t('verify_pending_description') }, ) } catch (err) { fail(t('verify_error_title'), err) } finally { setIsChecking(false) } }, [applyRow, fail, t, toast]) const patch = useCallback( async (body: { sender_local_part?: string; sender_name?: string | null; enabled?: boolean }) => { setIsSaving(true) try { const res = await fetch(BASE, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) const json = await res.json() if (!res.ok) throw new Error(json.error ?? t('save_error_title')) applyRow(json.data) toast({ title: t('saved_title') }) } catch (err) { fail(t('save_error_title'), err) } finally { setIsSaving(false) } }, [applyRow, fail, t, toast], ) const handleSaveSender = useCallback(() => { const name = senderName.trim() void patch({ sender_local_part: localPart.trim(), sender_name: name ? name : null }) }, [localPart, patch, senderName]) const handleRemove = useCallback(async () => { if (!domain) return if (!confirm(t('remove_confirm', { domain: domain.domain }))) return setIsRemoving(true) try { const res = await fetch(BASE, { method: 'DELETE' }) const json = await res.json() if (!res.ok) throw new Error(json.error ?? t('remove_error_title')) applyRow(null) toast({ title: t('remove_success_title') }) } catch (err) { fail(t('remove_error_title'), err) } finally { setIsRemoving(false) } }, [applyRow, domain, fail, t, toast]) const handleCopy = useCallback( async (value: string) => { const result = await copyToClipboard(value) toast( result === 'copied' ? { title: t('copied') } : { title: t('copy_failed_title'), description: t('copy_failed_description'), variant: 'destructive' }, ) }, [t, toast], ) if (!canManage) return null // Stay invisible until the opt-in is confirmed: no skeleton flash for the // companies that do not hold the grant (i.e. almost all of them). if (!available) return null const records: SendingDomainDnsRecord[] = domain?.dns_records ?? [] const statusLabels: Record = { pending: t('status_pending'), verified: t('status_verified'), failed: t('status_failed'), } const effectiveName = (domain?.sender_name ?? companyName ?? '').trim() const previewAddress = domain ? `${domain.sender_local_part}@${domain.domain}` : '' return ( {isLoading ? (
) : loadFailed ? (

{t('load_error')}

) : !domain ? ( <> setDomainInput(e.target.value)} placeholder="dittbolag.se" className="max-w-xs" onKeyDown={(e) => { if (e.nativeEvent.isComposing) return if (e.key === 'Enter') void handleClaim() }} /> {t('fallback_note')} ) : ( <> {domain.domain} {statusLabels[domain.status]}
{domain.status === 'verified' ? ( <> void patch({ enabled: checked })} aria-label={t('enabled_label')} /> {domain.enabled ? t('enabled_on') : t('enabled_off')} setLocalPart(e.target.value)} className="max-w-[10rem] font-mono" /> @{domain.domain} setSenderName(e.target.value)} placeholder={companyName ?? ''} className="max-w-xs" />

{t('preview_label')}{' '} {effectiveName ? `${effectiveName} <${previewAddress}>` : previewAddress}

{domain.verified_at ? t('verified_description_with_date', { date: formatDateLong(domain.verified_at) }) : t('verified_description')}

) : (

{t('dns_instructions')}

{records.length > 0 ? (
{records.map((r, i) => ( ))}
{t('dns_type')} {t('dns_name')} {t('dns_value')} {t('dns_status')}
{r.type} {r.name} {r.value} {r.status}
) : (

{t('dns_empty')}

)} {t('fallback_note')}
)} )}
) }