From beac4222050c799b1df6be3794611c623761e2fc Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Tue, 17 Mar 2026 13:56:22 +0000 Subject: [PATCH 01/18] feat: add Privy logins page to admin dashboard New /privy page showing daily/weekly/monthly login counts and a table of individual logins with email, Privy DID, and timestamp. Adds "View Privy Logins" nav button to AdminDashboard. Co-Authored-By: Claude Sonnet 4.6 --- app/privy/page.tsx | 10 +++ components/Home/AdminDashboard.tsx | 1 + components/PrivyLogins/PrivyLoginsPage.tsx | 83 +++++++++++++++++++++ components/PrivyLogins/PrivyLoginsTable.tsx | 42 +++++++++++ hooks/usePrivyLogins.ts | 24 ++++++ lib/recoup/fetchPrivyLogins.ts | 29 +++++++ types/privy.ts | 13 ++++ 7 files changed, 202 insertions(+) create mode 100644 app/privy/page.tsx create mode 100644 components/PrivyLogins/PrivyLoginsPage.tsx create mode 100644 components/PrivyLogins/PrivyLoginsTable.tsx create mode 100644 hooks/usePrivyLogins.ts create mode 100644 lib/recoup/fetchPrivyLogins.ts create mode 100644 types/privy.ts diff --git a/app/privy/page.tsx b/app/privy/page.tsx new file mode 100644 index 0000000..585b62a --- /dev/null +++ b/app/privy/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import PrivyLoginsPage from "@/components/PrivyLogins/PrivyLoginsPage"; + +export const metadata: Metadata = { + title: "Privy Logins — Recoup Admin", +}; + +export default function Page() { + return ; +} diff --git a/components/Home/AdminDashboard.tsx b/components/Home/AdminDashboard.tsx index 63e9617..32ac9e8 100644 --- a/components/Home/AdminDashboard.tsx +++ b/components/Home/AdminDashboard.tsx @@ -11,6 +11,7 @@ export default function AdminDashboard() { ); diff --git a/components/PrivyLogins/PrivyLoginsPage.tsx b/components/PrivyLogins/PrivyLoginsPage.tsx new file mode 100644 index 0000000..077d407 --- /dev/null +++ b/components/PrivyLogins/PrivyLoginsPage.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState } from "react"; +import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb"; +import ApiDocsLink from "@/components/ApiDocsLink"; +import { usePrivyLogins } from "@/hooks/usePrivyLogins"; +import PrivyLoginsTable from "@/components/PrivyLogins/PrivyLoginsTable"; +import type { PrivyLoginsPeriod } from "@/types/privy"; + +const PERIODS: { value: PrivyLoginsPeriod; label: string }[] = [ + { value: "daily", label: "Daily" }, + { value: "weekly", label: "Weekly" }, + { value: "monthly", label: "Monthly" }, +]; + +export default function PrivyLoginsPage() { + const [period, setPeriod] = useState("daily"); + const { data, isLoading, error } = usePrivyLogins(period); + + return ( +
+
+
+ +

+ Privy Logins +

+

+ User sign-ins via Privy, grouped by time period. +

