Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/privy/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <PrivyLoginsPage />;
}
1 change: 1 addition & 0 deletions components/Home/AdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default function AdminDashboard() {
<nav className="flex gap-4">
<NavButton href="/sandboxes" label="View Sandboxes" />
<NavButton href="/sandboxes/orgs" label="View Org Commits" />
<NavButton href="/privy" label="View Privy Logins" />
</nav>
</div>
);
Expand Down
83 changes: 83 additions & 0 deletions components/PrivyLogins/PrivyLoginsPage.tsx
Original file line number Diff line number Diff line change
@@ -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<PrivyLoginsPeriod>("daily");
const { data, isLoading, error } = usePrivyLogins(period);

return (
<main className="mx-auto max-w-6xl px-4 py-10">
<div className="mb-6 flex items-start justify-between">
<div>
<PageBreadcrumb current="Privy Logins" />
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
Privy Logins
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
User sign-ins via Privy, grouped by time period.
</p>
</div>
<ApiDocsLink path="admins/privy" />
</div>

<div className="mb-6 flex items-center gap-4">
<div className="flex rounded-lg border bg-white dark:bg-gray-900 overflow-hidden">
{PERIODS.map(({ value, label }) => (
<button
key={value}
onClick={() => setPeriod(value)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
period === value
? "bg-gray-900 text-white dark:bg-white dark:text-gray-900"
: "text-gray-600 hover:bg-gray-50 dark:text-gray-400 dark:hover:bg-gray-800"
}`}
>
{label}
</button>
))}
</div>

{data && (
<span className="text-sm text-gray-500 dark:text-gray-400">
<span className="font-semibold text-gray-900 dark:text-gray-100">{data.total}</span>{" "}
login{data.total !== 1 ? "s" : ""}
</span>
)}
</div>

{isLoading && (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
Loading logins…
</div>
)}

{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 Privy logins"}
</div>
)}

{!isLoading && !error && data && data.logins.length === 0 && (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
No logins found for this period.
</div>
)}

{!isLoading && !error && data && data.logins.length > 0 && (
<PrivyLoginsTable logins={data.logins} />
)}
</main>
);
}
42 changes: 42 additions & 0 deletions components/PrivyLogins/PrivyLoginsTable.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Privy DID</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logins.map((login) => (
<TableRow key={login.privy_did}>
Comment on lines +27 to +28
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the API response could include duplicate privy_did values
# by looking at API route handler or documentation
fd -t f -e ts -e tsx "privy" | xargs rg -l "api.*admins.*privy" | head -5

Repository: recoupable/admin

Length of output: 114


🏁 Script executed:

cat -n lib/recoup/fetchPrivyLogins.ts

Repository: recoupable/admin

Length of output: 1234


🏁 Script executed:

cat -n hooks/usePrivyLogins.ts

Repository: recoupable/admin

Length of output: 1042


🏁 Script executed:

cat -n components/PrivyLogins/PrivyLoginsTable.tsx

Repository: recoupable/admin

Length of output: 1573


🏁 Script executed:

fd -t f -name "privy.ts" -o -name "privy.d.ts" | head -5

Repository: recoupable/admin

Length of output: 290


🏁 Script executed:

rg "type PrivyLoginRow|type PrivyLoginsResponse|interface PrivyLogin" --type ts

Repository: recoupable/admin

Length of output: 233


🏁 Script executed:

cat -n types/privy.ts

Repository: recoupable/admin

Length of output: 430


Using privy_did alone as the React key will cause reconciliation issues.

Since each PrivyLoginRow represents a distinct login event (note the created_at field), the same user can appear multiple times in a single period with different login timestamps. Using only privy_did as the key will produce duplicate keys, causing React reconciliation failures.

Use a compound key that includes created_at:

🔧 Proposed fix
-          {logins.map((login) => (
-            <TableRow key={login.privy_did}>
+          {logins.map((login) => (
+            <TableRow key={`${login.privy_did}-${login.created_at}`}>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{logins.map((login) => (
<TableRow key={login.privy_did}>
{logins.map((login) => (
<TableRow key={`${login.privy_did}-${login.created_at}`}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/PrivyLogins/PrivyLoginsTable.tsx` around lines 27 - 28, The
TableRow key in PrivyLoginsTable uses only login.privy_did which can duplicate
for multiple events; change the key to a stable compound key that includes the
timestamp (for example combine login.privy_did and login.created_at) so each
login event is unique. Update the TableRow key usage in the PrivyLoginsTable
(and any related PrivyLoginRow mapping) to something like a string concat of
privy_did and created_at to avoid duplicate React keys.

<TableCell className="font-medium">
{login.email ?? <span className="text-gray-400 italic">No email</span>}
</TableCell>
<TableCell className="font-mono text-xs text-gray-500">{login.privy_did}</TableCell>
<TableCell className="text-sm text-gray-600 dark:text-gray-400">
{new Date(login.created_at).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
24 changes: 24 additions & 0 deletions hooks/usePrivyLogins.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
29 changes: 29 additions & 0 deletions lib/recoup/fetchPrivyLogins.ts
Original file line number Diff line number Diff line change
@@ -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<PrivyLoginsResponse> {
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();
}
13 changes: 13 additions & 0 deletions types/privy.ts
Original file line number Diff line number Diff line change
@@ -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[];
};
Loading