-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add Agent Sign-Ups admin page #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import type { Metadata } from "next"; | ||
| import AgentSignupsPage from "@/components/AgentSignups/AgentSignupsPage"; | ||
|
|
||
| export const metadata: Metadata = { | ||
| title: "Agent Sign-Ups — Recoup Admin", | ||
| }; | ||
|
|
||
| export default function Page() { | ||
| return <AgentSignupsPage />; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| "use client"; | ||
|
|
||
| import { useState } from "react"; | ||
| import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb"; | ||
| import ApiDocsLink from "@/components/ApiDocs/ApiDocsLink"; | ||
| import { useAgentSignups } from "@/hooks/useAgentSignups"; | ||
| import AgentSignupsTable from "@/components/AgentSignups/AgentSignupsTable"; | ||
| import AgentSignupsStats from "@/components/AgentSignups/AgentSignupsStats"; | ||
| import PeriodSelector from "@/components/Admin/PeriodSelector"; | ||
| import AdminLineChart from "@/components/Admin/AdminLineChart"; | ||
| import TableSkeleton from "@/components/Sandboxes/TableSkeleton"; | ||
| import ChartSkeleton from "@/components/PrivyLogins/ChartSkeleton"; | ||
| import { getSignupsByDate } from "@/lib/agent-signups/getSignupsByDate"; | ||
| import type { AdminPeriod } from "@/types/admin"; | ||
|
|
||
| export default function AgentSignupsPage() { | ||
| const [period, setPeriod] = useState<AdminPeriod>("all"); | ||
| const { data, isLoading, error } = useAgentSignups(period); | ||
|
|
||
| const signupsByDate = data ? getSignupsByDate(data.signups) : []; | ||
|
|
||
| return ( | ||
| <main className="mx-auto max-w-6xl px-4 py-10"> | ||
| <div className="mb-6 flex items-start justify-between"> | ||
| <div> | ||
| <PageBreadcrumb current="Agent Sign-Ups" /> | ||
| <h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-100"> | ||
| Agent Sign-Ups | ||
| </h1> | ||
| <p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> | ||
| API key sign-ups from AI agents, grouped by time period. | ||
| </p> | ||
| </div> | ||
| <ApiDocsLink path="admins/agent-signups" /> | ||
| </div> | ||
|
|
||
| <div className="mb-6 flex items-center gap-4"> | ||
| <PeriodSelector period={period} onPeriodChange={setPeriod} /> | ||
| {data && <AgentSignupsStats data={data} />} | ||
| </div> | ||
|
|
||
| {isLoading && ( | ||
| <> | ||
| <ChartSkeleton /> | ||
| <TableSkeleton columns={["Email", "Key Name", "Created At"]} /> | ||
| </> | ||
| )} | ||
|
|
||
| {error && ( | ||
| <div className="rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400"> | ||
| {error instanceof Error ? error.message : "Failed to load agent sign-ups"} | ||
| </div> | ||
| )} | ||
|
|
||
| {!isLoading && !error && data && data.signups.length === 0 && ( | ||
| <div className="flex items-center justify-center py-12 text-sm text-gray-400"> | ||
| No agent sign-ups found for this period. | ||
| </div> | ||
| )} | ||
|
|
||
| {!isLoading && !error && data && data.signups.length > 0 && ( | ||
| <> | ||
| <AdminLineChart | ||
| title="Agent Sign-Ups Over Time" | ||
| data={signupsByDate.map((d) => ({ date: d.date, count: d.count }))} | ||
| label="Sign-Ups" | ||
| /> | ||
| <AgentSignupsTable signups={data.signups} /> | ||
| </> | ||
| )} | ||
| </main> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import type { AgentSignupsResponse } from "@/types/agentSignups"; | ||
|
|
||
| interface AgentSignupsStatsProps { | ||
| data: AgentSignupsResponse; | ||
| } | ||
|
|
||
| export default function AgentSignupsStats({ data }: AgentSignupsStatsProps) { | ||
| return ( | ||
| <div className="flex gap-4 text-sm text-gray-500 dark:text-gray-400"> | ||
| <span> | ||
| <span className="font-semibold text-gray-900 dark:text-gray-100">{data.total}</span> sign-ups | ||
| </span> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| "use client"; | ||
|
|
||
| import { | ||
| flexRender, | ||
| getCoreRowModel, | ||
| getSortedRowModel, | ||
| useReactTable, | ||
| type SortingState, | ||
| } from "@tanstack/react-table"; | ||
| import { useState } from "react"; | ||
| import { | ||
| Table, | ||
| TableBody, | ||
| TableCell, | ||
| TableHead, | ||
| TableHeader, | ||
| TableRow, | ||
| } from "@/components/ui/table"; | ||
| import { agentSignupsColumns } from "@/components/AgentSignups/agentSignupsColumns"; | ||
| import type { AgentSignup } from "@/types/agentSignups"; | ||
|
|
||
| interface AgentSignupsTableProps { | ||
| signups: AgentSignup[]; | ||
| } | ||
|
|
||
| export default function AgentSignupsTable({ signups }: AgentSignupsTableProps) { | ||
| const [sorting, setSorting] = useState<SortingState>([ | ||
| { id: "created_at", desc: true }, | ||
| ]); | ||
|
|
||
| const table = useReactTable({ | ||
| data: signups, | ||
| columns: agentSignupsColumns, | ||
| state: { sorting }, | ||
| onSortingChange: setSorting, | ||
| getCoreRowModel: getCoreRowModel(), | ||
| getSortedRowModel: getSortedRowModel(), | ||
| }); | ||
|
|
||
| return ( | ||
| <div className="rounded-lg border"> | ||
| <Table> | ||
| <TableHeader> | ||
| {table.getHeaderGroups().map((headerGroup) => ( | ||
| <TableRow key={headerGroup.id}> | ||
| {headerGroup.headers.map((header) => ( | ||
| <TableHead key={header.id}> | ||
| {header.isPlaceholder | ||
| ? null | ||
| : flexRender(header.column.columnDef.header, header.getContext())} | ||
| </TableHead> | ||
| ))} | ||
| </TableRow> | ||
| ))} | ||
| </TableHeader> | ||
| <TableBody> | ||
| {table.getRowModel().rows.length ? ( | ||
| table.getRowModel().rows.map((row) => ( | ||
| <TableRow key={row.id}> | ||
| {row.getVisibleCells().map((cell) => ( | ||
| <TableCell key={cell.id}> | ||
| {flexRender(cell.column.columnDef.cell, cell.getContext())} | ||
| </TableCell> | ||
| ))} | ||
| </TableRow> | ||
| )) | ||
| ) : ( | ||
| <TableRow> | ||
| <TableCell colSpan={agentSignupsColumns.length} className="h-24 text-center"> | ||
| No results. | ||
| </TableCell> | ||
| </TableRow> | ||
| )} | ||
| </TableBody> | ||
| </Table> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import { type ColumnDef } from "@tanstack/react-table"; | ||
| import { SortableHeader } from "@/components/SandboxOrgs/SortableHeader"; | ||
| import type { AgentSignup } from "@/types/agentSignups"; | ||
|
|
||
| export const agentSignupsColumns: ColumnDef<AgentSignup>[] = [ | ||
| { | ||
| id: "email", | ||
| accessorKey: "email", | ||
| header: ({ column }) => <SortableHeader column={column} label="Email" />, | ||
| cell: ({ getValue }) => ( | ||
| <span className="font-medium">{getValue<string>()}</span> | ||
| ), | ||
| }, | ||
| { | ||
| id: "name", | ||
| accessorKey: "name", | ||
| header: "Key Name", | ||
| cell: ({ getValue }) => ( | ||
| <span className="max-w-xs truncate" title={getValue<string>()}> | ||
| {getValue<string>()} | ||
| </span> | ||
| ), | ||
| }, | ||
| { | ||
| id: "created_at", | ||
| accessorKey: "created_at", | ||
| header: ({ column }) => <SortableHeader column={column} label="Created At" />, | ||
| cell: ({ getValue }) => new Date(getValue<string>()).toLocaleString(), | ||
| sortingFn: "datetime", | ||
|
Comment on lines
+25
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: No, the built-in sortingFn: "datetime" in Citations:
🏁 Script executed: # Find package.json or similar to check TanStack version
find . -name "package.json" -o -name "package-lock.json" -o -name "yarn.lock" | head -5Repository: recoupable/admin Length of output: 389 🏁 Script executed: # Check for `@tanstack/react-table` dependency
rg "@tanstack/react-table" --max-count 5 -A 1Repository: recoupable/admin Length of output: 3064 🏁 Script executed: # Search for other uses of accessorFn in the codebase to verify the pattern
rg "accessorFn:" -B 2 -A 2Repository: recoupable/admin Length of output: 1224 🏁 Script executed: # Read the agentSignupsColumns.tsx file to see the full context
cat -n components/AgentSignups/agentSignupsColumns.tsxRepository: recoupable/admin Length of output: 1232 🏁 Script executed: # Check the AgentSignup type definition to confirm created_at is a string
rg "type AgentSignup" -A 10Repository: recoupable/admin Length of output: 538 Fix datetime sorting with Date accessor, not string accessor. The Corrected column definition {
id: "created_at",
- accessorKey: "created_at",
+ accessorFn: (row) => new Date(row.created_at),
header: ({ column }) => <SortableHeader column={column} label="Created At" />,
- cell: ({ getValue }) => new Date(getValue<string>()).toLocaleString(),
+ cell: ({ row }) => new Date(row.original.created_at).toLocaleString(),
sortingFn: "datetime",
},🤖 Prompt for AI Agents |
||
| }, | ||
| ]; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| "use client"; | ||
|
|
||
| import { useQuery } from "@tanstack/react-query"; | ||
| import { usePrivy } from "@privy-io/react-auth"; | ||
| import { fetchAgentSignups } from "@/lib/recoup/fetchAgentSignups"; | ||
| import type { AdminPeriod } from "@/types/admin"; | ||
|
|
||
| export function useAgentSignups(period: AdminPeriod) { | ||
| const { ready, authenticated, getAccessToken } = usePrivy(); | ||
|
|
||
| return useQuery({ | ||
| queryKey: ["admin", "agent-signups", period], | ||
| queryFn: async () => { | ||
| const token = await getAccessToken(); | ||
| if (!token) throw new Error("Not authenticated"); | ||
| return fetchAgentSignups(token, period); | ||
| }, | ||
| enabled: ready && authenticated, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import type { AgentSignup } from "@/types/agentSignups"; | ||
|
|
||
| export interface SignupsByDateEntry { | ||
| date: string; | ||
| count: number; | ||
| } | ||
|
|
||
| /** | ||
| * Aggregates agent sign-ups by UTC date (YYYY-MM-DD) for charting. | ||
| */ | ||
| export function getSignupsByDate(signups: AgentSignup[]): SignupsByDateEntry[] { | ||
| const counts: Record<string, number> = {}; | ||
|
|
||
| for (const signup of signups) { | ||
| const date = signup.created_at.slice(0, 10); | ||
| counts[date] = (counts[date] || 0) + 1; | ||
| } | ||
|
|
||
| return Object.entries(counts) | ||
| .map(([date, count]) => ({ date, count })) | ||
| .sort((a, b) => a.date.localeCompare(b.date)); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import type { AgentSignup } from "@/types/agentSignups"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Custom agent: Flag AI Slop and Fabricated Changes This function is dead code referencing a non-existent pie chart. The JSDoc claims it's "for pie chart display" but no pie chart exists in the codebase, and the PR scope is a line chart + data table. The function itself is never imported anywhere. Remove this file or, if the aggregation is actually needed, correct the comment and wire it into the page. Prompt for AI agents |
||
|
|
||
| export interface SignupsByEmailEntry { | ||
| name: string; | ||
| value: number; | ||
| } | ||
|
|
||
| /** | ||
| * Aggregates agent sign-ups by email address for pie chart display. | ||
| */ | ||
| export function getSignupsByEmail(signups: AgentSignup[]): SignupsByEmailEntry[] { | ||
| const counts: Record<string, number> = {}; | ||
|
|
||
| for (const signup of signups) { | ||
| const key = signup.email || "Unknown"; | ||
| counts[key] = (counts[key] || 0) + 1; | ||
| } | ||
|
|
||
| return Object.entries(counts) | ||
| .map(([name, value]) => ({ name, value })) | ||
| .sort((a, b) => b.value - a.value); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { API_BASE_URL } from "@/lib/consts"; | ||
| import type { AdminPeriod } from "@/types/admin"; | ||
| import type { AgentSignupsResponse } from "@/types/agentSignups"; | ||
|
|
||
| export async function fetchAgentSignups( | ||
| accessToken: string, | ||
| period: AdminPeriod, | ||
| ): Promise<AgentSignupsResponse> { | ||
| const url = new URL(`${API_BASE_URL}/api/admins/agent-signups`); | ||
| url.searchParams.set("period", period); | ||
|
|
||
| const res = await fetch(url.toString(), { | ||
| headers: { Authorization: `Bearer ${accessToken}` }, | ||
| }); | ||
|
|
||
| if (!res.ok) { | ||
| const body = await res.json().catch(() => ({})); | ||
| throw new Error(body.error ?? body.message ?? `HTTP ${res.status}`); | ||
| } | ||
|
|
||
| return res.json(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| export type AgentSignup = { | ||
| id: string; | ||
| name: string; | ||
| email: string; | ||
| created_at: string; | ||
| }; | ||
|
|
||
| export type AgentSignupsResponse = { | ||
| status: "success" | "error"; | ||
| total: number; | ||
| signups: AgentSignup[]; | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Custom agent: Flag AI Slop and Fabricated Changes
This entire file is a copy-paste of
PrivyLoginsTable.tsx(andContentSlackTable.tsx, etc.) with only entity names changed. Extract a shared genericDataTablecomponent that acceptsdata,columns, and a default sort field — then all 6+ copies collapse to one-line usages.(Based on your team's feedback about preferring shared components over duplicate wrappers.)
View Feedback
Prompt for AI agents