diff --git a/frontend/README.md b/frontend/README.md index a0c46fc..f4802ee 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -76,19 +76,3 @@ Super admin dashboard: - `/admin/relays` — list relays - `/admin/relays/:id` — relay detail - `/admin/relays/:id/edit` — edit relay - -## Todos - -- [ ] Marketing page (`/`) with value props, features, and CTA -- [ ] Tenant dashboard auth via NIP-98 - - [ ] Relays list (`/relays`) with search/filter and add relay CTA - - [ ] Relay detail (`/relays/:id`) with edit + deactivate actions - - [ ] New relay form (`/relays/new`) with plan selection + invoice flow - - [ ] Relay edit form (`/relays/:id/edit`) - - [ ] Account page (`/account`) with status, invoices, and recurring billing toggle -- [ ] Super admin dashboard auth via `PLATFORM_ADMIN_PUBKEYS` - - [ ] Tenants list (`/admin/tenants`) - - [ ] Tenant detail (`/admin/tenants/:id`) with status + deactivate actions - - [ ] Relays list (`/admin/relays`) - - [ ] Relay detail (`/admin/relays/:id`) - - [ ] Relay edit form (`/admin/relays/:id/edit`) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1cc731b..e82a5c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ -import { createEffect } from "solid-js" -import { Router, Route, useLocation } from "@solidjs/router" +import { createEffect, createResource, Show } from "solid-js" +import { Router, Route, useLocation, useNavigate } from "@solidjs/router" +import type { Component } from "solid-js" import Navbar from "./components/Navbar" import Home from "./pages/Home" import Login from "./pages/Login" @@ -13,6 +14,8 @@ import AdminTenantDetail from "./pages/admin/AdminTenantDetail" import AdminRelays from "./pages/admin/AdminRelays" import AdminRelayDetail from "./pages/admin/AdminRelayDetail" import AdminRelayEdit from "./pages/admin/AdminRelayEdit" +import { useActiveAccount } from "./lib/nostr" +import { adminListTenants } from "./lib/api" function Layout(props: { children?: any }) { const location = useLocation() @@ -32,20 +35,64 @@ function Layout(props: { children?: any }) { } export default function App() { + const withTenantAuth = (Page: Component): Component => { + return () => { + const navigate = useNavigate() + const account = useActiveAccount() + + createEffect(() => { + if (!account()) navigate("/login", { replace: true }) + }) + + return + } + } + + const withAdminAuth = (Page: Component): Component => { + return () => { + const navigate = useNavigate() + const account = useActiveAccount() + const [adminCheck] = createResource( + () => account()?.id, + async () => { + await adminListTenants() + return true + }, + ) + + createEffect(() => { + if (!account()) navigate("/login", { replace: true }) + }) + + return ( + + Checking admin access...}> + You do not have admin access.} + > + + + + + ) + } + } + return ( - - - - - - - - - - + + + + + + + + + + ) } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c19132f..a19105d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -96,10 +96,42 @@ export type Relay = { status: string } +export type Tenant = { + pubkey: string + status: string + tenant_nwc_url: string +} + +export type Invoice = { + id: string + tenant: string + amount: number + status: string + created_at: string + invoice: string +} + +export type TenantDetail = { + tenant: Tenant + relays: Relay[] +} + +export type UpdateRelayInput = { + name: string + subdomain: string + icon: string + description: string + plan: string +} + export function listTenantRelays() { return request("/tenant/relays") } +export function getTenant() { + return request("/tenant") +} + export type CreateRelayInput = { name: string subdomain: string @@ -118,3 +150,63 @@ export function createTenantRelay(input: CreateRelayInput) { export function getTenantRelay(id: string) { return request(`/tenant/relays/${id}`) } + +export function updateTenantRelay(id: string, input: UpdateRelayInput) { + return request(`/tenant/relays/${id}`, { + method: "PUT", + body: JSON.stringify(input), + }) +} + +export function deactivateTenantRelay(id: string) { + return request(`/tenant/relays/${id}`, { + method: "DELETE", + }) +} + +export function listTenantInvoices() { + return request("/tenant/invoices") +} + +export function updateTenantBilling(tenant_nwc_url: string) { + return request("/tenant/billing", { + method: "PUT", + body: JSON.stringify({ tenant_nwc_url }), + }) +} + +export function adminListTenants() { + return request("/admin/tenants") +} + +export function adminGetTenant(pubkey: string) { + return request(`/admin/tenants/${pubkey}`) +} + +export function adminUpdateTenantStatus(pubkey: string, status: string) { + return request(`/admin/tenants/${pubkey}`, { + method: "PUT", + body: JSON.stringify({ status }), + }) +} + +export function adminListRelays() { + return request("/admin/relays") +} + +export function adminGetRelay(id: string) { + return request(`/admin/relays/${id}`) +} + +export function adminUpdateRelay(id: string, input: UpdateRelayInput) { + return request(`/admin/relays/${id}`, { + method: "PUT", + body: JSON.stringify(input), + }) +} + +export function adminDeactivateRelay(id: string) { + return request(`/admin/relays/${id}`, { + method: "DELETE", + }) +} diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx index 341010c..035b816 100644 --- a/frontend/src/pages/Account.tsx +++ b/frontend/src/pages/Account.tsx @@ -1,4 +1,29 @@ +import { createMemo, createResource, createSignal, For, Show } from "solid-js" +import { getTenant, listTenantInvoices, updateTenantBilling } from "../lib/api" + export default function Account() { + const [tenant, { refetch: refetchTenant }] = createResource(getTenant) + const [invoices] = createResource(listTenantInvoices) + const [nwcUrl, setNwcUrl] = createSignal("") + const [saving, setSaving] = createSignal(false) + const [error, setError] = createSignal("") + + const recurringEnabled = createMemo(() => !!tenant()?.tenant_nwc_url?.trim()) + + async function saveBilling() { + setError("") + setSaving(true) + try { + await updateTenantBilling(nwcUrl().trim()) + setNwcUrl("") + await refetchTenant() + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to update billing") + } finally { + setSaving(false) + } + } + return (

