-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add Org Repos table page at /orgs #7
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,5 @@ | ||
| import OrgsPage from "@/components/Orgs/OrgsPage"; | ||
|
|
||
| export default function Page() { | ||
| return <OrgsPage />; | ||
| } | ||
| 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> | ||
| ); | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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> | ||
| ); | ||
| } |
| 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} />; | ||
| } |
| 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
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. Harden external-link opener isolation. For 💡 Suggested fix- rel="noreferrer"
+ rel="noopener noreferrer"📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| > | ||||||||||||||
| {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> | ||||||||||||||
| ), | ||||||||||||||
| }, | ||||||||||||||
| ]; | ||||||||||||||
| 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
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🏁 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=tsxRepository: 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 tsRepository: 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.tsRepository: recoupable/admin Length of output: 2034 Scope the query cache key by authenticated user. The static 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 🤖 Prompt for AI Agents |
||
| const token = await getAccessToken(); | ||
| if (!token) throw new Error("Not authenticated"); | ||
| return fetchAdminOrgs(token); | ||
| }, | ||
| enabled: ready && authenticated, | ||
| }); | ||
| } | ||
| 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
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. Handle logical API errors in successful HTTP responses. The current success path ignores 💡 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 |
||
| } | ||
| 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[]; | ||
| } |
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.
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) enforcesauthenticated+isAdminchecks./orgsshould apply equivalent gating to prevent unprivileged users from accessing the page shell.🔐 Suggested route-level adjustment
🤖 Prompt for AI Agents