diff --git a/app/orgs/page.tsx b/app/orgs/page.tsx new file mode 100644 index 0000000..c23823b --- /dev/null +++ b/app/orgs/page.tsx @@ -0,0 +1,5 @@ +import OrgsPage from "@/components/Orgs/OrgsPage"; + +export default function Page() { + return ; +} diff --git a/components/Home/AdminDashboard.tsx b/components/Home/AdminDashboard.tsx index 358d00d..e0687e9 100644 --- a/components/Home/AdminDashboard.tsx +++ b/components/Home/AdminDashboard.tsx @@ -1,4 +1,5 @@ import SandboxesNavButton from "@/components/Home/SandboxesNavButton"; +import OrgsNavButton from "@/components/Home/OrgsNavButton"; export default function AdminDashboard() { return ( @@ -9,6 +10,7 @@ export default function AdminDashboard() {

); diff --git a/components/Home/OrgsNavButton.tsx b/components/Home/OrgsNavButton.tsx new file mode 100644 index 0000000..d00f425 --- /dev/null +++ b/components/Home/OrgsNavButton.tsx @@ -0,0 +1,10 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export default function OrgsNavButton() { + return ( + + ); +} diff --git a/components/Orgs/OrgsPage.tsx b/components/Orgs/OrgsPage.tsx new file mode 100644 index 0000000..4ba4edc --- /dev/null +++ b/components/Orgs/OrgsPage.tsx @@ -0,0 +1,17 @@ +import OrgsTableContainer from "@/components/Orgs/OrgsTableContainer"; + +export default function OrgsPage() { + return ( +
+
+

+ Org Repos +

+

+ All organisation-level repositories and their statistics. +

+
+ +
+ ); +} diff --git a/components/Orgs/OrgsTable.tsx b/components/Orgs/OrgsTable.tsx new file mode 100644 index 0000000..bf15200 --- /dev/null +++ b/components/Orgs/OrgsTable.tsx @@ -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([ + { id: "latest_commit_at", desc: true }, + ]); + + const table = useReactTable({ + data: repos, + columns: orgsColumns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {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/Orgs/OrgsTableContainer.tsx b/components/Orgs/OrgsTableContainer.tsx new file mode 100644 index 0000000..3882ebd --- /dev/null +++ b/components/Orgs/OrgsTableContainer.tsx @@ -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 ( +
+ Loading… +
+ ); + } + + if (error) { + return ( +
+ {error instanceof Error ? error.message : "Failed to load org repos"} +
+ ); + } + + if (!repos || repos.length === 0) { + return ( +
+ No org repos found. +
+ ); + } + + return ; +} diff --git a/components/Orgs/orgsColumns.tsx b/components/Orgs/orgsColumns.tsx new file mode 100644 index 0000000..be625c6 --- /dev/null +++ b/components/Orgs/orgsColumns.tsx @@ -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[] = [ + { + accessorKey: "repo_name", + header: "Repository", + cell: ({ row }) => ( + + {row.getValue("repo_name")} + + + ), + }, + { + accessorKey: "total_commits", + header: ({ column }) => ( + + ), + }, + { + accessorKey: "latest_5_commit_messages", + header: "Latest 5 Commits", + cell: ({ row }) => { + const messages = row.getValue("latest_5_commit_messages"); + return ( +
    + {messages.map((msg, i) => ( +
  • + {msg} +
  • + ))} +
+ ); + }, + }, + { + accessorKey: "earliest_commit_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const val = row.getValue("earliest_commit_at"); + return val ? new Date(val).toLocaleString() : "—"; + }, + sortingFn: "datetime", + }, + { + accessorKey: "latest_commit_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const val = row.getValue("latest_commit_at"); + return val ? new Date(val).toLocaleString() : "—"; + }, + sortingFn: "datetime", + }, + { + accessorKey: "account_repos_with_submodule", + header: ({ column }) => ( + + ), + }, +]; diff --git a/hooks/useAdminOrgs.ts b/hooks/useAdminOrgs.ts new file mode 100644 index 0000000..77b5f21 --- /dev/null +++ b/hooks/useAdminOrgs.ts @@ -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 () => { + const token = await getAccessToken(); + if (!token) throw new Error("Not authenticated"); + return fetchAdminOrgs(token); + }, + enabled: ready && authenticated, + }); +} diff --git a/lib/fetchAdminOrgs.ts b/lib/fetchAdminOrgs.ts new file mode 100644 index 0000000..9788f5e --- /dev/null +++ b/lib/fetchAdminOrgs.ts @@ -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 { + 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 ?? []; +} diff --git a/types/org.ts b/types/org.ts new file mode 100644 index 0000000..deae21a --- /dev/null +++ b/types/org.ts @@ -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[]; +}