From 41b9f48a8943e101ac67aa378c096f5b7801dee7 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 6 Nov 2026 17:54:33 -0700 Subject: [PATCH 1/3] Certificates: order one with the names you choose, and order again "Order a certificate" on the Certificates page takes the names you want, finds the domain they belong to (or adds one just for certificates, with no DKIM keys and nothing published), checks the names the way the existing guide does, and sets them as that domain's certificate hostnames. The server orders per domain, so that's how a certificate with chosen names is ordered. Once it's issued the guide offers to make it the default certificate; connections asking for its names get it without that. Above the list, every automatic certificate shows the domain that renews it, the next renewal, and why the last order failed. Order again tells the queued renewal to run now; it only creates one when none is queued, since a second would keep renewing for good (inbuxa-server#290). The existing guide's loading, polling and name lookups move to shared.ts so both guides use them. (#167) --- src/components/layout/MainContent.tsx | 7 +- .../certificates/CertificateOrdersPanel.tsx | 238 ++++++ .../certificates/CertificateSetupCard.tsx | 1 + .../certificates/CertificateSetupPage.tsx | 191 +---- .../certificates/OrderCertificatePage.tsx | 748 ++++++++++++++++++ src/features/certificates/StateIcon.tsx | 15 + src/features/certificates/orders.test.ts | 118 +++ src/features/certificates/orders.ts | 128 +++ src/features/certificates/shared.ts | 190 +++++ src/i18n/en.json | 55 ++ src/lib/layout.ts | 1 + src/lib/layoutRules.test.ts | 1 + 12 files changed, 1524 insertions(+), 169 deletions(-) create mode 100644 src/features/certificates/CertificateOrdersPanel.tsx create mode 100644 src/features/certificates/OrderCertificatePage.tsx create mode 100644 src/features/certificates/StateIcon.tsx create mode 100644 src/features/certificates/orders.test.ts create mode 100644 src/features/certificates/orders.ts create mode 100644 src/features/certificates/shared.ts 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.', + )} +

+
+ + } + > +
+ +