Account

@@ -6,7 +31,19 @@ export default function Account() {

Account Status

-

Status information coming soon.

+ +

Loading account...

+
+ + {(t) => ( +
+

+ Status: {t().status} +

+

Tenant pubkey: {t().pubkey}

+
+ )} +
@@ -14,16 +51,66 @@ export default function Account() {

Enable automatic payments by providing your Nostr Wallet Connect URL.

- +

+ Current setting:{" "} + + {recurringEnabled() ? "Enabled" : "Disabled"} + +

+
+ setNwcUrl(e.currentTarget.value)} + placeholder="nostr+walletconnect://..." + class="flex-1 border border-gray-300 rounded-lg px-3 py-2" + /> + + +
+ +

{error()}

+

Invoice History

-

No invoices yet.

+ +

Loading invoices...

+
+ 0} fallback={

No invoices yet.

}> +
    + + {(invoice) => ( +
  • +
    + {invoice.amount.toLocaleString()} sats + {invoice.status} +
    +

    {new Date(invoice.created_at).toLocaleString()}

    +

    {invoice.invoice}

    +
  • + )} +
    +
+
diff --git a/frontend/src/pages/admin/AdminRelayDetail.tsx b/frontend/src/pages/admin/AdminRelayDetail.tsx index d0e93c9..db086bf 100644 --- a/frontend/src/pages/admin/AdminRelayDetail.tsx +++ b/frontend/src/pages/admin/AdminRelayDetail.tsx @@ -1,14 +1,56 @@ import { useParams, A } from "@solidjs/router" +import { createResource, createSignal, Show } from "solid-js" +import { adminDeactivateRelay, adminGetRelay } from "../../lib/api" export default function AdminRelayDetail() { const params = useParams() + const relayId = () => params.id ?? "" + const [relay, { refetch }] = createResource(relayId, adminGetRelay) + const [busy, setBusy] = createSignal(false) + const [error, setError] = createSignal("") + + async function handleDeactivate() { + if (busy()) return + setError("") + setBusy(true) + try { + await adminDeactivateRelay(relayId()) + await refetch() + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to deactivate relay") + } finally { + setBusy(false) + } + } return (
-

Relay {params.id}

+ + +

Loading relay...

+
+ +

Failed to load relay.

+
+ + + {(r) => ( +
+

{r().name}

+

{r().subdomain}.spaces.coracle.social

+

Tenant: {r().tenant}

+

Plan: {r().plan}

+

Status: {r().status}

+ +

{r().description}

+
+
+ )} +
+
Edit -
+ +

{error()}

+
) } diff --git a/frontend/src/pages/admin/AdminRelayEdit.tsx b/frontend/src/pages/admin/AdminRelayEdit.tsx index dbf77d6..4bdc03a 100644 --- a/frontend/src/pages/admin/AdminRelayEdit.tsx +++ b/frontend/src/pages/admin/AdminRelayEdit.tsx @@ -1,7 +1,63 @@ -import { useParams, A } from "@solidjs/router" +import { A, useNavigate, useParams } from "@solidjs/router" +import { Show, createEffect, createResource, createSignal } from "solid-js" +import { adminGetRelay, adminUpdateRelay } from "../../lib/api" + +const PLANS = ["free", "basic", "growth"] as const +type PlanId = (typeof PLANS)[number] + +function slugify(s: string) { + return s + .toLowerCase() + .trim() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} export default function AdminRelayEdit() { + const navigate = useNavigate() const params = useParams() + const relayId = () => params.id ?? "" + const [relay] = createResource(relayId, adminGetRelay) + + const [name, setName] = createSignal("") + const [subdomain, setSubdomain] = createSignal("") + const [icon, setIcon] = createSignal("") + const [description, setDescription] = createSignal("") + const [plan, setPlan] = createSignal("free") + const [error, setError] = createSignal("") + const [submitting, setSubmitting] = createSignal(false) + + createEffect(() => { + const data = relay() + if (!data) return + setName(data.name) + setSubdomain(data.subdomain) + setIcon(data.icon) + setDescription(data.description) + setPlan(PLANS.includes(data.plan as PlanId) ? (data.plan as PlanId) : "free") + }) + + async function handleSubmit(e: Event) { + e.preventDefault() + setError("") + setSubmitting(true) + try { + await adminUpdateRelay(relayId(), { + name: name().trim(), + subdomain: slugify(subdomain()), + icon: icon().trim(), + description: description().trim(), + plan: plan(), + }) + navigate(`/admin/relays/${relayId()}`) + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to update relay") + } finally { + setSubmitting(false) + } + } return (
@@ -9,7 +65,81 @@ export default function AdminRelayEdit() { ← Back

Edit Relay (Admin)

-

Edit form coming soon.

+ + +

Loading relay...

+
+ +

Failed to load relay.

+
+ + +
+
+ + setName(e.currentTarget.value)} + required + class="w-full border border-gray-300 rounded-lg px-3 py-2" + /> +
+
+ +
+ setSubdomain(e.currentTarget.value)} + required + class="flex-1 px-3 py-2" + /> + .spaces.coracle.social +
+
+
+ + setIcon(e.currentTarget.value)} + class="w-full border border-gray-300 rounded-lg px-3 py-2" + /> +
+
+ +