+
+ +
+ +
+
+ {PERIODS.map(({ value, label }) => ( + + ))} +
+ + {data && ( + + {data.total}{" "} + login{data.total !== 1 ? "s" : ""} + + )} +
+ + {isLoading && ( +
+ Loading logins… +
+ )} + + {error && ( +
+ {error instanceof Error ? error.message : "Failed to load Privy logins"} +
+ )} + + {!isLoading && !error && data && data.logins.length === 0 && ( +
+ No logins found for this period. +
+ )} + + {!isLoading && !error && data && data.logins.length > 0 && ( + + )} +
+ ); +} diff --git a/components/PrivyLogins/PrivyLoginsTable.tsx b/components/PrivyLogins/PrivyLoginsTable.tsx new file mode 100644 index 0000000..acc97e4 --- /dev/null +++ b/components/PrivyLogins/PrivyLoginsTable.tsx @@ -0,0 +1,42 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { PrivyLoginRow } from "@/types/privy"; + +interface PrivyLoginsTableProps { + logins: PrivyLoginRow[]; +} + +export default function PrivyLoginsTable({ logins }: PrivyLoginsTableProps) { + return ( +
+ + + + Email + Privy DID + Created At + + + + {logins.map((login) => ( + + + {login.email ?? No email} + + {login.privy_did} + + {new Date(login.created_at).toLocaleString()} + + + ))} + +
+
+ ); +} diff --git a/hooks/usePrivyLogins.ts b/hooks/usePrivyLogins.ts new file mode 100644 index 0000000..ee63b9f --- /dev/null +++ b/hooks/usePrivyLogins.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { usePrivy } from "@privy-io/react-auth"; +import { fetchPrivyLogins } from "@/lib/recoup/fetchPrivyLogins"; +import type { PrivyLoginsPeriod } from "@/types/privy"; + +/** + * Fetches Privy login statistics for the given period from GET /api/admins/privy. + * Authenticates with the Privy access token (admin Bearer auth). + */ +export function usePrivyLogins(period: PrivyLoginsPeriod) { + const { ready, authenticated, getAccessToken } = usePrivy(); + + return useQuery({ + queryKey: ["admin", "privy", "logins", period], + queryFn: async () => { + const token = await getAccessToken(); + if (!token) throw new Error("Not authenticated"); + return fetchPrivyLogins(token, period); + }, + enabled: ready && authenticated, + }); +} diff --git a/lib/recoup/fetchPrivyLogins.ts b/lib/recoup/fetchPrivyLogins.ts new file mode 100644 index 0000000..874abc2 --- /dev/null +++ b/lib/recoup/fetchPrivyLogins.ts @@ -0,0 +1,29 @@ +import { API_BASE_URL } from "@/lib/consts"; +import type { PrivyLoginsPeriod, PrivyLoginsResponse } from "@/types/privy"; + +/** + * Fetches Privy login statistics from GET /api/admins/privy. + * Authenticates using the caller's Privy access token (admin Bearer auth). + * + * @param accessToken - Privy access token from getAccessToken() + * @param period - Time period: "daily", "weekly", or "monthly" + * @returns PrivyLoginsResponse with total count and login rows + */ +export async function fetchPrivyLogins( + accessToken: string, + period: PrivyLoginsPeriod, +): Promise { + const url = new URL(`${API_BASE_URL}/api/admins/privy`); + 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(); +} diff --git a/types/privy.ts b/types/privy.ts new file mode 100644 index 0000000..db52373 --- /dev/null +++ b/types/privy.ts @@ -0,0 +1,13 @@ +export type PrivyLoginsPeriod = "daily" | "weekly" | "monthly"; + +export type PrivyLoginRow = { + privy_did: string; + email: string | null; + created_at: string; +}; + +export type PrivyLoginsResponse = { + status: "success" | "error"; + total: number; + logins: PrivyLoginRow[]; +}; From f2c253982e0c0db33cba0f1d55040d74513f6046 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 17 Mar 2026 12:40:15 -0500 Subject: [PATCH 02/18] feat: update admin privy page for latest API response shape - Add 'all' period (new default) to period selector - Update types to use full Privy User objects instead of filtered rows - Add total_new, total_active, total_privy_users to response type - Show new/active/total counts in the UI - Extract email from linked_accounts in table - Convert created_at from seconds to Date Co-Authored-By: Claude Opus 4.6 (1M context) --- components/PrivyLogins/PrivyLoginsPage.tsx | 18 ++++++++--- components/PrivyLogins/PrivyLoginsTable.tsx | 34 +++++++++++++-------- lib/recoup/fetchPrivyLogins.ts | 4 +-- types/privy.ts | 27 ++++++++++++---- 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/components/PrivyLogins/PrivyLoginsPage.tsx b/components/PrivyLogins/PrivyLoginsPage.tsx index 077d407..1238a0b 100644 --- a/components/PrivyLogins/PrivyLoginsPage.tsx +++ b/components/PrivyLogins/PrivyLoginsPage.tsx @@ -8,13 +8,14 @@ import PrivyLoginsTable from "@/components/PrivyLogins/PrivyLoginsTable"; import type { PrivyLoginsPeriod } from "@/types/privy"; const PERIODS: { value: PrivyLoginsPeriod; label: string }[] = [ + { value: "all", label: "All Time" }, { value: "daily", label: "Daily" }, { value: "weekly", label: "Weekly" }, { value: "monthly", label: "Monthly" }, ]; export default function PrivyLoginsPage() { - const [period, setPeriod] = useState("daily"); + const [period, setPeriod] = useState("all"); const { data, isLoading, error } = usePrivyLogins(period); return ( @@ -50,10 +51,17 @@ export default function PrivyLoginsPage() { {data && ( - - {data.total}{" "} - login{data.total !== 1 ? "s" : ""} - +
+ + {data.total_new} new + + + {data.total_active} active + + + {data.total} total + +
)} diff --git a/components/PrivyLogins/PrivyLoginsTable.tsx b/components/PrivyLogins/PrivyLoginsTable.tsx index acc97e4..f3bda19 100644 --- a/components/PrivyLogins/PrivyLoginsTable.tsx +++ b/components/PrivyLogins/PrivyLoginsTable.tsx @@ -6,10 +6,15 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import type { PrivyLoginRow } from "@/types/privy"; +import type { PrivyUser } from "@/types/privy"; interface PrivyLoginsTableProps { - logins: PrivyLoginRow[]; + logins: PrivyUser[]; +} + +function getEmail(user: PrivyUser): string | null { + const emailAccount = user.linked_accounts.find((a) => a.type === "email"); + return (emailAccount?.address as string) ?? null; } export default function PrivyLoginsTable({ logins }: PrivyLoginsTableProps) { @@ -24,17 +29,20 @@ export default function PrivyLoginsTable({ logins }: PrivyLoginsTableProps) { - {logins.map((login) => ( - - - {login.email ?? No email} - - {login.privy_did} - - {new Date(login.created_at).toLocaleString()} - - - ))} + {logins.map((login) => { + const email = getEmail(login); + return ( + + + {email ?? No email} + + {login.id} + + {new Date(login.created_at * 1000).toLocaleString()} + + + ); + })} diff --git a/lib/recoup/fetchPrivyLogins.ts b/lib/recoup/fetchPrivyLogins.ts index 874abc2..8ac70b7 100644 --- a/lib/recoup/fetchPrivyLogins.ts +++ b/lib/recoup/fetchPrivyLogins.ts @@ -6,8 +6,8 @@ import type { PrivyLoginsPeriod, PrivyLoginsResponse } from "@/types/privy"; * Authenticates using the caller's Privy access token (admin Bearer auth). * * @param accessToken - Privy access token from getAccessToken() - * @param period - Time period: "daily", "weekly", or "monthly" - * @returns PrivyLoginsResponse with total count and login rows + * @param period - Time period: "all", "daily", "weekly", or "monthly" + * @returns PrivyLoginsResponse with counts and full Privy user objects */ export async function fetchPrivyLogins( accessToken: string, diff --git a/types/privy.ts b/types/privy.ts index db52373..dda46bd 100644 --- a/types/privy.ts +++ b/types/privy.ts @@ -1,13 +1,28 @@ -export type PrivyLoginsPeriod = "daily" | "weekly" | "monthly"; +export type PrivyLoginsPeriod = "all" | "daily" | "weekly" | "monthly"; -export type PrivyLoginRow = { - privy_did: string; - email: string | null; - created_at: string; +export type PrivyLinkedAccount = { + type: string; + address?: string; + verified_at?: number; + first_verified_at?: number | null; + latest_verified_at?: number | null; + [key: string]: unknown; +}; + +export type PrivyUser = { + id: string; + created_at: number; + linked_accounts: PrivyLinkedAccount[]; + mfa_methods: unknown[]; + has_accepted_terms: boolean; + is_guest: boolean; }; export type PrivyLoginsResponse = { status: "success" | "error"; total: number; - logins: PrivyLoginRow[]; + total_new: number; + total_active: number; + total_privy_users: number; + logins: PrivyUser[]; }; From 38a34502ab7c6053d17a22baf5c54e7fa9ac811e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 17 Mar 2026 12:41:07 -0500 Subject: [PATCH 03/18] refactor: extract getEmail to lib/privy/getEmail.ts (SRP) Co-Authored-By: Claude Opus 4.6 (1M context) --- components/PrivyLogins/PrivyLoginsTable.tsx | 6 +----- lib/privy/getEmail.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 lib/privy/getEmail.ts diff --git a/components/PrivyLogins/PrivyLoginsTable.tsx b/components/PrivyLogins/PrivyLoginsTable.tsx index f3bda19..fda0ec5 100644 --- a/components/PrivyLogins/PrivyLoginsTable.tsx +++ b/components/PrivyLogins/PrivyLoginsTable.tsx @@ -7,16 +7,12 @@ import { TableRow, } from "@/components/ui/table"; import type { PrivyUser } from "@/types/privy"; +import { getEmail } from "@/lib/privy/getEmail"; interface PrivyLoginsTableProps { logins: PrivyUser[]; } -function getEmail(user: PrivyUser): string | null { - const emailAccount = user.linked_accounts.find((a) => a.type === "email"); - return (emailAccount?.address as string) ?? null; -} - export default function PrivyLoginsTable({ logins }: PrivyLoginsTableProps) { return (
diff --git a/lib/privy/getEmail.ts b/lib/privy/getEmail.ts new file mode 100644 index 0000000..c25be0f --- /dev/null +++ b/lib/privy/getEmail.ts @@ -0,0 +1,9 @@ +import type { PrivyUser } from "@/types/privy"; + +/** + * Extracts the email address from a Privy user's linked accounts. + */ +export function getEmail(user: PrivyUser): string | null { + const emailAccount = user.linked_accounts.find((a) => a.type === "email"); + return (emailAccount?.address as string) ?? null; +} From 29216dab9e71568254666a0366a0a32d809f5b84 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 17 Mar 2026 12:45:59 -0500 Subject: [PATCH 04/18] refactor: use User type from @privy-io/react-auth SDK instead of custom type DRY - removed PrivyLinkedAccount and custom PrivyUser type definitions, now re-exports User from the Privy SDK. Co-Authored-By: Claude Opus 4.6 (1M context) --- types/privy.ts | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/types/privy.ts b/types/privy.ts index dda46bd..e8129c8 100644 --- a/types/privy.ts +++ b/types/privy.ts @@ -1,22 +1,6 @@ -export type PrivyLoginsPeriod = "all" | "daily" | "weekly" | "monthly"; - -export type PrivyLinkedAccount = { - type: string; - address?: string; - verified_at?: number; - first_verified_at?: number | null; - latest_verified_at?: number | null; - [key: string]: unknown; -}; +export type { User as PrivyUser } from "@privy-io/react-auth"; -export type PrivyUser = { - id: string; - created_at: number; - linked_accounts: PrivyLinkedAccount[]; - mfa_methods: unknown[]; - has_accepted_terms: boolean; - is_guest: boolean; -}; +export type PrivyLoginsPeriod = "all" | "daily" | "weekly" | "monthly"; export type PrivyLoginsResponse = { status: "success" | "error"; @@ -24,5 +8,5 @@ export type PrivyLoginsResponse = { total_new: number; total_active: number; total_privy_users: number; - logins: PrivyUser[]; + logins: import("@privy-io/react-auth").User[]; }; From 7f0d6945d6528d9d859fc371fd777da5dad50cd5 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 17 Mar 2026 12:47:43 -0500 Subject: [PATCH 05/18] refactor: extract sub-components from PrivyLoginsPage (SRP) - PrivyLoginRow: single table row component - PrivyPeriodSelector: period toggle buttons - PrivyLoginsStats: new/active/total counts display Co-Authored-By: Claude Opus 4.6 (1M context) --- components/PrivyLogins/PrivyLoginRow.tsx | 22 ++++++++++ components/PrivyLogins/PrivyLoginsPage.tsx | 40 ++----------------- components/PrivyLogins/PrivyLoginsStats.tsx | 21 ++++++++++ components/PrivyLogins/PrivyLoginsTable.tsx | 20 ++-------- .../PrivyLogins/PrivyPeriodSelector.tsx | 33 +++++++++++++++ 5 files changed, 84 insertions(+), 52 deletions(-) create mode 100644 components/PrivyLogins/PrivyLoginRow.tsx create mode 100644 components/PrivyLogins/PrivyLoginsStats.tsx create mode 100644 components/PrivyLogins/PrivyPeriodSelector.tsx diff --git a/components/PrivyLogins/PrivyLoginRow.tsx b/components/PrivyLogins/PrivyLoginRow.tsx new file mode 100644 index 0000000..de4121f --- /dev/null +++ b/components/PrivyLogins/PrivyLoginRow.tsx @@ -0,0 +1,22 @@ +import { TableRow, TableCell } from "@/components/ui/table"; +import type { PrivyUser } from "@/types/privy"; +import { getEmail } from "@/lib/privy/getEmail"; + +interface PrivyLoginRowProps { + login: PrivyUser; +} + +export default function PrivyLoginRow({ login }: PrivyLoginRowProps) { + const email = getEmail(login); + return ( + + + {email ?? No email} + + {login.id} + + {new Date(login.created_at * 1000).toLocaleString()} + + + ); +} diff --git a/components/PrivyLogins/PrivyLoginsPage.tsx b/components/PrivyLogins/PrivyLoginsPage.tsx index 1238a0b..2709bd3 100644 --- a/components/PrivyLogins/PrivyLoginsPage.tsx +++ b/components/PrivyLogins/PrivyLoginsPage.tsx @@ -5,15 +5,10 @@ import PageBreadcrumb from "@/components/Sandboxes/PageBreadcrumb"; import ApiDocsLink from "@/components/ApiDocsLink"; import { usePrivyLogins } from "@/hooks/usePrivyLogins"; import PrivyLoginsTable from "@/components/PrivyLogins/PrivyLoginsTable"; +import PrivyPeriodSelector from "@/components/PrivyLogins/PrivyPeriodSelector"; +import PrivyLoginsStats from "@/components/PrivyLogins/PrivyLoginsStats"; import type { PrivyLoginsPeriod } from "@/types/privy"; -const PERIODS: { value: PrivyLoginsPeriod; label: string }[] = [ - { value: "all", label: "All Time" }, - { value: "daily", label: "Daily" }, - { value: "weekly", label: "Weekly" }, - { value: "monthly", label: "Monthly" }, -]; - export default function PrivyLoginsPage() { const [period, setPeriod] = useState("all"); const { data, isLoading, error } = usePrivyLogins(period); @@ -34,35 +29,8 @@ export default function PrivyLoginsPage() {
-
- {PERIODS.map(({ value, label }) => ( - - ))} -
- - {data && ( -
- - {data.total_new} new - - - {data.total_active} active - - - {data.total} total - -
- )} + + {data && }
{isLoading && ( diff --git a/components/PrivyLogins/PrivyLoginsStats.tsx b/components/PrivyLogins/PrivyLoginsStats.tsx new file mode 100644 index 0000000..8fa2a23 --- /dev/null +++ b/components/PrivyLogins/PrivyLoginsStats.tsx @@ -0,0 +1,21 @@ +import type { PrivyLoginsResponse } from "@/types/privy"; + +interface PrivyLoginsStatsProps { + data: PrivyLoginsResponse; +} + +export default function PrivyLoginsStats({ data }: PrivyLoginsStatsProps) { + return ( +
+ + {data.total_new} new + + + {data.total_active} active + + + {data.total} total + +
+ ); +} diff --git a/components/PrivyLogins/PrivyLoginsTable.tsx b/components/PrivyLogins/PrivyLoginsTable.tsx index fda0ec5..1cba2b3 100644 --- a/components/PrivyLogins/PrivyLoginsTable.tsx +++ b/components/PrivyLogins/PrivyLoginsTable.tsx @@ -1,13 +1,12 @@ import { Table, TableBody, - TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import type { PrivyUser } from "@/types/privy"; -import { getEmail } from "@/lib/privy/getEmail"; +import PrivyLoginRow from "@/components/PrivyLogins/PrivyLoginRow"; interface PrivyLoginsTableProps { logins: PrivyUser[]; @@ -25,20 +24,9 @@ export default function PrivyLoginsTable({ logins }: PrivyLoginsTableProps) { - {logins.map((login) => { - const email = getEmail(login); - return ( - - - {email ?? No email} - - {login.id} - - {new Date(login.created_at * 1000).toLocaleString()} - - - ); - })} + {logins.map((login) => ( + + ))} diff --git a/components/PrivyLogins/PrivyPeriodSelector.tsx b/components/PrivyLogins/PrivyPeriodSelector.tsx new file mode 100644 index 0000000..5295113 --- /dev/null +++ b/components/PrivyLogins/PrivyPeriodSelector.tsx @@ -0,0 +1,33 @@ +import type { PrivyLoginsPeriod } from "@/types/privy"; + +const PERIODS: { value: PrivyLoginsPeriod; label: string }[] = [ + { value: "all", label: "All Time" }, + { value: "daily", label: "Daily" }, + { value: "weekly", label: "Weekly" }, + { value: "monthly", label: "Monthly" }, +]; + +interface PrivyPeriodSelectorProps { + period: PrivyLoginsPeriod; + onPeriodChange: (period: PrivyLoginsPeriod) => void; +} + +export default function PrivyPeriodSelector({ period, onPeriodChange }: PrivyPeriodSelectorProps) { + return ( +
+ {PERIODS.map(({ value, label }) => ( + + ))} +
+ ); +} From fe1db6978d20a69aa3ed8715a9664c257b7fb176 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 17 Mar 2026 12:48:28 -0500 Subject: [PATCH 06/18] style: use PrivyUser alias instead of inline import Co-Authored-By: Claude Opus 4.6 (1M context) --- types/privy.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/types/privy.ts b/types/privy.ts index e8129c8..be46268 100644 --- a/types/privy.ts +++ b/types/privy.ts @@ -1,4 +1,6 @@ -export type { User as PrivyUser } from "@privy-io/react-auth"; +import type { User } from "@privy-io/react-auth"; + +export type PrivyUser = User; export type PrivyLoginsPeriod = "all" | "daily" | "weekly" | "monthly"; @@ -8,5 +10,5 @@ export type PrivyLoginsResponse = { total_new: number; total_active: number; total_privy_users: number; - logins: import("@privy-io/react-auth").User[]; + logins: PrivyUser[]; }; From 7ae551b3970003e04997ef8d9de3d45df15f3980 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 17 Mar 2026 12:49:55 -0500 Subject: [PATCH 07/18] fix: define PrivyUser matching raw Management API response (snake_case) The React SDK's User type uses camelCase (createdAt: Date) but our API returns the raw Privy Management API shape with snake_case (created_at: number). Define our own type to match the actual response. Co-Authored-By: Claude Opus 4.6 (1M context) --- types/privy.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/types/privy.ts b/types/privy.ts index be46268..66fd76f 100644 --- a/types/privy.ts +++ b/types/privy.ts @@ -1,6 +1,24 @@ -import type { User } from "@privy-io/react-auth"; +export type PrivyLinkedAccount = { + type: string; + address?: string; + verified_at?: number; + first_verified_at?: number | null; + latest_verified_at?: number | null; + [key: string]: unknown; +}; -export type PrivyUser = User; +/** + * Raw Privy user object as returned by the Management API (snake_case). + * Differs from @privy-io/react-auth's User type which uses camelCase. + */ +export type PrivyUser = { + id: string; + created_at: number; + linked_accounts: PrivyLinkedAccount[]; + mfa_methods: unknown[]; + has_accepted_terms: boolean; + is_guest: boolean; +}; export type PrivyLoginsPeriod = "all" | "daily" | "weekly" | "monthly"; From 6b7eefbee9786ccf87ff3d4ea935e56045296821 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 17 Mar 2026 12:51:42 -0500 Subject: [PATCH 08/18] fix: use intersection type for PrivyLinkedAccount so address resolves as string Index signature [key: string]: unknown was overriding the explicit address?: string property, causing it to resolve as unknown. Co-Authored-By: Claude Opus 4.6 (1M context) --- types/privy.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/types/privy.ts b/types/privy.ts index 66fd76f..5a3fae3 100644 --- a/types/privy.ts +++ b/types/privy.ts @@ -1,10 +1,9 @@ -export type PrivyLinkedAccount = { +export type PrivyLinkedAccount = Record & { type: string; address?: string; verified_at?: number; first_verified_at?: number | null; latest_verified_at?: number | null; - [key: string]: unknown; }; /** From 78aeb1a8116d4e87024bcf83a031b806846688f6 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 17 Mar 2026 12:53:38 -0500 Subject: [PATCH 09/18] feat: add Last Seen column to privy logins table Shows the most recent latest_verified_at across all linked accounts. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/PrivyLogins/PrivyLoginRow.tsx | 5 +++++ components/PrivyLogins/PrivyLoginsTable.tsx | 1 + lib/privy/getLastSeen.ts | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 lib/privy/getLastSeen.ts diff --git a/components/PrivyLogins/PrivyLoginRow.tsx b/components/PrivyLogins/PrivyLoginRow.tsx index de4121f..80e8f50 100644 --- a/components/PrivyLogins/PrivyLoginRow.tsx +++ b/components/PrivyLogins/PrivyLoginRow.tsx @@ -1,6 +1,7 @@ import { TableRow, TableCell } from "@/components/ui/table"; import type { PrivyUser } from "@/types/privy"; import { getEmail } from "@/lib/privy/getEmail"; +import { getLastSeen } from "@/lib/privy/getLastSeen"; interface PrivyLoginRowProps { login: PrivyUser; @@ -8,6 +9,7 @@ interface PrivyLoginRowProps { export default function PrivyLoginRow({ login }: PrivyLoginRowProps) { const email = getEmail(login); + const lastSeen = getLastSeen(login); return ( @@ -17,6 +19,9 @@ export default function PrivyLoginRow({ login }: PrivyLoginRowProps) { {new Date(login.created_at * 1000).toLocaleString()} + + {lastSeen ? new Date(lastSeen * 1000).toLocaleString() : Never} + ); } diff --git a/components/PrivyLogins/PrivyLoginsTable.tsx b/components/PrivyLogins/PrivyLoginsTable.tsx index 1cba2b3..81d1dd5 100644 --- a/components/PrivyLogins/PrivyLoginsTable.tsx +++ b/components/PrivyLogins/PrivyLoginsTable.tsx @@ -21,6 +21,7 @@ export default function PrivyLoginsTable({ logins }: PrivyLoginsTableProps) { Email Privy DID Created At + Last Seen diff --git a/lib/privy/getLastSeen.ts b/lib/privy/getLastSeen.ts new file mode 100644 index 0000000..2bc83a9 --- /dev/null +++ b/lib/privy/getLastSeen.ts @@ -0,0 +1,18 @@ +import type { PrivyUser } from "@/types/privy"; + +/** + * Returns the most recent latest_verified_at (in seconds) across all linked accounts. + * Returns null if no linked account has a latest_verified_at. + */ +export function getLastSeen(user: PrivyUser): number | null { + let latest: number | null = null; + + for (const account of user.linked_accounts) { + const verifiedAt = account.latest_verified_at; + if (typeof verifiedAt === "number" && (latest === null || verifiedAt > latest)) { + latest = verifiedAt; + } + } + + return latest; +} From ea114fac00b88a84ff5cfb65fb53c5c4d923b461 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 17 Mar 2026 12:54:01 -0500 Subject: [PATCH 10/18] fix: remove duplicate total count, keep only new and active Co-Authored-By: Claude Opus 4.6 (1M context) --- components/PrivyLogins/PrivyLoginsStats.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/components/PrivyLogins/PrivyLoginsStats.tsx b/components/PrivyLogins/PrivyLoginsStats.tsx index 8fa2a23..bb6d03e 100644 --- a/components/PrivyLogins/PrivyLoginsStats.tsx +++ b/components/PrivyLogins/PrivyLoginsStats.tsx @@ -13,9 +13,6 @@ export default function PrivyLoginsStats({ data }: PrivyLoginsStatsProps) { {data.total_active} active - - {data.total} total - ); } From 2aa2d11c99deaade550bf992f8f5205f87aa59eb Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 17 Mar 2026 12:54:20 -0500 Subject: [PATCH 11/18] fix: remove Privy DID column from logins table Co-Authored-By: Claude Opus 4.6 (1M context) --- components/PrivyLogins/PrivyLoginRow.tsx | 1 - components/PrivyLogins/PrivyLoginsTable.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/components/PrivyLogins/PrivyLoginRow.tsx b/components/PrivyLogins/PrivyLoginRow.tsx index 80e8f50..3825d05 100644 --- a/components/PrivyLogins/PrivyLoginRow.tsx +++ b/components/PrivyLogins/PrivyLoginRow.tsx @@ -15,7 +15,6 @@ export default function PrivyLoginRow({ login }: PrivyLoginRowProps) { {email ?? No email} - {login.id} {new Date(login.created_at * 1000).toLocaleString()} diff --git a/components/PrivyLogins/PrivyLoginsTable.tsx b/components/PrivyLogins/PrivyLoginsTable.tsx index 81d1dd5..14d7586 100644 --- a/components/PrivyLogins/PrivyLoginsTable.tsx +++ b/components/PrivyLogins/PrivyLoginsTable.tsx @@ -19,7 +19,6 @@ export default function PrivyLoginsTable({ logins }: PrivyLoginsTableProps) { Email - Privy DID Created At Last Seen From 87ec01c3738269f8a8b558b912070eb7184f747b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 17 Mar 2026 12:55:42 -0500 Subject: [PATCH 12/18] refactor: switch to DataTable with sortable Created At and Last Seen columns Uses @tanstack/react-table and SortableHeader, matching the pattern from SandboxesTable. Default sort: Created At descending. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/PrivyLogins/PrivyLoginRow.tsx | 26 -------- components/PrivyLogins/PrivyLoginsTable.tsx | 64 ++++++++++++++++--- components/PrivyLogins/privyLoginsColumns.tsx | 34 ++++++++++ 3 files changed, 88 insertions(+), 36 deletions(-) delete mode 100644 components/PrivyLogins/PrivyLoginRow.tsx create mode 100644 components/PrivyLogins/privyLoginsColumns.tsx diff --git a/components/PrivyLogins/PrivyLoginRow.tsx b/components/PrivyLogins/PrivyLoginRow.tsx deleted file mode 100644 index 3825d05..0000000 --- a/components/PrivyLogins/PrivyLoginRow.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { TableRow, TableCell } from "@/components/ui/table"; -import type { PrivyUser } from "@/types/privy"; -import { getEmail } from "@/lib/privy/getEmail"; -import { getLastSeen } from "@/lib/privy/getLastSeen"; - -interface PrivyLoginRowProps { - login: PrivyUser; -} - -export default function PrivyLoginRow({ login }: PrivyLoginRowProps) { - const email = getEmail(login); - const lastSeen = getLastSeen(login); - return ( - - - {email ?? No email} - - - {new Date(login.created_at * 1000).toLocaleString()} - - - {lastSeen ? new Date(lastSeen * 1000).toLocaleString() : Never} - - - ); -} diff --git a/components/PrivyLogins/PrivyLoginsTable.tsx b/components/PrivyLogins/PrivyLoginsTable.tsx index 14d7586..b036e1d 100644 --- a/components/PrivyLogins/PrivyLoginsTable.tsx +++ b/components/PrivyLogins/PrivyLoginsTable.tsx @@ -1,32 +1,76 @@ +"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 { privyLoginsColumns } from "@/components/PrivyLogins/privyLoginsColumns"; import type { PrivyUser } from "@/types/privy"; -import PrivyLoginRow from "@/components/PrivyLogins/PrivyLoginRow"; interface PrivyLoginsTableProps { logins: PrivyUser[]; } export default function PrivyLoginsTable({ logins }: PrivyLoginsTableProps) { + const [sorting, setSorting] = useState([ + { id: "created_at", desc: true }, + ]); + + const table = useReactTable({ + data: logins, + columns: privyLoginsColumns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + return ( -
+
- - Email - Created At - Last Seen - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} - {logins.map((login) => ( - - ))} + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )}
diff --git a/components/PrivyLogins/privyLoginsColumns.tsx b/components/PrivyLogins/privyLoginsColumns.tsx new file mode 100644 index 0000000..b9dc1a4 --- /dev/null +++ b/components/PrivyLogins/privyLoginsColumns.tsx @@ -0,0 +1,34 @@ +import { type ColumnDef } from "@tanstack/react-table"; +import { SortableHeader } from "@/components/SandboxOrgs/SortableHeader"; +import { getEmail } from "@/lib/privy/getEmail"; +import { getLastSeen } from "@/lib/privy/getLastSeen"; +import type { PrivyUser } from "@/types/privy"; + +export const privyLoginsColumns: ColumnDef[] = [ + { + id: "email", + accessorFn: (row) => getEmail(row), + header: "Email", + cell: ({ getValue }) => { + const email = getValue(); + return email ?? No email; + }, + }, + { + id: "created_at", + accessorKey: "created_at", + header: ({ column }) => , + cell: ({ getValue }) => new Date(getValue() * 1000).toLocaleString(), + sortingFn: "basic", + }, + { + id: "last_seen", + accessorFn: (row) => getLastSeen(row), + header: ({ column }) => , + cell: ({ getValue }) => { + const ts = getValue(); + return ts ? new Date(ts * 1000).toLocaleString() : Never; + }, + sortingFn: "basic", + }, +]; From 231fb4f3cd788e9cae48cef074b3c7dc127a456f Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 17 Mar 2026 12:56:15 -0500 Subject: [PATCH 13/18] fix: use TableSkeleton for loading state instead of text Co-Authored-By: Claude Opus 4.6 (1M context) --- components/PrivyLogins/PrivyLoginsPage.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/components/PrivyLogins/PrivyLoginsPage.tsx b/components/PrivyLogins/PrivyLoginsPage.tsx index 2709bd3..f8e9eea 100644 --- a/components/PrivyLogins/PrivyLoginsPage.tsx +++ b/components/PrivyLogins/PrivyLoginsPage.tsx @@ -7,6 +7,7 @@ import { usePrivyLogins } from "@/hooks/usePrivyLogins"; import PrivyLoginsTable from "@/components/PrivyLogins/PrivyLoginsTable"; import PrivyPeriodSelector from "@/components/PrivyLogins/PrivyPeriodSelector"; import PrivyLoginsStats from "@/components/PrivyLogins/PrivyLoginsStats"; +import TableSkeleton from "@/components/Sandboxes/TableSkeleton"; import type { PrivyLoginsPeriod } from "@/types/privy"; export default function PrivyLoginsPage() { @@ -34,9 +35,7 @@ export default function PrivyLoginsPage() {
{isLoading && ( -
- Loading logins… -
+ )} {error && ( From 8d62c3e4c0145362e714431ab31bf90909d19b7b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 17 Mar 2026 13:02:46 -0500 Subject: [PATCH 14/18] feat: add Last Seen line chart using shadcn/recharts Shows daily last seen activity counts as a line chart above the table. Uses shadcn ChartContainer with Recharts LineChart. Data grouped by date via getLastSeenByDate helper. Co-Authored-By: Claude Opus 4.6 (1M context) --- components/PrivyLogins/PrivyLastSeenChart.tsx | 69 ++++ components/PrivyLogins/PrivyLoginsPage.tsx | 6 +- components/ui/chart.tsx | 357 ++++++++++++++++ lib/privy/getLastSeenByDate.ts | 27 ++ package.json | 5 +- pnpm-lock.yaml | 384 +++++++++++++++--- 6 files changed, 780 insertions(+), 68 deletions(-) create mode 100644 components/PrivyLogins/PrivyLastSeenChart.tsx create mode 100644 components/ui/chart.tsx create mode 100644 lib/privy/getLastSeenByDate.ts diff --git a/components/PrivyLogins/PrivyLastSeenChart.tsx b/components/PrivyLogins/PrivyLastSeenChart.tsx new file mode 100644 index 0000000..e2fe6d8 --- /dev/null +++ b/components/PrivyLogins/PrivyLastSeenChart.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import type { PrivyUser } from "@/types/privy"; +import { getLastSeenByDate } from "@/lib/privy/getLastSeenByDate"; + +const chartConfig = { + count: { + label: "Last Seen", + color: "#345A5D", + }, +} satisfies ChartConfig; + +interface PrivyLastSeenChartProps { + logins: PrivyUser[]; +} + +export default function PrivyLastSeenChart({ logins }: PrivyLastSeenChartProps) { + const data = getLastSeenByDate(logins); + + if (data.length === 0) return null; + + return ( +
+

+ Last Seen Activity +

+ + + + { + const d = new Date(value + "T00:00:00"); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + }} + /> + + { + const d = new Date(value + "T00:00:00"); + return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }); + }} + /> + } + /> + + + +
+ ); +} diff --git a/components/PrivyLogins/PrivyLoginsPage.tsx b/components/PrivyLogins/PrivyLoginsPage.tsx index f8e9eea..5dfafbb 100644 --- a/components/PrivyLogins/PrivyLoginsPage.tsx +++ b/components/PrivyLogins/PrivyLoginsPage.tsx @@ -8,6 +8,7 @@ import PrivyLoginsTable from "@/components/PrivyLogins/PrivyLoginsTable"; import PrivyPeriodSelector from "@/components/PrivyLogins/PrivyPeriodSelector"; import PrivyLoginsStats from "@/components/PrivyLogins/PrivyLoginsStats"; import TableSkeleton from "@/components/Sandboxes/TableSkeleton"; +import PrivyLastSeenChart from "@/components/PrivyLogins/PrivyLastSeenChart"; import type { PrivyLoginsPeriod } from "@/types/privy"; export default function PrivyLoginsPage() { @@ -51,7 +52,10 @@ export default function PrivyLoginsPage() { )} {!isLoading && !error && data && data.logins.length > 0 && ( - + <> + + + )} ); diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx new file mode 100644 index 0000000..1cdfc92 --- /dev/null +++ b/components/ui/chart.tsx @@ -0,0 +1,357 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] +}) { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +