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
5 changes: 5 additions & 0 deletions app/orgs/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import OrgsPage from "@/components/Orgs/OrgsPage";

export default function Page() {
return <OrgsPage />;
Comment on lines +1 to +4
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

Add the same admin/auth guard used by other admin surfaces.

This route renders admin content directly, while components/Home/HomeContent.tsx (Lines 1-38) enforces authenticated + isAdmin checks. /orgs should apply equivalent gating to prevent unprivileged users from accessing the page shell.

🔐 Suggested route-level adjustment
-import OrgsPage from "@/components/Orgs/OrgsPage";
+import OrgsContent from "@/components/Orgs/OrgsContent";

 export default function Page() {
-  return <OrgsPage />;
+  return <OrgsContent />;
 }
// components/Orgs/OrgsContent.tsx
"use client";

import { usePrivy } from "@privy-io/react-auth";
import { useIsAdmin } from "@/hooks/useIsAdmin";
import OrgsPage from "@/components/Orgs/OrgsPage";

export default function OrgsContent() {
  const { ready, authenticated } = usePrivy();
  const { data: isAdmin, isLoading } = useIsAdmin();

  if (!ready || isLoading) return null; // or skeleton
  if (!authenticated || !isAdmin) return <div>Access Denied</div>;

  return <OrgsPage />;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/orgs/page.tsx` around lines 1 - 4, The /orgs route currently renders
OrgsPage directly; wrap it with the same client-side admin/auth guard used
elsewhere by creating a client component (e.g., OrgsContent) that imports
usePrivy and useIsAdmin, checks ready/authenticated and isAdmin (returning null
or a loading skeleton while ready/isLoading, and an access-denied UI when not
authenticated or not admin), and then renders OrgsPage; update the default
export in app/orgs/page.tsx to render OrgsContent instead of OrgsPage so the
route enforces authenticated + isAdmin gating.

}
2 changes: 2 additions & 0 deletions components/Home/AdminDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SandboxesNavButton from "@/components/Home/SandboxesNavButton";
import OrgsNavButton from "@/components/Home/OrgsNavButton";

export default function AdminDashboard() {
return (
Expand All @@ -9,6 +10,7 @@ export default function AdminDashboard() {
</p>
<nav className="flex gap-4">
<SandboxesNavButton />
<OrgsNavButton />
</nav>
</div>
);
Expand Down
10 changes: 10 additions & 0 deletions components/Home/OrgsNavButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";

export default function OrgsNavButton() {
return (
<Button asChild variant="outline">
<Link href="/orgs">View Org Repos →</Link>
</Button>
);
}
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-7xl 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 organisation-level repositories and their 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 statistics.
* Default sort: Latest Commit descending (most recently active first).
*
* @param repos - Array of org repo rows from the admin API
*/
export default function OrgsTable({ repos }: OrgsTableProps) {
const [sorting, setSorting] = useState<SortingState>([
{ id: "latest_commit_at", 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 { useAdminOrgs } from "@/hooks/useAdminOrgs";
import OrgsTable from "@/components/Orgs/OrgsTable";

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

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 repos found.
</div>
);
}

return <OrgsTable repos={repos} />;
}
124 changes: 124 additions & 0 deletions components/Orgs/orgsColumns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, ArrowUp, ArrowDown, ExternalLink } 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="noreferrer"
className="flex items-center gap-1 font-medium hover:underline"
Comment on lines +13 to +15
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

Harden external-link opener isolation.

For target="_blank" links, include noopener in rel to explicitly prevent opener access.

💡 Suggested fix
-        rel="noreferrer"
+        rel="noopener noreferrer"
📝 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
target="_blank"
rel="noreferrer"
className="flex items-center gap-1 font-medium hover:underline"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 font-medium hover:underline"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/Orgs/orgsColumns.tsx` around lines 13 - 15, The anchor element in
orgsColumns.tsx that uses target="_blank" currently sets rel="noreferrer" —
update this to include noopener (e.g., rel="noreferrer noopener") to explicitly
prevent opener access; locate the anchor with attributes target="_blank" and
className="flex items-center gap-1 font-medium hover:underline" and modify its
rel value accordingly.

>
{row.getValue("repo_name")}
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</a>
),
},
{
accessorKey: "total_commits",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="-ml-3 h-8 px-3"
>
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_5_commit_messages",
header: "Latest 5 Commits",
cell: ({ row }) => {
const messages = row.getValue<string[]>("latest_5_commit_messages");
return (
<ul className="space-y-0.5 text-xs">
{messages.map((msg, i) => (
<li key={i} className="truncate max-w-xs text-muted-foreground" title={msg}>
{msg}
</li>
))}
</ul>
);
},
},
{
accessorKey: "earliest_commit_at",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="-ml-3 h-8 px-3"
>
Earliest 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 }) => {
const val = row.getValue<string>("earliest_commit_at");
return val ? new Date(val).toLocaleString() : "—";
},
sortingFn: "datetime",
},
{
accessorKey: "latest_commit_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 }) => {
const val = row.getValue<string>("latest_commit_at");
return val ? new Date(val).toLocaleString() : "—";
},
sortingFn: "datetime",
},
{
accessorKey: "account_repos_with_submodule",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="-ml-3 h-8 px-3"
>
Submodule Installs
{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/useAdminOrgs.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 { fetchAdminOrgs } from "@/lib/fetchAdminOrgs";

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

return useQuery({
queryKey: ["admin", "orgs"],
queryFn: async () => {
Comment on lines +12 to +16
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
set -euo pipefail

echo "== React Query version(s) =="
fd package.json --exec jq -r '.dependencies["@tanstack/react-query"] // .devDependencies["@tanstack/react-query"] // empty' {}

echo
echo "== Privy hook usage patterns =="
rg -n -C2 '\busePrivy\(' --type=ts --type=tsx

echo
echo "== Admin query key patterns =="
rg -n -C2 'queryKey:\s*\["admin"' --type=ts --type=tsx

Repository: recoupable/admin

Length of output: 174


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# First, find and display the useAdminOrgs.ts file
echo "== Content of hooks/useAdminOrgs.ts =="
find . -name "useAdminOrgs.ts" -type f -exec cat -n {} +

echo
echo "== Privy hook usage patterns (fixed) =="
rg -n -C2 'usePrivy' --type ts

echo
echo "== Admin query key patterns =="
rg -n -C2 'queryKey.*admin' --type ts

Repository: recoupable/admin

Length of output: 4473


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check useAdminSandboxes which was mentioned in search results
echo "== Content of hooks/useAdminSandboxes.ts =="
cat -n hooks/useAdminSandboxes.ts

echo
echo "== Content of hooks/useIsAdmin.ts =="
cat -n hooks/useIsAdmin.ts

Repository: recoupable/admin

Length of output: 2034


Scope the query cache key by authenticated user.

The static queryKey: ["admin", "orgs"] without a user identifier can leak cached org data across account switches in the same client session. Since usePrivy() exposes a user property (as seen in other components), include the user ID in the queryKey to isolate cache entries per user. Update the enabled condition to ensure the query doesn't run until the user ID is available.

Suggested fix
 export function useAdminOrgs() {
-  const { ready, authenticated, getAccessToken } = usePrivy();
+  const { ready, authenticated, user, getAccessToken } = usePrivy();
 
   return useQuery({
-    queryKey: ["admin", "orgs"],
+    queryKey: ["admin", "orgs", user?.id],
     queryFn: async () => {
       const token = await getAccessToken();
       if (!token) throw new Error("Not authenticated");
       return fetchAdminOrgs(token);
     },
-    enabled: ready && authenticated,
+    enabled: ready && authenticated && !!user?.id,
   });
 }

Similar patterns exist in useAdminSandboxes (lines 15, 21) and should receive the same treatment.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/useAdminOrgs.ts` around lines 12 - 16, The query cache key in
useAdminOrgs is currently static ("admin","orgs") which can leak data across
account switches; update useAdminOrgs to include the current user's ID from
usePrivy (e.g., queryKey: ["admin","orgs", user.id]) and change the enabled
condition to wait for the user id (e.g., enabled: ready && authenticated &&
!!user?.id); apply the same fix to useAdminSandboxes (replace its static
queryKey with ["admin","sandboxes", user.id] and guard its enabled flag on
user.id) so cache entries are isolated per authenticated user.

const token = await getAccessToken();
if (!token) throw new Error("Not authenticated");
return fetchAdminOrgs(token);
},
enabled: ready && authenticated,
});
}
23 changes: 23 additions & 0 deletions lib/fetchAdminOrgs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { API_BASE_URL } from "@/lib/consts";
import type { OrgRepoRow } from "@/types/org";

/**
* Fetches org repo 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 fetchAdminOrgs(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 ?? [];
Comment on lines +21 to +22
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

Handle logical API errors in successful HTTP responses.

The current success path ignores status from the response body. If the backend returns { status: "error" } with HTTP 200, this will silently return an empty list instead of surfacing the failure.

💡 Suggested fix
 import { API_BASE_URL } from "@/lib/consts";
-import type { OrgRepoRow } from "@/types/org";
+import type { AdminOrgsResponse, OrgRepoRow } from "@/types/org";
@@
-  const data = await res.json();
-  return data.repos ?? [];
+  const data = (await res.json()) as AdminOrgsResponse & { message?: string };
+  if (data.status !== "success") {
+    throw new Error(data.message ?? "Failed to load org repos");
+  }
+  return Array.isArray(data.repos) ? data.repos : [];
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/fetchAdminOrgs.ts` around lines 21 - 22, The code in
lib/fetchAdminOrgs.ts returns data.repos from the parsed JSON without checking
the API-level status field; update the success path in the function that calls
res.json() (the variable named data) to validate data.status (or equivalent) and
treat non-success values as errors: if data.status !== 'success' (or if
data.error/message exists) throw or return an explicit error instead of silently
returning data.repos ?? []; ensure the thrown error includes the API error
message/details so callers can surface the failure.

}
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_5_commit_messages: string[];
earliest_commit_at: string;
latest_commit_at: string;
account_repos_with_submodule: number;
}

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