Show tenant links, remove plan from relay edit form

This commit is contained in:
Jon Staab
2026-06-01 16:37:39 -07:00
parent 55a0b69089
commit 93bfe8e5a4
7 changed files with 182 additions and 43 deletions
+44 -6
View File
@@ -1,5 +1,6 @@
import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import type { Relay, PlanId } from "@/lib/api"
import menuDotsIcon from "@/assets/menu-dots-2.svg"
import ConfirmDialog from "@/components/ConfirmDialog"
@@ -8,7 +9,12 @@ import PricingTable from "@/components/PricingTable"
import ToggleButton from "@/components/ToggleButton"
import ToggleField from "@/components/ToggleField"
import { setToastMessage } from "@/components/Toast"
import { plans } from "@/lib/state"
import { primeProfiles } from "@/lib/hooks"
import { eventStore, plans } from "@/lib/state"
function shortenPubkey(pubkey: string) {
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
const STATUS_STYLES: Record<string, string> = {
active: "bg-green-50 text-green-700 border-green-200",
@@ -78,6 +84,28 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
const [menuOpen, setMenuOpen] = createSignal(false)
const [planId, setPlanId] = createSignal<PlanId>(props.relay.plan_id)
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
const [tenantProfile, setTenantProfile] = createSignal<{ name?: string; picture?: string }>({})
// 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.
createEffect(() => {
if (!props.showTenant) return
const pubkey = props.relay.tenant_pubkey
if (!pubkey) return
const sub = eventStore.profile(pubkey).subscribe((metadata) => {
setTenantProfile({
name: metadata?.name || metadata?.display_name,
picture: getProfilePicture(metadata),
})
})
const primeSub = primeProfiles([pubkey])
onCleanup(() => {
sub.unsubscribe()
primeSub.unsubscribe()
})
})
let menuContainerRef: HTMLDivElement | undefined
@@ -184,6 +212,21 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
>
wss://{r().subdomain}.spaces.coracle.social
</a>
<Show when={props.showTenant}>
<A href={`/admin/tenants/${r().tenant_pubkey}`} class="group mt-1.5 flex w-fit items-center gap-2 min-w-0">
<Show
when={tenantProfile().picture}
fallback={
<div class="h-6 w-6 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{(tenantProfile().name || r().tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={tenantProfile().picture} alt="" class="h-6 w-6 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="text-sm text-blue-600 group-hover:underline truncate">{tenantProfile().name || shortenPubkey(r().tenant_pubkey)}</span>
</A>
</Show>
<Show when={r().info_description.trim()}>
<p class="mt-2 text-sm text-gray-600">{r().info_description}</p>
</Show>
@@ -358,11 +401,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
<Field label="Member limit">
<span class="text-gray-900">{memberLimitLabel()}</span>
</Field>
<Show when={props.showTenant}>
<Field label="Tenant">
<span class="font-mono text-xs break-all">{r().tenant_pubkey}</span>
</Field>
</Show>
</MembershipSection>
<Show when={showPlanActions()}>
+29 -23
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, For } from "solid-js"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import type { Relay } from "@/lib/hooks"
import { slugify } from "@/lib/slugify"
import { validateSubdomainLabel } from "@/lib/subdomain"
@@ -10,12 +10,16 @@ export type RelayFormValues = Pick<Relay, "info_name" | "subdomain" | "info_icon
type RelayFormProps = {
initialValues?: Partial<RelayFormValues>
syncSubdomainWithName?: boolean
// The plan can't be changed by editing a relay (only at creation or via the
// detail card), so the edit form hides the selector by passing false here.
showPlanSelector?: boolean
onSubmit: (values: RelayFormValues) => Promise<void> | void
submitLabel: string
submittingLabel: string
}
export default function RelayForm(props: RelayFormProps) {
const showPlanSelector = () => props.showPlanSelector ?? true
const defaultPlanId = createMemo(() => props.initialValues?.plan_id ?? plans()[0]?.id ?? "free")
const [planId, setPlanId] = createSignal(defaultPlanId())
const [name, setName] = createSignal(props.initialValues?.info_name ?? "")
@@ -27,7 +31,7 @@ export default function RelayForm(props: RelayFormProps) {
async function handleSubmit(e: Event) {
e.preventDefault()
if (!planId()) {
if (showPlanSelector() && !planId()) {
setToastMessage("Please select a plan")
return
}
@@ -105,28 +109,30 @@ export default function RelayForm(props: RelayFormProps) {
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Plan</label>
<div class="grid grid-cols-3 gap-3">
<For each={plans()}>
{(p) => (
<button
type="button"
onClick={() => setPlanId(p.id)}
class={`border-2 rounded-xl p-4 text-left transition-colors ${planId() === p.id ? "border-blue-600 bg-blue-50" : "border-gray-200 hover:border-gray-300"}`}
>
<div class="font-bold text-gray-900">{p.name}</div>
<div class="text-sm text-gray-500 mt-1">
{p.amount === 0 ? "Free" : `$${p.amount / 100}/mo`}
</div>
<div class="text-xs text-gray-500 mt-2">
{p.members === null ? "Unlimited members" : `Up to ${p.members} members`}
</div>
</button>
)}
</For>
<Show when={showPlanSelector()}>
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Plan</label>
<div class="grid grid-cols-3 gap-3">
<For each={plans()}>
{(p) => (
<button
type="button"
onClick={() => setPlanId(p.id)}
class={`border-2 rounded-xl p-4 text-left transition-colors ${planId() === p.id ? "border-blue-600 bg-blue-50" : "border-gray-200 hover:border-gray-300"}`}
>
<div class="font-bold text-gray-900">{p.name}</div>
<div class="text-sm text-gray-500 mt-1">
{p.amount === 0 ? "Free" : `$${p.amount / 100}/mo`}
</div>
<div class="text-xs text-gray-500 mt-2">
{p.members === null ? "Unlimited members" : `Up to ${p.members} members`}
</div>
</button>
)}
</For>
</div>
</div>
</div>
</Show>
<button
type="submit"
disabled={submitting()}
+41 -5
View File
@@ -1,6 +1,8 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import { createEffect, createSignal, onCleanup, Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import type { Relay } from "@/lib/api"
import { eventStore } from "@/lib/state"
type RelayListItemProps = {
relay: Relay
@@ -8,17 +10,51 @@ type RelayListItemProps = {
showTenant?: boolean
}
function shortenPubkey(pubkey: string) {
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
export default function RelayListItem(props: RelayListItemProps) {
const [tenantProfile, setTenantProfile] = createSignal<{ name?: string; picture?: string }>({})
// Resolve the owning tenant's profile from the event store. The list that
// passes `showTenant` is responsible for priming these profiles in one batch.
createEffect(() => {
if (!props.showTenant) return
const pubkey = props.relay.tenant_pubkey
if (!pubkey) return
const sub = eventStore.profile(pubkey).subscribe((metadata) => {
setTenantProfile({
name: metadata?.name || metadata?.display_name,
picture: getProfilePicture(metadata),
})
})
onCleanup(() => sub.unsubscribe())
})
return (
<li>
<A href={props.href} class="block rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300">
<div class="flex items-center justify-between gap-3">
<div>
<div class="min-w-0">
<p class="font-medium text-gray-900">{props.relay.info_name || props.relay.subdomain}</p>
<p class="text-xs text-gray-500">{props.relay.subdomain}.spaces.coracle.social</p>
{props.showTenant && (
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant_pubkey}</p>
)}
<Show when={props.showTenant}>
<div class="mt-1.5 flex items-center gap-2">
<Show
when={tenantProfile().picture}
fallback={
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{(tenantProfile().name || props.relay.tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={tenantProfile().picture} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="text-xs text-gray-500 truncate">{tenantProfile().name || shortenPubkey(props.relay.tenant_pubkey)}</span>
</div>
</Show>
</div>
<Show
when={props.relay.sync_error}
+11 -2
View File
@@ -1,17 +1,26 @@
import Fuse from "fuse.js"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import PageContainer from "@/components/PageContainer"
import RelayListItem from "@/components/RelayListItem"
import ResourceState from "@/components/ResourceState"
import SearchInput from "@/components/SearchInput"
import useMinLoading from "@/components/useMinLoading"
import { useAdminRelays } from "@/lib/hooks"
import { primeProfiles, useAdminRelays } from "@/lib/hooks"
export default function AdminRelayList() {
const [query, setQuery] = createSignal("")
const [relays] = useAdminRelays()
const loading = useMinLoading(() => relays.loading)
// Each list item shows its tenant's profile; prime them all in one batch so
// we don't open a separate outbox subscription per relay.
createEffect(() => {
const list = relays() ?? []
if (!list.length) return
const sub = primeProfiles(list.map(r => r.tenant_pubkey))
onCleanup(() => sub.unsubscribe())
})
const filtered = createMemo(() => {
const list = relays() ?? []
const q = query().trim()
+53 -6
View File
@@ -1,11 +1,17 @@
import { useParams } from "@solidjs/router"
import { For, Show } from "solid-js"
import { createEffect, createSignal, For, onCleanup, Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import BackLink from "@/components/BackLink"
import PageContainer from "@/components/PageContainer"
import RelayListItem from "@/components/RelayListItem"
import ResourceState from "@/components/ResourceState"
import useMinLoading from "@/components/useMinLoading"
import { useAdminTenant, useAdminTenantRelays } from "@/lib/hooks"
import { primeProfiles, useAdminTenant, useAdminTenantRelays } from "@/lib/hooks"
import { eventStore } from "@/lib/state"
function shortenPubkey(pubkey: string) {
return pubkey.length <= 16 ? pubkey : `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
export default function AdminTenantDetail() {
const params = useParams()
@@ -13,6 +19,28 @@ export default function AdminTenantDetail() {
const [tenant] = useAdminTenant(tenantId)
const [relays] = useAdminTenantRelays(tenantId)
const loading = useMinLoading(() => tenant.loading || relays.loading)
const [profile, setProfile] = createSignal<{ name?: string; picture?: string }>({})
createEffect(() => {
const pubkey = tenantId()
if (!pubkey) {
setProfile({})
return
}
const profileSub = eventStore.profile(pubkey).subscribe((metadata) => {
setProfile({
name: metadata?.name || metadata?.display_name,
picture: getProfilePicture(metadata),
})
})
const primeSub = primeProfiles([pubkey])
onCleanup(() => {
profileSub.unsubscribe()
primeSub.unsubscribe()
})
})
const churnedLabel = () => {
const ts = tenant()?.churned_at
@@ -23,7 +51,22 @@ export default function AdminTenantDetail() {
return (
<PageContainer>
<BackLink href="/admin/tenants" label="Tenants" />
<h1 class="text-2xl font-bold text-gray-900 mb-6 py-2">Tenant {params.id}</h1>
<div class="flex items-center gap-4 mb-6 py-2">
<Show
when={profile().picture}
fallback={
<div class="h-14 w-14 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-lg">
{(profile().name || tenantId()).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={profile().picture} alt="Profile" class="h-14 w-14 flex-shrink-0 rounded-full object-cover" />
</Show>
<div class="min-w-0">
<h1 class="text-2xl font-bold text-gray-900 truncate">{profile().name || shortenPubkey(tenantId())}</h1>
<p class="text-xs text-gray-500 break-all">{tenantId()}</p>
</div>
</div>
<ResourceState loading={loading()} error={tenant.error || relays.error} loadingText="Loading tenant..." errorText="Failed to load tenant." class="mb-4" />
<Show when={!loading()}>
<div class="space-y-6">
@@ -32,14 +75,18 @@ export default function AdminTenantDetail() {
<Show when={tenant()}>
{(t) => (
<dl class="grid gap-y-3 text-sm">
<div class="flex gap-2">
<div class="flex gap-2 items-center">
<dt class="text-gray-500">Status:</dt>
<dd class="font-medium uppercase tracking-wide">{t().churned_at ? "delinquent" : "active"}</dd>
<dd>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${t().churned_at ? "bg-red-50 text-red-700 border-red-200" : "bg-green-50 text-green-700 border-green-200"}`}>
{t().churned_at ? "delinquent" : "active"}
</span>
</dd>
</div>
<Show when={t().stripe_customer_id}>
<div class="flex gap-2">
<dt class="text-gray-500">Stripe Customer:</dt>
<dd class="font-mono text-xs">{t().stripe_customer_id}</dd>
<dd class="font-mono">{t().stripe_customer_id}</dd>
</div>
</Show>
<Show when={churnedLabel()}>
+3 -1
View File
@@ -85,7 +85,9 @@ export default function AdminTenantList() {
<p class="mt-1 text-xs text-gray-500 break-all">{tenant.pubkey}</p>
</div>
</div>
<p class="text-xs uppercase tracking-wide text-gray-500">tenant</p>
<span class={`inline-flex flex-shrink-0 items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${tenant.churned_at ? "bg-red-50 text-red-700 border-red-200" : "bg-green-50 text-green-700 border-green-200"}`}>
{tenant.churned_at ? "delinquent" : "active"}
</span>
</div>
</A>
</li>
+1
View File
@@ -33,6 +33,7 @@ export default function RelayEdit(props: { basePath?: string; title?: string })
<Show when={relay() && !loading()}>
<RelayForm
initialValues={relay()!}
showPlanSelector={false}
onSubmit={handleSubmit}
submitLabel="Save Changes"
submittingLabel="Saving..."