This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user