diff --git a/adminsite/app/(staff)/staff/accounts/AccountSearch.test.tsx b/adminsite/app/(staff)/staff/accounts/AccountSearch.test.tsx new file mode 100644 index 0000000..bf05197 --- /dev/null +++ b/adminsite/app/(staff)/staff/accounts/AccountSearch.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import { AccountSearch } from "./AccountSearch"; + +const accounts = vi.fn(); +vi.mock("@/lib/api", async () => { + const actual = await vi.importActual("@/lib/api"); + return { + ...actual, + api: { ...actual.api, staff: { ...actual.api.staff, accounts: (q?: string) => accounts(q) } }, + }; +}); + +function wrap(ui: React.ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +describe("AccountSearch", () => { + it("finds an account by instance UUID, which is often all a support email has", async () => { + const uuid = "6a0fe3f0-49d2-4aa1-967c-a3094b200b5d"; + accounts.mockResolvedValue([ + { + account_id: "a1", + name: "Acme", + billing_email: "ops@acme.example", + status: "active", + created_at: "2026-01-01T00:00:00Z", + }, + ]); + + wrap(); + await userEvent.type(screen.getByLabelText(/search/i), uuid); + + await waitFor(() => expect(accounts).toHaveBeenCalledWith(uuid)); + expect(await screen.findByRole("link", { name: /acme/i })).toHaveAttribute( + "href", + "/staff/accounts/a1", + ); + }); +}); diff --git a/adminsite/app/(staff)/staff/accounts/AccountSearch.tsx b/adminsite/app/(staff)/staff/accounts/AccountSearch.tsx new file mode 100644 index 0000000..a91d302 --- /dev/null +++ b/adminsite/app/(staff)/staff/accounts/AccountSearch.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useState } from "react"; +import { api } from "@/lib/api"; +import { Field } from "@/components/Field"; +import { formatDate } from "@/lib/format"; + +export function AccountSearch() { + const [q, setQ] = useState(""); + const { data, isFetching } = useQuery({ + queryKey: ["staff-accounts", q], + queryFn: () => api.staff.accounts(q || undefined), + }); + + return ( +
+ setQ(e.target.value)} + hint="Name, email, Paddle customer ID, or an instance UUID." + /> +
+ + + + + + + + + + + {(data ?? []).map((a) => ( + + + + + + + ))} + +
AccountBilling emailStatusCreated
+ + {a.name} + + + {a.billing_email} + {a.status} + {formatDate(a.created_at)} +
+ {!isFetching && (data ?? []).length === 0 && ( +

+ No account matches that. Try the instance UUID from the customer’s + email. +

+ )} +
+
+ ); +} diff --git a/adminsite/app/(staff)/staff/accounts/[id]/page.tsx b/adminsite/app/(staff)/staff/accounts/[id]/page.tsx new file mode 100644 index 0000000..ed96609 --- /dev/null +++ b/adminsite/app/(staff)/staff/accounts/[id]/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { api } from "@/lib/api"; +import { formatDate } from "@/lib/format"; + +export default function AccountDetailPage() { + const id = String(useParams().id); + const { data, isLoading } = useQuery({ + queryKey: ["staff-account", id], + queryFn: () => api.staff.account(id), + }); + + if (isLoading || !data) return

Loading…

; + + return ( +
+
+

{data.account.name}

+

+ {data.account.billing_email} · {data.account.account_id} +

+
+ + +
    + {data.instances.map((i) => ( +
  • + + {i.name || i.instance_id} + + + {i.deployment} · {i.tier ?? "no tier"} · {i.status} + +
  • + ))} + {data.instances.length === 0 &&
  • None.
  • } +
+
+ + +
    + {data.subscriptions.map((s) => ( +
  • + + {s.tier.replace("_", " ")} · {s.term} + + + {s.status} · renews {formatDate(s.current_period_end)} + +
  • + ))} + {data.subscriptions.length === 0 &&
  • None.
  • } +
+
+ + +
    + {data.users.map((u) => ( +
  • + {u.email} + + {u.verified_at + ? `verified ${formatDate(u.verified_at)}` + : "not verified"} + +
  • + ))} + {data.users.length === 0 && ( +
  • + None — this is a cloud account, so its people sign in with their + control-plane details. +
  • + )} +
+
+ + +
    + {data.audit.map((e, n) => ( +
  • + + {e.action} · {e.actor} + + + {formatDate(e.created_at)} + +
  • + ))} + {data.audit.length === 0 &&
  • Nothing yet.
  • } +
+
+
+ ); +} + +function Panel({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} diff --git a/adminsite/app/(staff)/staff/accounts/page.tsx b/adminsite/app/(staff)/staff/accounts/page.tsx new file mode 100644 index 0000000..b10e185 --- /dev/null +++ b/adminsite/app/(staff)/staff/accounts/page.tsx @@ -0,0 +1,10 @@ +import { AccountSearch } from "./AccountSearch"; + +export default function AccountsPage() { + return ( +
+

Accounts

+ +
+ ); +}