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/sandboxes/orgs/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import OrgsPage from "@/components/Orgs/OrgsPage";

export const metadata = {
title: "Org Repos — Recoup Admin",
description: "View all GitHub org repositories and their commit statistics.",
};

export default function Page() {
return <OrgsPage />;
}
17 changes: 17 additions & 0 deletions components/Orgs/OrgsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import OrgsTableContainer from "@/components/Orgs/OrgsTableContainer";

export default function OrgsPage() {
return (
<main className="mx-auto max-w-5xl px-4 py-10">
<div className="mb-6">
<h1 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
Org Repos
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
All GitHub org repositories and their commit statistics.
</p>
</div>
<OrgsTableContainer />
</main>
);
}
90 changes: 90 additions & 0 deletions components/Orgs/OrgsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"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 { orgsColumns } from "@/components/Orgs/orgsColumns";
import type { OrgRepoRow } from "@/types/org";

interface OrgsTableProps {
repos: OrgRepoRow[];
}

/**
* Renders a shadcn Data Table of org repos and their commit statistics.
* Default sort: Total Commits descending (most commits first).
*
* @param repos - Array of org repo rows from the admin API
*/
export default function OrgsTable({ repos }: OrgsTableProps) {
const [sorting, setSorting] = useState<SortingState>([
{ id: "total_commits", desc: true },
]);

const table = useReactTable({
data: repos,
columns: orgsColumns,
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={orgsColumns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
34 changes: 34 additions & 0 deletions components/Orgs/OrgsTableContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";

import { useAdminSandboxOrgs } from "@/hooks/useAdminSandboxOrgs";
import OrgsTable from "@/components/Orgs/OrgsTable";

export default function OrgsTableContainer() {
const { data: repos, isLoading, error } = useAdminSandboxOrgs();

if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-gray-500">
Loading…
</div>
);
}

if (error) {
return (
<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 org repos"}
</div>
);
}

if (!repos || repos.length === 0) {
return (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
No org repo data found.
</div>
);
}
Comment on lines +25 to +31
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether auth-gating exists before rendering this container and whether disabled-query states are handled.
rg -nP --type=tsx 'useAdminSandboxOrgs\(' -C2
rg -nP --type=tsx 'No org repo data found' -C2
rg -nP --type=ts --type=tsx 'usePrivy\(|authenticated|ready|redirect\(' -C2

Repository: recoupable/admin

Length of output: 143


🏁 Script executed:

#!/bin/bash
# Find TypeScript/TSX files and search for relevant patterns
fd -e tsx -e ts | head -20

# Search for the component and hook usage
rg "useAdminSandboxOrgs" -A 5 -B 2

# Search for auth-related patterns
rg "usePrivy\|authenticated\|ready" -A 2 -B 2

# Find the specific error message
rg "No org repo data found"

# Check if route has auth protection
rg "middleware\|auth\|redirect" | grep -E "(middleware|auth|route)" | head -20

Repository: recoupable/admin

Length of output: 2137


🏁 Script executed:

#!/bin/bash
# Read the hook implementation
echo "=== Hook Implementation ==="
cat -n hooks/useAdminSandboxOrgs.ts

echo ""
echo "=== Component Implementation ==="
cat -n components/Orgs/OrgsTableContainer.tsx

echo ""
echo "=== Parent page that uses this component ==="
cat -n components/Orgs/OrgsPage.tsx

Repository: recoupable/admin

Length of output: 2830


🏁 Script executed:

#!/bin/bash
# Check the route page
echo "=== Route page ==="
cat -n app/sandboxes/orgs/page.tsx

echo ""
echo "=== Check for middleware ==="
find . -name "middleware.*" -o -name "*middleware*" | grep -v node_modules

echo ""
echo "=== Check for auth guards in app layout ==="
cat -n app/layout.tsx | head -50

Repository: recoupable/admin

Length of output: 1682


Handle disabled query state explicitly in OrgsTableContainer.

When the query is disabled (user not authenticated or Privy not ready), isLoading and error are both falsy, causing the component to display "No org repo data found." This is misleading UX that masks authentication state instead of indicating why data cannot be loaded.

Add explicit ready and authenticated checks before showing the empty state. The hook already has access to Privy state; the container needs to mirror these checks or pass them through.

Suggested approach
 "use client";
 
 import { useAdminSandboxOrgs } from "@/hooks/useAdminSandboxOrgs";
 import OrgsTable from "@/components/Orgs/OrgsTable";
+import { usePrivy } from "@privy-io/react-auth";
 
 export default function OrgsTableContainer() {
+  const { ready, authenticated } = usePrivy();
   const { data: repos, isLoading, error } = useAdminSandboxOrgs();
+
+  if (!ready) {
+    return <div className="flex items-center justify-center py-12 text-sm text-gray-500">Loading…</div>;
+  }
+
+  if (!authenticated) {
+    return <div className="flex items-center justify-center py-12 text-sm text-gray-400">Sign in to view org repos.</div>;
+  }
 
   if (isLoading) {
📝 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
if (!repos || repos.length === 0) {
return (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
No org repo data found.
</div>
);
}
"use client";
import { useAdminSandboxOrgs } from "@/hooks/useAdminSandboxOrgs";
import OrgsTable from "@/components/Orgs/OrgsTable";
import { usePrivy } from "@privy-io/react-auth";
export default function OrgsTableContainer() {
const { ready, authenticated } = usePrivy();
const { data: repos, isLoading, error } = useAdminSandboxOrgs();
if (!ready) {
return <div className="flex items-center justify-center py-12 text-sm text-gray-500">Loading…</div>;
}
if (!authenticated) {
return <div className="flex items-center justify-center py-12 text-sm text-gray-400">Sign in to view org repos.</div>;
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-gray-500">
Loading org repos...
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center py-12 text-sm text-red-400">
Error loading org repos.
</div>
);
}
if (!repos || repos.length === 0) {
return (
<div className="flex items-center justify-center py-12 text-sm text-gray-400">
No org repo data found.
</div>
);
}
return <OrgsTable repos={repos} />;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/Orgs/OrgsTableContainer.tsx` around lines 25 - 31, The empty-state
branch in OrgsTableContainer currently only checks repos and shows "No org repo
data found."—update the component to explicitly handle the Privy/query disabled
state by checking the hook's ready and authenticated flags (or accepting them as
props) before showing the empty message: if !ready show a loading/placeholder,
if ready && !authenticated show an auth prompt/message, if authenticated &&
!isLoading && !error && repos?.length === 0 show the "No org repo data found"
message; reference the OrgsTableContainer component and the
repos/isLoading/error/ready/authenticated values from the query hook to
implement these conditional returns.


return <OrgsTable repos={repos} />;
}
119 changes: 119 additions & 0 deletions components/Orgs/orgsColumns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { OrgRepoRow } from "@/types/org";

export const orgsColumns: ColumnDef<OrgRepoRow>[] = [
{
accessorKey: "repo_name",
header: "Repository",
cell: ({ row }) => (
<a
href={row.original.repo_url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{row.getValue("repo_name")}
</a>
),
},
{
accessorKey: "total_commits",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="-ml-3 h-8 px-3"
>
Total Commits
{column.getIsSorted() === "asc" ? (
<ArrowUp className="ml-1 h-4 w-4" />
) : column.getIsSorted() === "desc" ? (
<ArrowDown className="ml-1 h-4 w-4" />
) : (
<ArrowUpDown className="ml-1 h-4 w-4 text-muted-foreground" />
)}
</Button>
),
},
{
accessorKey: "latest_commit_messages",
header: "Latest Commits",
cell: ({ row }) => {
const messages = row.getValue<string[]>("latest_commit_messages");
return (
<ul className="space-y-0.5 text-xs text-gray-600 dark:text-gray-400">
{messages.map((msg, i) => (
<li key={i} className="truncate max-w-xs" title={msg}>
{msg}
</li>
))}
</ul>
);
},
},
{
accessorKey: "earliest_committed_at",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="-ml-3 h-8 px-3"
>
First Commit
{column.getIsSorted() === "asc" ? (
<ArrowUp className="ml-1 h-4 w-4" />
) : column.getIsSorted() === "desc" ? (
<ArrowDown className="ml-1 h-4 w-4" />
) : (
<ArrowUpDown className="ml-1 h-4 w-4 text-muted-foreground" />
)}
</Button>
),
cell: ({ row }) =>
new Date(row.getValue<string>("earliest_committed_at")).toLocaleString(),
sortingFn: "datetime",
},
{
accessorKey: "latest_committed_at",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="-ml-3 h-8 px-3"
>
Latest Commit
{column.getIsSorted() === "asc" ? (
<ArrowUp className="ml-1 h-4 w-4" />
) : column.getIsSorted() === "desc" ? (
<ArrowDown className="ml-1 h-4 w-4" />
) : (
<ArrowUpDown className="ml-1 h-4 w-4 text-muted-foreground" />
)}
</Button>
),
cell: ({ row }) =>
new Date(row.getValue<string>("latest_committed_at")).toLocaleString(),
sortingFn: "datetime",
},
{
accessorKey: "account_repo_count",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="-ml-3 h-8 px-3"
>
Account Repos
{column.getIsSorted() === "asc" ? (
<ArrowUp className="ml-1 h-4 w-4" />
) : column.getIsSorted() === "desc" ? (
<ArrowDown className="ml-1 h-4 w-4" />
) : (
<ArrowUpDown className="ml-1 h-4 w-4 text-muted-foreground" />
)}
</Button>
),
},
];
23 changes: 23 additions & 0 deletions hooks/useAdminSandboxOrgs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { usePrivy } from "@privy-io/react-auth";
import { fetchAdminSandboxOrgs } from "@/lib/fetchAdminSandboxOrgs";

/**
* Fetches org repo commit statistics from GET /api/admins/sandboxes/orgs
* using TanStack Query. Authenticates with the Privy access token.
*/
export function useAdminSandboxOrgs() {
const { ready, authenticated, getAccessToken } = usePrivy();

return useQuery({
queryKey: ["admin", "sandboxes", "orgs"],
queryFn: async () => {
const token = await getAccessToken();
if (!token) throw new Error("Not authenticated");
return fetchAdminSandboxOrgs(token);
},
enabled: ready && authenticated,
Comment on lines +15 to +21
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether query cache is user-scoped or explicitly cleared on auth transitions.
rg -nP --type=ts --type=tsx 'queryKey:\s*\[\s*"admin"\s*,\s*"sandboxes"(?:\s*,\s*"orgs")?' -C2
rg -nP --type=ts --type=tsx 'removeQueries|resetQueries|clear\(|invalidateQueries|signOut|logout|logOut' -C2

Repository: recoupable/admin

Length of output: 115


🏁 Script executed:

#!/bin/bash
# Find the target file and understand the codebase structure
find . -name "useAdminSandboxOrgs.ts" -o -name "useAdminSandboxOrgs.tsx" 2>/dev/null

# Also look for hooks directory
fd -type f 'useAdmin' . 2>/dev/null | head -20

Repository: recoupable/admin

Length of output: 90


🏁 Script executed:

#!/bin/bash
# Search for cache clearing patterns without type restrictions
rg "queryKey.*admin.*sandbox" -B 2 -A 5 2>/dev/null
rg "invalidateQueries|removeQueries|resetQueries|clearCache|signOut|logout" -B 1 -A 2 2>/dev/null | head -50

Repository: recoupable/admin

Length of output: 1483


🏁 Script executed:

#!/bin/bash
# Look for Privy auth setup and cache clearing
rg "usePrivy|signOut|logout|authentication" -B 1 -A 1 2>/dev/null | head -80

Repository: recoupable/admin

Length of output: 2343


🏁 Script executed:

#!/bin/bash
# Check query client setup and cache clearing handlers
rg "QueryClient|new QueryClient" -B 2 -A 5 2>/dev/null
rg "logout|signOut" -B 3 -A 5 2>/dev/null | grep -A 5 -B 3 "logout\|signOut"

Repository: recoupable/admin

Length of output: 1844


🏁 Script executed:

#!/bin/bash
# Check useIsAdmin.ts for same pattern
cat hooks/useIsAdmin.ts 2>/dev/null

Repository: recoupable/admin

Length of output: 869


🏁 Script executed:

#!/bin/bash
# Check where useAdminSandboxOrgs is used and if there's cache invalidation nearby
rg "useAdminSandboxOrgs" -B 2 -A 2 2>/dev/null

Repository: recoupable/admin

Length of output: 1004


🏁 Script executed:

#!/bin/bash
# Check PrivyProvider for logout handlers or cache clearing
cat providers/PrivyProvider.tsx 2>/dev/null

Repository: recoupable/admin

Length of output: 973


🏁 Script executed:

#!/bin/bash
# Check full useAdminSandboxOrgs.ts implementation
cat hooks/useAdminSandboxOrgs.ts 2>/dev/null

Repository: recoupable/admin

Length of output: 785


Scope the query cache key to the authenticated identity.

Using a static key for authenticated data risks serving stale cached data after logout/sign-in/user-switch in the same browser session. When logout() is called in LoginButton, the query cache is not cleared, so subsequent logins will retrieve data cached under the previous user's session.

This pattern affects multiple auth-bound queries (useAdminSandboxOrgs, useAdminSandboxes, useIsAdmin). Either scope query keys to user identity or implement explicit cache invalidation on auth state changes.

🔐 Proposed fix
 export function useAdminSandboxOrgs() {
-  const { ready, authenticated, getAccessToken } = usePrivy();
+  const { ready, authenticated, user, getAccessToken } = usePrivy();
 
   return useQuery({
-    queryKey: ["admin", "sandboxes", "orgs"],
+    queryKey: ["admin", "sandboxes", "orgs", user?.id],
     queryFn: async () => {
       const token = await getAccessToken();
       if (!token) throw new Error("Not authenticated");
       return fetchAdminSandboxOrgs(token);
     },
-    enabled: ready && authenticated,
+    enabled: ready && authenticated && !!user?.id,
   });
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/useAdminSandboxOrgs.ts` around lines 15 - 21, The query cache key in
useAdminSandboxOrgs is too generic (queryKey: ["admin","sandboxes","orgs"]) and
can return another user's data after logout/sign-in; change the query key to
include the authenticated identity (for example include getAccessToken() result
or a stable user id) so cache is scoped per user, or alternatively ensure you
invalidate related queries on auth changes (see LoginButton logout flow) by
calling queryClient.invalidateQueries or queryClient.removeQueries for the keys
used by useAdminSandboxOrgs and other auth-bound hooks (useAdminSandboxes,
useIsAdmin).

});
}
25 changes: 25 additions & 0 deletions lib/fetchAdminSandboxOrgs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { API_BASE_URL } from "@/lib/consts";
import type { OrgRepoRow } from "@/types/org";

/**
* Fetches org repo commit statistics from GET /api/admins/sandboxes/orgs.
* Authenticates using the caller's Privy access token.
*
* @param accessToken - Privy access token from getAccessToken()
* @returns Array of org repo rows
*/
export async function fetchAdminSandboxOrgs(
accessToken: string,
): Promise<OrgRepoRow[]> {
const res = await fetch(`${API_BASE_URL}/api/admins/sandboxes/orgs`, {
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? `HTTP ${res.status}`);
}

const data = await res.json();
return data.repos ?? [];
}
14 changes: 14 additions & 0 deletions types/org.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export interface OrgRepoRow {
repo_name: string;
repo_url: string;
total_commits: number;
latest_commit_messages: string[];
earliest_committed_at: string;
latest_committed_at: string;
account_repo_count: number;
}

export interface AdminSandboxOrgsResponse {
status: "success" | "error";
repos: OrgRepoRow[];
}
Loading