diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index a21bed1..81184a8 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -12,6 +12,7 @@ import { DynamicList } from '@/components/lists/DynamicList'; import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { AlertsPage } from '@/features/alerts/AlertsPage'; import { CertificateSetupCard } from '@/features/certificates/CertificateSetupCard'; +import { CertificateOrdersPanel } from '@/features/certificates/CertificateOrdersPanel'; import { DirectorySetupCard } from '@/features/directory/DirectorySetupCard'; import { WebhooksPage } from '@/features/events/WebhooksPage'; import { WebhookTestCard } from '@/features/events/WebhookTestCard'; @@ -171,6 +172,10 @@ const WIZARDS: Record = { () => import('@/features/certificates/CertificateSetupPage'), (m) => m.CertificateSetupPage, ), + 'certificate-order': onDemand( + () => import('@/features/certificates/OrderCertificatePage'), + (m) => m.OrderCertificatePage, + ), }; const stacked = (...parts: ReactNode[]) => createElement('div', { className: 'space-y-4' }, ...parts); @@ -206,7 +211,7 @@ const limitsList = (object: LimitObject) => () => /** Lists that open with a guide or a summary above them, or have a page of their own. */ const LIST_PAGES: Record ReactNode> = { - 'x:Certificate': listUnder(), + 'x:Certificate': listUnder(stacked(, )), 'x:AcmeProvider': listUnder(), // Received reports open with who sends as us and whether they pass. 'x:DmarcExternalReport': listUnder(), diff --git a/src/features/certificates/CertificateOrdersPanel.tsx b/src/features/certificates/CertificateOrdersPanel.tsx new file mode 100644 index 0000000..ff79ad2 --- /dev/null +++ b/src/features/certificates/CertificateOrdersPanel.tsx @@ -0,0 +1,238 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: above the Certificates list, every certificate the server orders + * by itself (#167): which domain renews it, when, why the last order failed, + * and Order again. + */ + +import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { AlertTriangle, CheckCircle2, Clock, Loader2, Plus, RotateCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { getAccountId, jmapSet } from '@/services/jmap/client'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { cn } from '@/lib/utils'; +import { orderNames } from './names'; +import { canOrderAgain, orderAgainSet, renewalState, type RenewalState } from './orders'; +import { coveringCert, fmtDate, type CertRow, type Loaded, load, pollOrders, refusal } from './shared'; +import { ORDER_WIZARD_VIEW } from './CertificateSetupCard'; + +interface State { + data: Loaded; + certs: CertRow[]; + tasks: Record[]; + /** False when renewal tasks can't be read (no permission): then nothing is known about them. */ + tasksKnown: boolean; + /** When this was read, for telling a renewal due soon from one due later. */ + at: number; +} + +export function CertificateOrdersPanel({ className }: { className?: string }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const viewToSection = useSchemaStore((s) => s.viewToSection); + const section = viewToSection['x:Certificate'] ?? 'Settings'; + const domainSection = viewToSection['x:Domain'] ?? 'Management'; + const [state, setState] = useState(null); + const [failed, setFailed] = useState(false); + const [busy, setBusy] = useState(null); + const [errors, setErrors] = useState>({}); + const [following, setFollowing] = useState(false); + const timer = useRef | null>(null); + + const refresh = useCallback(async () => { + try { + const data = await load(); + // Tasks need their own permission; without it the panel still lists the orders. + const orders = await pollOrders().catch(() => null); + setState({ + data, + certs: orders?.certs ?? data.certs, + tasks: orders?.tasks ?? [], + tasksKnown: orders !== null, + at: Date.now(), + }); + } catch { + setFailed(true); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => () => void (timer.current && clearInterval(timer.current)), []); + + /** After Order again, reads again every few seconds for a minute, so the outcome shows. */ + const follow = useCallback(() => { + if (timer.current) clearInterval(timer.current); + const began = Date.now(); + setFollowing(true); + timer.current = setInterval(() => { + if (Date.now() - began > 60_000) { + clearInterval(timer.current!); + timer.current = null; + setFollowing(false); + return; + } + void refresh(); + }, 5000); + }, [refresh]); + + const orderAgain = async (domainId: string, taskId: string | undefined) => { + setBusy(domainId); + setErrors((e) => ({ ...e, [domainId]: '' })); + try { + const [res] = await jmapSet('x:Task', getAccountId('x:Task'), orderAgainSet(domainId, taskId)); + const why = refusal(res?.[1], 'The order could not be started.'); + if (why) throw new Error(why); + await refresh(); + follow(); + } catch (e) { + setErrors((s) => ({ ...s, [domainId]: e instanceof Error ? e.message : String(e) })); + } finally { + setBusy(null); + } + }; + + // Nothing to show when the server can't be read; the list below says why. + if (failed) return null; + + const orderButton = ( + + ); + + if (!state) { + return ( +
+ + {t('certOrders.loading', 'Reading automatic certificates…')} +
+ ); + } + + const automatic = state.data.domains.filter((d) => d.automatic); + + return ( +
+
+
+

{t('certOrders.title', 'Ordered automatically')}

+

+ {automatic.length === 0 + ? t('certOrders.none', 'No certificate is ordered automatically yet.') + : t( + 'certOrders.hint', + 'Each one belongs to a domain, which renews it before it expires. Connections that ask for one of its names get it.', + )} +

+
+ {following && } + {orderButton} +
+ {automatic.length > 0 && ( +
    + {automatic.map((d) => { + const method = d.dnsAutomatic ? 'dns' : 'tls'; + const names = orderNames(d.name, method, state.data.serverName, d.zone, d.hostnames); + const cert = coveringCert(state.certs, names); + const task = state.tasks.find((x) => x.domainId === d.id); + const renewal = renewalState(task); + const isDefault = !!cert && cert.id === state.data.defaultCertificateId; + return ( +
  • +
    +
    +

    {names.join(', ')}

    +

    + + {d.name} + + {' · '} + {cert + ? t('certOrders.validUntil', 'valid until {{date}}', { date: fmtDate(cert.notValidAfter) }) + : t('certOrders.noCert', 'not issued yet')} + {isDefault && ` · ${t('certOrders.default', 'default certificate')}`} +

    +
    + {state.tasksKnown && canOrderAgain(renewal) && ( + + )} +
    + {state.tasksKnown && } + {errors[d.id] &&

    {errors[d.id]}

    } +
  • + ); + })} +
+ )} +
+ ); +} + +function RenewalLine({ state, hasCert, now }: { state: RenewalState; hasCert: boolean; now: number }) { + const { t } = useTranslation(); + const line = (icon: ReactNode, text: string, tone = 'text-muted-foreground') => ( +

+ {icon} + {text} +

+ ); + switch (state.kind) { + case 'failed': + return line( + , + t('certOrders.failed', 'The last order failed{{at}}: {{reason}}', { + at: state.at ? ` ${t('certOrders.on', 'on {{date}}', { date: fmtDate(state.at) })}` : '', + reason: state.reason || t('certOrders.noReason', 'no reason given'), + }), + 'text-destructive', + ); + case 'retry': + return line( + , + t('certOrders.retry', 'Failed, trying again {{when}}: {{reason}}', { + when: state.due + ? t('certOrders.on', 'on {{date}}', { date: fmtDate(state.due) }) + : t('certOrders.soon', 'soon'), + reason: state.reason || t('certOrders.noReason', 'no reason given'), + }), + 'text-amber-700 dark:text-amber-500', + ); + case 'pending': { + const later = !!state.due && new Date(state.due).getTime() > now + 3600_000; + return line( + , + !later + ? t('certOrders.ordering', 'Ordering now.') + : hasCert + ? t('certOrders.renews', 'Renews on {{date}}.', { date: fmtDate(state.due!) }) + : t('certOrders.next', 'Next order on {{date}}.', { date: fmtDate(state.due!) }), + ); + } + default: + return line( + , + t('certOrders.nothingQueued', 'No renewal is queued. Order again queues one.'), + 'text-amber-700 dark:text-amber-500', + ); + } +} diff --git a/src/features/certificates/CertificateSetupCard.tsx b/src/features/certificates/CertificateSetupCard.tsx index 8af6c9a..d6664ac 100644 --- a/src/features/certificates/CertificateSetupCard.tsx +++ b/src/features/certificates/CertificateSetupCard.tsx @@ -14,6 +14,7 @@ import { useSchemaStore } from '@/stores/schemaStore'; import { cn } from '@/lib/utils'; export const CERTIFICATE_WIZARD_VIEW = 'Wizard/certificates'; +export const ORDER_WIZARD_VIEW = 'Wizard/certificate-order'; /** "Guided or manual?" for automatic certificates. Manual opens the domains, where the setting lives. */ export function CertificateLaunchChoice({ diff --git a/src/features/certificates/CertificateSetupPage.tsx b/src/features/certificates/CertificateSetupPage.tsx index c7d86d3..3847e03 100644 --- a/src/features/certificates/CertificateSetupPage.tsx +++ b/src/features/certificates/CertificateSetupPage.tsx @@ -15,7 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { AlertTriangle, CheckCircle2, CircleDashed, Loader2, RefreshCw, XCircle } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, Loader2, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; @@ -23,99 +23,32 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { WizardNote, WizardShell, type WizardStep } from '@/components/wizard/WizardShell'; -import { getAccountId, jmapQueryAndGet, jmapRequest, jmapSet } from '@/services/jmap/client'; +import { getAccountId, jmapSet } from '@/services/jmap/client'; import { useSchemaStore } from '@/stores/schemaStore'; import type { JmapSetResponse } from '@/types/jmap'; -import { dohQuery, RESOLVER_NAME } from '@/features/dns/liveCheck'; -import { parseZone } from '@/features/dns/zone'; +import { RESOLVER_NAME } from '@/features/dns/liveCheck'; import { cn } from '@/lib/utils'; -import { classify, orderNames, type Method, type NameState } from './names'; +import { classify, orderNames, type Method } from './names'; +import { + addProvider, + CHALLENGE, + type CertRow, + type Check, + coveringCert, + currentCert, + type DomainRow, + fmtDate, + LE_PRODUCTION, + LE_STAGING, + type Loaded, + load, + pollOrders, + resolveAddresses, +} from './shared'; +import { StateIcon } from './StateIcon'; -const LE_PRODUCTION = 'https://acme-v02.api.letsencrypt.org/directory'; -const LE_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory'; -const CHALLENGE: Record = { dns: 'Dns01', tls: 'TlsAlpn01' }; const WATCH_MS = 5 * 60_000; -interface DomainRow { - id: string; - name: string; - automatic: boolean; - providerId?: string; - /** The domain's own certificate hostnames, kept as they are when it's switched. */ - hostnames: string[]; - dnsAutomatic: boolean; - zone: ReturnType; -} - -interface ProviderRow { - id: string; - directory: string; - challengeType: string; - contact: string[]; -} - -interface CertRow { - sans: string[]; - notValidBefore: string; - notValidAfter: string; -} - -interface Loaded { - domains: DomainRow[]; - providers: ProviderRow[]; - certs: CertRow[]; - serverName: string; -} - -type Check = { name: string; state: NameState | 'checking' | 'error'; addresses: string[] }; - -async function load(): Promise { - const get = (obj: string, args: Record, id: string) => - [`${obj}/get`, { accountId: getAccountId(obj), ...args }, id] as [string, Record, string]; - const res = await jmapRequest([ - get('x:Domain', { ids: null, properties: ['name', 'certificateManagement', 'dnsManagement', 'dnsZoneFile'] }, 'd'), - get('x:AcmeProvider', { ids: null, properties: ['directory', 'challengeType', 'contact'] }, 'p'), - get( - 'x:Certificate', - { ids: null, properties: ['subjectAlternativeNames', 'notValidBefore', 'notValidAfter'] }, - 'c', - ), - get('x:SystemSettings', { ids: ['singleton'], properties: ['defaultHostname'] }, 's'), - ]); - const list = (id: string) => - (res.find(([, , i]) => i === id)?.[1] as { list?: Record[] } | undefined)?.list ?? []; - const keys = (v: unknown) => (v && typeof v === 'object' ? Object.keys(v as object) : []); - return { - domains: list('d') - .map((d) => { - const cm = (d.certificateManagement ?? {}) as Record; - const dm = (d.dnsManagement ?? {}) as Record; - return { - id: String(d.id), - name: String(d.name), - automatic: cm['@type'] === 'Automatic', - providerId: cm.acmeProviderId as string | undefined, - hostnames: cm['@type'] === 'Automatic' ? keys(cm.subjectAlternativeNames) : [], - dnsAutomatic: dm['@type'] === 'Automatic', - zone: parseZone(d.dnsZoneFile as string | undefined), - }; - }) - .sort((a, b) => a.name.localeCompare(b.name)), - providers: list('p').map((p) => ({ - id: String(p.id), - directory: String(p.directory ?? ''), - challengeType: String(p.challengeType ?? ''), - contact: keys(p.contact).map((c) => c.replace(/^mailto:/, '')), - })), - certs: list('c').map((c) => ({ - sans: keys(c.subjectAlternativeNames).map((s) => s.toLowerCase()), - notValidBefore: String(c.notValidBefore ?? ''), - notValidAfter: String(c.notValidAfter ?? ''), - })), - serverName: String(list('s')[0]?.defaultHostname ?? ''), - }; -} - /** "a and b" for the default pair, "a, b, c" for a domain's own list. */ const joinNames = (names: string[]) => (names.length === 2 ? names.join(' and ') : names.join(', ')); @@ -124,41 +57,6 @@ async function switchedAt(): Promise { return Date.now(); } -/** The newest certificate covering every one of these names. */ -function coveringCert(certs: CertRow[], names: string[]): CertRow | undefined { - return certs - .filter((c) => names.every((n) => c.sans.includes(n.toLowerCase()))) - .sort((a, b) => b.notValidAfter.localeCompare(a.notValidAfter))[0]; -} - -/** The newest certificate covering any of these names: what the domain uses today. */ -function currentCert(certs: CertRow[], names: string[]): CertRow | undefined { - return certs - .filter((c) => names.some((n) => c.sans.includes(n.toLowerCase()))) - .sort((a, b) => b.notValidAfter.localeCompare(a.notValidAfter))[0]; -} - -async function resolveAddresses(name: string): Promise { - const out: string[] = []; - for (const type of ['A', 'AAAA']) { - const r = await dohQuery(name.replace(/^\*\./, ''), type); - out.push(...r.answer.filter((a) => a.type === 1 || a.type === 28).map((a) => a.data)); - } - return out; -} - -function fmtDate(iso: string): string { - const d = new Date(iso); - return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { dateStyle: 'medium' }); -} - -function StateIcon({ state }: { state: Check['state'] }) { - if (state === 'ok') return ; - if (state === 'checking') return ; - if (state === 'error') return ; - return ; -} - export function CertificateSetupPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -264,35 +162,7 @@ export function CertificateSetupPage() { const needsNewProvider = methodsNeeded.some((m) => !providerFor(m)); const poll = useCallback(async () => { - const [certRes] = await jmapRequest([ - [ - 'x:Certificate/get', - { - accountId: getAccountId('x:Certificate'), - ids: null, - properties: ['subjectAlternativeNames', 'notValidBefore', 'notValidAfter'], - }, - 'c', - ], - ]); - const [, taskRes] = await jmapQueryAndGet( - 'x:Task', - getAccountId('x:Task'), - { filter: { '@type': 'AcmeRenewal' } }, - ['@type', 'domainId', 'status'], - ); - const keys = (v: unknown) => (v && typeof v === 'object' ? Object.keys(v as object) : []); - const certs = ( - ((certRes?.[1] as { list?: Record[] })?.list ?? []) as Record[] - ).map((c) => ({ - sans: keys(c.subjectAlternativeNames).map((s) => s.toLowerCase()), - notValidBefore: String(c.notValidBefore ?? ''), - notValidAfter: String(c.notValidAfter ?? ''), - })); - const tasks = ( - ((taskRes?.[1] as { list?: Record[] })?.list ?? []) as Record[] - ).filter((x) => x['@type'] === 'AcmeRenewal'); - setWatch({ certs, tasks, at: Date.now() }); + setWatch(await pollOrders()); }, []); const startWatching = useCallback(() => { @@ -324,22 +194,7 @@ export function CertificateSetupPage() { ids[m] = existing.id; continue; } - const [res] = await jmapSet('x:AcmeProvider', getAccountId('x:AcmeProvider'), { - create: { - acme: { - directory, - challengeType: CHALLENGE[m], - contact: { [contact.trim()]: true }, - description: `${staging ? 'Let’s Encrypt (staging)' : 'Let’s Encrypt'}, ${m === 'dns' ? 'DNS-01' : 'TLS-ALPN-01'}, ${contact.trim()}`, - }, - }, - }); - const body = res?.[1] as unknown as JmapSetResponse | undefined; - const id = (body?.created?.acme as { id?: string } | undefined)?.id; - if (!id) { - const e = body?.notCreated ? Object.values(body.notCreated)[0] : undefined; - throw new Error(e?.description ?? e?.type ?? 'The Let’s Encrypt account could not be added.'); - } + const id = await addProvider(m, contact, staging); ids[m] = id; made.push(id); } diff --git a/src/features/certificates/OrderCertificatePage.tsx b/src/features/certificates/OrderCertificatePage.tsx new file mode 100644 index 0000000..496eab8 --- /dev/null +++ b/src/features/certificates/OrderCertificatePage.tsx @@ -0,0 +1,748 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: "Order a certificate" (#167). Takes the names you want, finds the + * domain they belong to (or adds one just for certificates), checks the + * names, sets them as that domain's certificate hostnames, and watches the + * certificate arrive. The server orders certificates per domain, so this is + * the domain's automatic certificate with a name list of your choosing. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { AlertTriangle, CheckCircle2, Loader2, Plus, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { WizardNote, WizardShell, type WizardStep } from '@/components/wizard/WizardShell'; +import { getAccountId, jmapSet } from '@/services/jmap/client'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { RESOLVER_NAME } from '@/features/dns/liveCheck'; +import { cn } from '@/lib/utils'; +import { classify, orderNames, type Method } from './names'; +import { + domainsFor, + hostnamesFor, + isValidName, + isWildcard, + nameInDomain, + parseNames, + renewalState, + sameNames, + suggestDomain, +} from './orders'; +import { + addProvider, + CHALLENGE, + type CertRow, + type Check, + coveringCert, + type DomainRow, + fmtDate, + LE_PRODUCTION, + LE_STAGING, + type Loaded, + load, + pollOrders, + refusal, + resolveAddresses, +} from './shared'; +import { StateIcon } from './StateIcon'; + +const WATCH_MS = 5 * 60_000; + +export function OrderCertificatePage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const viewToSection = useSchemaStore((s) => s.viewToSection); + const section = viewToSection['x:Certificate'] ?? 'Settings'; + const domainSection = viewToSection['x:Domain'] ?? 'Management'; + + const [data, setData] = useState(null); + const [loadError, setLoadError] = useState(null); + const [step, setStep] = useState(0); + const [text, setText] = useState(''); + const [domainId, setDomainId] = useState(null); + const [newDomain, setNewDomain] = useState(null); + const [checks, setChecks] = useState([]); + const [serverAddresses, setServerAddresses] = useState(null); + const [anyway, setAnyway] = useState(false); + const [staging, setStaging] = useState(false); + const [contact, setContact] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [startedAt, setStartedAt] = useState(null); + const [watch, setWatch] = useState<{ certs: CertRow[]; tasks: Record[]; at: number } | null>(null); + const [watching, setWatching] = useState(false); + const [madeDefault, setMadeDefault] = useState(false); + const timer = useRef | null>(null); + + const reload = useCallback(async () => { + const d = await load(); + setData(d); + setContact((c) => c || (d.providers.find((p) => p.contact.length > 0)?.contact[0] ?? '')); + return d; + }, []); + + useEffect(() => { + let live = true; + load() + .then((d) => { + if (!live) return; + setData(d); + setContact(d.providers.find((p) => p.contact.length > 0)?.contact[0] ?? ''); + }) + .catch((e: unknown) => live && setLoadError(e instanceof Error ? e.message : String(e))); + return () => { + live = false; + }; + }, []); + + useEffect(() => () => void (timer.current && clearInterval(timer.current)), []); + + const steps: WizardStep[] = useMemo( + () => [ + { id: 'names', title: t('certOrder.stepNames', 'Names') }, + { id: 'check', title: t('certSetup.stepCheck', 'Check names') }, + { id: 'account', title: t('certSetup.stepAccount', 'Account') }, + { id: 'review', title: t('certSetup.stepReview', 'Review') }, + { id: 'watch', title: t('certOrder.stepWatch', 'Watch it arrive') }, + ], + [t], + ); + + const names = useMemo(() => parseNames(text), [text]); + const invalid = names.filter((n) => !isValidName(n)); + const candidates = useMemo(() => (data ? domainsFor(names, data.domains) : []), [data, names]); + // The closest domain unless one was picked and still fits. + const domain: DomainRow | undefined = candidates.find((d) => d.id === domainId) ?? candidates[0]; + const method: Method = domain?.dnsAutomatic ? 'dns' : 'tls'; + const wildcardBlocked = method === 'tls' && names.some(isWildcard); + const currentNames = domain?.automatic + ? orderNames(domain.name, method, data?.serverName ?? '', domain.zone, domain.hostnames) + : []; + const unchanged = !!domain?.automatic && sameNames(currentNames, names); + const replaced = domain?.automatic && !unchanged ? currentNames.filter((n) => !names.includes(n)) : []; + + const directory = staging ? LE_STAGING : LE_PRODUCTION; + const provider = data?.providers.find((p) => p.directory === directory && p.challengeType === CHALLENGE[method]); + + const runChecks = useCallback(async () => { + if (!data || method !== 'tls') return; + setChecks(names.map((name) => ({ name, state: 'checking', addresses: [] }))); + const anchor = + data.serverName || + domain?.zone + .find((r) => r.type === 'MX') + ?.value.split(/\s+/) + .pop() || + ''; + const mine = anchor ? await resolveAddresses(anchor).catch(() => []) : []; + setServerAddresses(mine); + const results = await Promise.all( + names.map(async (name): Promise => { + try { + const addresses = await resolveAddresses(name); + return { + name, + state: mine.length > 0 ? classify(addresses, mine) : addresses.length ? 'ok' : 'missing', + addresses, + }; + } catch { + return { name, state: 'error', addresses: [] }; + } + }), + ); + setChecks(results); + }, [data, domain, method, names]); + + const poll = useCallback(async () => { + setWatch(await pollOrders()); + }, []); + + const startWatching = useCallback(() => { + if (timer.current) clearInterval(timer.current); + const began = Date.now(); + setWatching(true); + void poll().catch(() => undefined); + timer.current = setInterval(() => { + if (Date.now() - began > WATCH_MS) { + clearInterval(timer.current!); + timer.current = null; + setWatching(false); + return; + } + void poll().catch(() => undefined); + }, 5000); + }, [poll]); + + /** Adds a domain that's only there for certificates: no DKIM keys, nothing published. */ + const addDomain = async (name: string) => { + setBusy(true); + setError(null); + try { + const [res] = await jmapSet('x:Domain', getAccountId('x:Domain'), { + create: { + d: { + name, + description: t('certOrder.domainDescription', 'Added for certificates'), + dkimManagement: { '@type': 'Manual' }, + dnsManagement: { '@type': 'Manual' }, + certificateManagement: { '@type': 'Manual' }, + }, + }, + }); + const failed = refusal(res?.[1], 'The domain could not be added.'); + if (failed) throw new Error(failed); + const id = (res?.[1] as { created?: Record })?.created?.d?.id; + await reload(); + if (id) setDomainId(id); + setNewDomain(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const apply = async () => { + if (!data || !domain) return; + setBusy(true); + setError(null); + let made: string | null = null; + try { + let providerId = provider?.id; + if (!providerId) { + providerId = await addProvider(method, contact, staging); + made = providerId; + } + const [res] = await jmapSet('x:Domain', getAccountId('x:Domain'), { + update: { + [domain.id]: { + certificateManagement: { + '@type': 'Automatic', + acmeProviderId: providerId, + subjectAlternativeNames: hostnamesFor(names), + }, + }, + }, + }); + const failed = refusal(res?.[1], 'The certificate could not be ordered.'); + if (failed) throw new Error(failed); + setStartedAt(Date.now()); + setStep(4); + startWatching(); + } catch (e) { + // Failsafe: an account added for this order is removed again when nothing uses it. + if (made) + await jmapSet('x:AcmeProvider', getAccountId('x:AcmeProvider'), { destroy: [made] }).catch(() => undefined); + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const makeDefault = async (certId: string) => { + setBusy(true); + setError(null); + try { + const [res] = await jmapSet('x:SystemSettings', getAccountId('x:SystemSettings'), { + update: { singleton: { defaultCertificateId: certId } }, + }); + const failed = refusal(res?.[1], 'The default certificate could not be changed.'); + if (failed) throw new Error(failed); + setMadeDefault(true); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + if (loadError) return
{loadError}
; + if (!data) return ; + + const listView = `/${section}/x:Certificate`; + const common = { + icon: 'shield-check', + title: t('certOrder.title', 'Order a certificate'), + subtitle: t( + 'certOrder.subtitle', + 'A free Let’s Encrypt certificate for the names you choose, renewed by the server before it expires.', + ), + steps, + current: domain ? step : 0, + onCancel: () => navigate(listView), + }; + const errorLine = error && ( +

+ + {error} +

+ ); + const how = method === 'dns' ? t('certSetup.howDnsWord', 'through DNS') : t('certSetup.howTlsWord', 'on port 443'); + + // ── Step 1: names (also where it goes back to if the domain stops fitting) ── + if (step === 0 || !domain) { + const ready = names.length > 0 && invalid.length === 0; + const suggestion = newDomain ?? suggestDomain(names); + return ( + { + setAnyway(false); + setStep(1); + void runChecks(); + }} + aside={ + <> + +

+ {t( + 'certOrder.oneDomain', + 'The server orders each certificate for a domain, and renews it for as long as the domain says so. The names you enter become that domain’s certificate hostnames.', + )} +

+
+ +

+ {t( + 'certOrder.howDns', + 'When the domain’s DNS is connected, the names are checked through DNS, and wildcards work. Otherwise Let’s Encrypt connects to this server on port 443 for each name.', + )} +

+
+ + } + > +
+ +