Skip to content

Commit 9d6609a

Browse files
Merge pull request #45 from InstaNode-dev/feat/admin-customers-ui-fresh
AdminCustomersPage: founder view of all teams + tier/promo actions
2 parents f05493e + 4a13d37 commit 9d6609a

9 files changed

Lines changed: 2399 additions & 4 deletions

File tree

src/App.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ const SettingsPage = lazy(() =>
9696
const ContractsPage = lazy(() =>
9797
import('./pages/ContractsPage').then((m) => ({ default: m.ContractsPage })),
9898
)
99+
// AdminCustomersPage — founder console at /app/admin/customers. The page
100+
// itself reads ctx.me.is_platform_admin and renders a Navigate when the
101+
// caller isn't an admin, so non-admins never see the route exists. We
102+
// still lazy-load it so the bytes don't ship for regular users.
103+
const AdminCustomersPage = lazy(() =>
104+
import('./pages/AdminCustomersPage').then((m) => ({ default: m.AdminCustomersPage })),
105+
)
99106

100107
import { getToken } from './api'
101108

@@ -196,6 +203,9 @@ export function AppRoutes() {
196203
<Route path="billing" element={<BillingPage />} />
197204
<Route path="settings" element={<SettingsPage />} />
198205
<Route path="contracts" element={<ContractsPage />} />
206+
{/* Admin-only — the page itself renders <Navigate to="/" /> for
207+
non-admin users so the route 404s instead of 403s. */}
208+
<Route path="admin/customers" element={<AdminCustomersPage />} />
199209
</Route>
200210

201211
{/* Back-compat: every legacy unprefixed path that used to be a

src/api/index.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
import type {
1111
Resource, DashboardStack, DashboardDeployment, DeploymentStatus,
1212
DashboardTeam, BillingDetails, Invoice,
13-
TeamMember, TeamInvitation, AuthMeResponse, VaultEntry, ActivityItem
13+
TeamMember, TeamInvitation, AuthMeResponse, VaultEntry, ActivityItem,
14+
AdminCustomerListResponse, AdminCustomerDetailResponse,
15+
AdminIssuePromoInput, AdminIssuePromoResponse,
16+
AdminSetTierInput, AdminSetTierResponse,
1417
} from './types'
1518

1619
export * from './types'
@@ -159,6 +162,10 @@ export async function fetchMe(): Promise<AuthMeResponse> {
159162
* field entirely — callers must treat undefined as "no
160163
* experiment, render control variant". */
161164
experiments?: Record<string, string>
165+
/** Track A — server-authoritative platform-admin flag. The
166+
* dashboard surfaces the `/app/admin/customers` console only when
167+
* this is `true`. Absent on older API builds → treat as `false`. */
168+
is_platform_admin?: boolean
162169
}
163170
// No try/catch — errors propagate. The previous fixture fallback masked
164171
// backend outages by serving the `aanya@acme.dev` mock identity, which
@@ -190,6 +197,7 @@ export async function fetchMe(): Promise<AuthMeResponse> {
190197
created_at: '',
191198
},
192199
experiments: me.experiments,
200+
is_platform_admin: me.is_platform_admin === true,
193201
}
194202
}
195203

