From 66237bd217f0188968fd3ad81250219620e7c266 Mon Sep 17 00:00:00 2001 From: Iqbal Date: Wed, 4 Nov 2026 10:50:02 +0700 Subject: [PATCH] Add an Admin menu with a Billing page of credit usage Organization admins can see what credits were used, by date, user and module, filter by period, see the total and export it as CSV. Other members don't get the menu, and the engine refuses them the data anyway. It shows credits because the engine does not record LLM tokens. The ledger has no date filter, so a period is loaded page by page and cut to size here. It also can't tell Agent use from other features, so every charge is listed. Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 12 ++++- src/components/AppLayout.tsx | 11 ++-- src/express/billing.test.ts | 93 ++++++++++++++++++++++++++++++++ src/express/billing.ts | 93 ++++++++++++++++++++++++++++++++ src/i18n.ts | 56 ++++++++++++++++++++ src/pages/Billing.tsx | 100 +++++++++++++++++++++++++++++++++++ src/styles/app.css | 16 ++++++ 7 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 src/express/billing.test.ts create mode 100644 src/express/billing.ts create mode 100644 src/pages/Billing.tsx diff --git a/src/App.tsx b/src/App.tsx index a60ad72..4a861ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,21 @@ +import { useAuth } from '@clerk/react' +import type { ReactNode } from 'react' import { Navigate, Route, Routes } from 'react-router-dom' import { AuthGate } from './auth/AuthGate' -import { AppLayout } from './components/AppLayout' +import { AppLayout, ORG_ADMIN } from './components/AppLayout' +import { BillingPage } from './pages/Billing' import { BrainPage } from './pages/Brain' import { ProductionPage } from './pages/Production' import { InstructionPage } from './pages/Instruction' import { SchedulePage } from './pages/Schedule' import { ResultsPage } from './pages/Results' +// For a member who opens the link directly; the engine refuses them the data either way. +function AdminOnly({ children }: { children: ReactNode }) { + const { orgRole } = useAuth() + return orgRole === ORG_ADMIN ? children : +} + export default function App() { return ( @@ -17,6 +26,7 @@ export default function App() { } /> } /> } /> + } /> {/* Paths from before the routes were renamed, so old links keep working. */} } /> diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 63e02a6..59fc3f9 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react' -import { useClerk, useOrganization, useOrganizationList, useUser } from '@clerk/react' +import { useAuth, useClerk, useOrganization, useOrganizationList, useUser } from '@clerk/react' import { useQuery } from '@tanstack/react-query' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n' @@ -11,9 +11,12 @@ import { ErrorBoundary } from './ErrorBoundary' // Learning Studio (learning-lab/frontend); both apps share one Clerk instance, so the user stays signed in. const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL +/** The Clerk role the engine treats as the organization's owner (`require_org_owner`). */ +export const ORG_ADMIN = 'org:admin' + // The Production dashboard shows what Express is making; the four settings doors run it: // feed the Brain, give an instruction, watch the schedule, collect results. -export const NAV_GROUPS: { label: Key; items: { to: string; label: Key; icon: IconName }[] }[] = [ +export const NAV_GROUPS: { label: Key; adminOnly?: boolean; items: { to: string; label: Key; icon: IconName }[] }[] = [ { label: 'nav.dashboard', items: [{ to: '/production', label: 'nav.production', icon: 'chart' }] }, { label: 'nav.settings', @@ -24,6 +27,7 @@ export const NAV_GROUPS: { label: Key; items: { to: string; label: Key; icon: Ic { to: '/results', label: 'nav.results', icon: 'layers' }, ], }, + { label: 'nav.admin', adminOnly: true, items: [{ to: '/admin/billing', label: 'nav.billing', icon: 'coin' }] }, ] export const NAV = NAV_GROUPS.flatMap(g => g.items) @@ -150,6 +154,7 @@ export function AppLayout() { const [help, setHelp] = useState(false) const { user } = useUser() const { signOut } = useClerk() + const { orgRole } = useAuth() const { pathname } = useLocation() const current = NAV.find(n => pathname.startsWith(n.to)) // Nav groups stay closed unless they hold the current page; a click on the label overrides that until the next route. @@ -178,7 +183,7 @@ export function AppLayout() {