Add custom domain support
Docker / build-and-push-image (push) Successful in 1m41s

This commit is contained in:
Jon Staab
2026-06-11 14:28:12 -07:00
parent bd3217f43d
commit 90f5a55269
20 changed files with 629 additions and 13 deletions
+74 -1
View File
@@ -1,16 +1,20 @@
import { Show, createSignal } from "solid-js"
import type { Relay, PlanId } from "@/lib/api"
import { RELAY_DOMAIN } from "@/lib/subdomain"
import ConfirmDialog from "@/components/ConfirmDialog"
import CustomDomainModal from "@/components/relay/CustomDomainModal"
import Field from "@/components/Field"
import PricingTable from "@/components/PricingTable"
import ToggleButton from "@/components/ToggleButton"
import ToggleField from "@/components/ToggleField"
import RelayCardHeader from "@/components/relay/RelayCardHeader"
import RelayCardHeader, { StatusBadge } from "@/components/relay/RelayCardHeader"
import PlanGatedToggle from "@/components/relay/PlanGatedToggle"
import { setToastMessage } from "@/lib/state"
import { useProfileMetadata } from "@/lib/hooks"
import useMinLoading from "@/lib/useMinLoading"
import { flagToBool } from "@/lib/relayFlags"
import { plans } from "@/lib/state"
import useCustomDomain from "@/lib/useCustomDomain"
function DetailSection(props: { title: string; children: any }) {
return (
@@ -53,12 +57,17 @@ type RelayDetailCardProps = {
onUpdatePlan?: (planId: PlanId) => Promise<void>
enforcePlanLimits?: boolean
showPlanActions?: boolean
mutateRelay?: (relay: Relay) => void
}
export default function RelayDetailCard(props: RelayDetailCardProps) {
const r = () => props.relay
const [planId, setPlanId] = createSignal<PlanId>(props.relay.plan_id)
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
const [customDomainModalOpen, setCustomDomainModalOpen] = createSignal(false)
const { saving: cdSaving, verifying: cdVerifying, error: cdError, saveDomain, verifyDomain } =
useCustomDomain(() => props.relay.id, props.mutateRelay ?? (() => {}))
const cdVerifyingVisible = useMinLoading(cdVerifying)
// Resolve the owning tenant's profile so the Tenant field can show a name and
// avatar instead of a raw pubkey. Only relevant in admin (showTenant) views.
@@ -134,6 +143,70 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
reactivating={props.reactivating}
onRequestDeactivate={props.onDeactivate ? () => openActionDialog("deactivate") : undefined}
onRequestReactivate={props.onReactivate ? () => openActionDialog("reactivate") : undefined}
onRequestManageCustomDomain={() => setCustomDomainModalOpen(true)}
/>
<hr class="border-gray-200" />
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider mb-6">Custom Domain</h3>
<div class="space-y-3">
<div class="flex items-center justify-between gap-2">
<Show
when={r().custom_domain}
fallback={<span class="text-gray-400 text-sm">Not configured</span>}
>
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{r().custom_domain}</span>
<Show when={r().custom_domain_verified === 1}>
<StatusBadge status="verified" />
</Show>
</div>
</Show>
<button
class="inline-flex items-center rounded-lg border border-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() => setCustomDomainModalOpen(true)}
>
Update
</button>
</div>
<Show when={r().custom_domain && r().custom_domain_verified !== 1}>
<div class="rounded-lg border border-yellow-200 bg-yellow-50 px-4 py-3 space-y-3">
<div class="flex items-start gap-2">
<span class="mt-0.5 text-yellow-600 text-sm shrink-0"></span>
<p class="text-sm font-medium text-yellow-800">
Not yet verified add this DNS record, then verify:
</p>
</div>
<div class="rounded border border-yellow-200 bg-white px-3 py-2 font-mono text-xs text-gray-700 break-all">
{r().custom_domain} CNAME {r().subdomain}.{RELAY_DOMAIN}
</div>
<p class="text-xs text-yellow-700">
For apex domains (e.g. example.com), use an ALIAS or ANAME record instead.
</p>
<Show when={cdError()}>
<p class="text-sm text-red-600">{cdError()}</p>
</Show>
<button
type="button"
onClick={verifyDomain}
disabled={cdVerifyingVisible()}
class="inline-flex items-center rounded-lg bg-yellow-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-yellow-700 disabled:opacity-50"
>
{cdVerifyingVisible() ? "Verifying…" : "Verify DNS record"}
</button>
</div>
</Show>
</div>
</div>
<CustomDomainModal
open={customDomainModalOpen()}
onClose={() => setCustomDomainModalOpen(false)}
relay={r}
saving={cdSaving}
error={cdError}
onSave={saveDomain}
/>
<hr class="border-gray-200" />
@@ -0,0 +1,93 @@
import { Show, createEffect, createSignal } from "solid-js"
import Modal from "@/components/Modal"
import type { Relay } from "@/lib/api"
type Props = {
open: boolean
onClose: () => void
relay: () => Relay | undefined
saving: () => boolean
error: () => string | undefined
onSave: (domain: string) => Promise<void>
}
export default function CustomDomainModal(props: Props) {
const [input, setInput] = createSignal("")
createEffect(() => {
if (props.open) {
setInput(props.relay()?.custom_domain ?? "")
}
})
const current = () => props.relay()?.custom_domain ?? ""
const inputTrimmed = () => input().trim()
async function handleSubmit(e: Event) {
e.preventDefault()
await props.onSave(inputTrimmed())
if (!props.error()) props.onClose()
}
return (
<Modal
open={props.open}
onClose={props.onClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
panelClass="w-full max-w-lg bg-white rounded-2xl shadow-xl p-6 space-y-5"
>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Custom domain</h2>
<button
type="button"
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<form onSubmit={handleSubmit} class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Custom domain</label>
<input
type="text"
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
placeholder="relay.example.com"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
autocomplete="off"
autocapitalize="none"
spellcheck={false}
/>
<p class="mt-1 text-xs text-gray-500">
Must be a domain you control, e.g. <span class="font-mono">relay.example.com</span>
</p>
</div>
<Show when={props.error()}>
<p class="text-sm text-red-600">{props.error()}</p>
</Show>
<div class="flex items-center gap-3 flex-wrap">
<button
type="submit"
disabled={props.saving() || !inputTrimmed() || inputTrimmed() === current()}
class="inline-flex items-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{props.saving() ? "Saving…" : "Save domain"}
</button>
<Show when={current()}>
<button
type="button"
onClick={() => props.onSave("")}
disabled={props.saving()}
class="inline-flex items-center rounded-lg border border-red-200 px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50 disabled:opacity-50"
>
{props.saving() ? "Removing…" : "Remove custom domain"}
</button>
</Show>
</div>
</form>
</Modal>
)
}
@@ -9,6 +9,7 @@ import { RELAY_DOMAIN } from "@/lib/subdomain"
const STATUS_STYLES: Record<string, string> = {
active: "bg-green-50 text-green-700 border-green-200",
inactive: "bg-gray-100 text-gray-500 border-gray-200",
verified: "bg-green-50 text-green-700 border-green-200",
}
export function StatusBadge(props: { status: string }) {
@@ -47,6 +48,7 @@ type RelayCardHeaderProps = {
reactivating?: boolean
onRequestDeactivate?: () => void
onRequestReactivate?: () => void
onRequestManageCustomDomain?: () => void
}
export default function RelayCardHeader(props: RelayCardHeaderProps) {
@@ -127,7 +129,7 @@ export default function RelayCardHeader(props: RelayCardHeaderProps) {
</div>
</div>
<Show when={props.editHref && (props.onRequestDeactivate || props.onRequestReactivate)}>
<Show when={props.editHref || props.onRequestDeactivate || props.onRequestReactivate || props.onRequestManageCustomDomain}>
<div class="relative shrink-0" ref={menuContainerRef}>
<button
type="button"
@@ -145,13 +147,27 @@ export default function RelayCardHeader(props: RelayCardHeaderProps) {
"opacity-0 scale-95 pointer-events-none": !menuOpen(),
}}
>
<A
href={props.editHref!}
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => setMenuOpen(false)}
>
Edit Details
</A>
<Show when={props.editHref}>
<A
href={props.editHref!}
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => setMenuOpen(false)}
>
Edit Details
</A>
</Show>
<Show when={props.onRequestManageCustomDomain}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => {
setMenuOpen(false)
props.onRequestManageCustomDomain?.()
}}
>
Manage custom domain
</button>
</Show>
<Show when={r().status === "active" && props.onRequestDeactivate}>
<button
type="button"
+4
View File
@@ -50,6 +50,8 @@ export type Relay = {
status: string
sync_error: string
synced: number
custom_domain: string
custom_domain_verified: number
info_name: string
info_icon: string
info_description: string
@@ -76,6 +78,7 @@ export type CreateRelayInput = {
blossom_enabled?: number
livekit_enabled?: number
push_enabled?: number
custom_domain?: string
}
export type UpdateRelayInput = {
@@ -91,6 +94,7 @@ export type UpdateRelayInput = {
blossom_enabled?: number
livekit_enabled?: number
push_enabled?: number
custom_domain?: string
}
export type Tenant = {
+39
View File
@@ -0,0 +1,39 @@
import { createSignal } from "solid-js"
import type { Relay } from "@/lib/api"
import { getRelay, updateRelay } from "@/lib/api"
export default function useCustomDomain(relayId: () => string, mutate: (relay: Relay) => void) {
const [saving, setSaving] = createSignal(false)
const [verifying, setVerifying] = createSignal(false)
const [error, setError] = createSignal<string>()
async function saveDomain(domain: string) {
setSaving(true)
setError(undefined)
try {
const updated = await updateRelay(relayId(), { custom_domain: domain })
mutate(updated)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to save domain")
} finally {
setSaving(false)
}
}
// Verification runs in a background poller on the backend; reload the relay so
// the UI picks up the latest custom_domain_verified state.
async function verifyDomain() {
setVerifying(true)
setError(undefined)
try {
const updated = await getRelay(relayId())
mutate(updated)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Verification failed")
} finally {
setVerifying(false)
}
}
return { saving, verifying, error, saveDomain, verifyDomain }
}
@@ -45,6 +45,7 @@ export default function AdminRelayDetail() {
reactivating={busy()}
enforcePlanLimits={false}
showPlanActions={false}
mutateRelay={mutate}
{...toggles}
/>
<ActivityFeed activity={activity() ?? []} loading={activity.loading} />
@@ -60,6 +60,7 @@ export default function RelayDetail() {
deactivating={busy()}
reactivating={busy()}
onUpdatePlan={handleUpdatePlan}
mutateRelay={mutate}
{...toggles}
/>
<ActivityFeed activity={activity() ?? []} loading={activity.loading} />