@@ -1219,3 +1227,98 @@ export type QuotaWallResponse = {
12191227
export async function fetchQuotaWall(): Promise<QuotaWallResponse> {
12201228
return call<QuotaWallResponse>('/api/v1/usage/wall')
12211229
}
1230+
1231+
// ─── Admin Customers (Track A — founder console) ────────────────────────
1232+
//
1233+
// Four endpoints back the /app/admin/customers page:
1234+
// listAdminCustomers — GET /api/v1/admin/customers
1235+
// getAdminCustomer — GET /api/v1/admin/customers/:team_id
1236+
// setAdminCustomerTier — POST /api/v1/admin/customers/:team_id/tier
1237+
// issueAdminCustomerPromo — POST /api/v1/admin/customers/:team_id/promo
1238+
//
1239+
// Track A returns 403 with `agent_action` for non-admin callers; the
1240+
// dashboard's route guard turns the page into a 404 for those users so
1241+
// the route's existence isn't leaked. Other errors propagate so the
1242+
// page renders a real banner instead of silently failing.
1243+
1244+
/** Filter / sort options accepted by GET /api/v1/admin/customers. The
1245+
* query string is built up only for the fields the caller actually sets
1246+
* so older API builds that don't yet honour a flag don't get tripped on
1247+
* an empty string. */
1248+
export interface ListAdminCustomersInput {
1249+
/** Free-text search (email, name, team_id substring). */
1250+
q?: string
1251+
/** Filter pill — undefined or 'all' returns every tier. */
1252+
tier?: 'all' | 'anonymous' | 'free' | 'hobby' | 'pro' | 'team' | 'growth'
1253+
/** Track A sort keys: mrr | last_active | created_at | storage | deployments. */
1254+
sort_by?: string
1255+
/** Page size — Track A clamps to 200; default 50. */
1256+
limit?: number
1257+
offset?: number
1258+
}
1259+
1260+
export async function listAdminCustomers(
1261+
input: ListAdminCustomersInput = {},
1262+
): Promise<AdminCustomerListResponse> {
1263+
const params = new URLSearchParams()
1264+
if (input.q && input.q.trim()) params.set('q', input.q.trim())
1265+
if (input.tier && input.tier !== 'all') params.set('tier', input.tier)
1266+
if (input.sort_by) params.set('sort_by', input.sort_by)
1267+
if (input.limit !== undefined) params.set('limit', String(input.limit))
1268+
if (input.offset !== undefined) params.set('offset', String(input.offset))
1269+
const qs = params.toString()
1270+
const path = qs ? `/api/v1/admin/customers?${qs}` : '/api/v1/admin/customers'
1271+
const r = await call<{
1272+
ok: boolean
1273+
customers?: AdminCustomerListResponse['customers']
1274+
total?: number
1275+
}>(path)
1276+
return { ok: true, customers: r.customers ?? [], total: r.total ?? 0 }
1277+
}
1278+
1279+
export async function getAdminCustomer(
1280+
teamID: string,
1281+
): Promise<AdminCustomerDetailResponse> {
1282+
const r = await call<{
1283+
ok: boolean
1284+
team?: AdminCustomerDetailResponse['team']
1285+
users?: AdminCustomerDetailResponse['users']
1286+
resources?: AdminCustomerDetailResponse['resources']
1287+
audit_log?: AdminCustomerDetailResponse['audit_log']
1288+
deploys?: AdminCustomerDetailResponse['deploys']
1289+
subscription?: AdminCustomerDetailResponse['subscription']
1290+
promos?: AdminCustomerDetailResponse['promos']
1291+
}>(`/api/v1/admin/customers/${encodeURIComponent(teamID)}`)
1292+
return {
1293+
ok: true,
1294+
team: r.team ?? ({ id: teamID } as AdminCustomerDetailResponse['team']),
1295+
users: r.users ?? [],
1296+
resources: r.resources ?? [],
1297+
audit_log: r.audit_log ?? [],
1298+
deploys: r.deploys ?? [],
1299+
subscription: r.subscription ?? null,
1300+
promos: r.promos ?? [],
1301+
}
1302+
}
1303+
1304+
export async function setAdminCustomerTier(
1305+
teamID: string,
1306+
input: AdminSetTierInput,
1307+
): Promise<AdminSetTierResponse> {
1308+
const r = await call<{ ok: boolean; team: DashboardTeam }>(
1309+
`/api/v1/admin/customers/${encodeURIComponent(teamID)}/tier`,
1310+
{ method: 'POST', body: JSON.stringify(input) },
1311+
)
1312+
return { ok: true, team: r.team }
1313+
}
1314+
1315+
export async function issueAdminCustomerPromo(
1316+
teamID: string,
1317+
input: AdminIssuePromoInput,
1318+
): Promise<AdminIssuePromoResponse> {
1319+
const r = await call<{ ok: boolean; code: string; expires_at: string | null }>(
1320+
`/api/v1/admin/customers/${encodeURIComponent(teamID)}/promo`,
1321+
{ method: 'POST', body: JSON.stringify(input) },
1322+
)
1323+
return { ok: true, code: r.code, expires_at: r.expires_at ?? null }
1324+
}

src/api/types.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,4 +254,106 @@ export interface AuthMeResponse {
254254
team: DashboardTeam
255255
access_token?: string
256256
experiments?: Record<string, string>
257+
/** Track A platform-admin flag — when true, the dashboard renders the
258+
* `/app/admin/customers` route + sidebar link. Older API builds omit
259+
* the field entirely, in which case the dashboard treats the caller
260+
* as a regular user (404 on the admin route, link hidden). The flag
261+
* is server-authoritative; the dashboard never elevates by itself. */
262+
is_platform_admin?: boolean
263+
}
264+
265+
// ---------- Admin customers (Track A — founder console) ----------
266+
//
267+
// Shape of GET /api/v1/admin/customers responses. Track A is shipping
268+
// the backend in parallel; we mirror the contract here so the dashboard
269+
// compiles independently. Fields the agent API hasn't pinned yet are
270+
// marked optional so the adapter can degrade gracefully.
271+
272+
export interface AdminCustomerSummary {
273+
team_id: string
274+
primary_email: string
275+
/** Display name — agents that never set one fall back to the email
276+
* local part on the server side. May be empty for very old teams. */
277+
name?: string
278+
tier: Tier
279+
/** Monthly MRR in INR paise (×100). Track A returns 0 for unpaid teams. */
280+
mrr_monthly: number
281+
/** Yearly MRR in INR paise — 0 when the team isn't on a yearly plan. */
282+
mrr_yearly: number
283+
/** Aggregate storage across every resource owned by the team. */
284+
storage_bytes: number
285+
/** Count of running deployments. */
286+
deployments_active: number
287+
/** ISO-8601 — last authenticated request (auth, provision, dashboard). */
288+
last_active: string | null
289+
created_at: string
290+
}
291+
292+
export interface AdminCustomerListResponse {
293+
ok: true
294+
customers: AdminCustomerSummary[]
295+
total: number
296+
}
297+
298+
export interface AdminAuditEntry {
299+
id: string
300+
kind: string
301+
summary: string
302+
at: string
303+
actor?: string
304+
}
305+
306+
export interface AdminPromoEntry {
307+
id: string
308+
code: string
309+
kind: 'percent_off' | 'first_month_free' | 'amount_off'
310+
value: number
311+
applies_to: number
312+
valid_for_days: number
313+
expires_at: string | null
314+
created_at: string
315+
}
316+
317+
export interface AdminSubscriptionInfo {
318+
status?: string
319+
next_renewal_at?: string | null
320+
amount_inr?: number | null
321+
razorpay_subscription_id?: string | null
322+
}
323+
324+
export interface AdminCustomerDetailResponse {
325+
ok: true
326+
team: DashboardTeam & { primary_email?: string }
327+
users: User[]
328+
resources: Resource[]
329+
audit_log: AdminAuditEntry[]
330+
deploys: DashboardDeployment[]
331+
subscription: AdminSubscriptionInfo | null
332+
promos?: AdminPromoEntry[]
333+
}
334+
335+
export interface AdminIssuePromoInput {
336+
kind: 'percent_off' | 'first_month_free' | 'amount_off'
337+
/** Integer — 15 for 15% off; 49 for $49 off; ignored for first_month_free. */
338+
value: number
339+
/** Integer — 1 = first month only, 3 = first 3 months, 0 = ongoing. */
340+
applies_to: number
341+
/** Days the code is redeemable for. Default 30. */
342+
valid_for_days: number
343+
}
344+
345+
export interface AdminIssuePromoResponse {
346+
ok: true
347+
code: string
348+
expires_at: string | null
349+
}
350+
351+
export interface AdminSetTierInput {
352+
tier: Tier
353+
reason: string
354+
}
355+
356+
export interface AdminSetTierResponse {
357+
ok: true
358+
team: DashboardTeam
257359
}

0 commit comments

Comments
 (0)