|
10 | 10 | import type { |
11 | 11 | Resource, DashboardStack, DashboardDeployment, DeploymentStatus, |
12 | 12 | DashboardTeam, BillingDetails, Invoice, |
13 | | - TeamMember, TeamInvitation, AuthMeResponse, VaultEntry, ActivityItem |
| 13 | + TeamMember, TeamInvitation, AuthMeResponse, VaultEntry, ActivityItem, |
| 14 | + AdminCustomerListResponse, AdminCustomerDetailResponse, |
| 15 | + AdminIssuePromoInput, AdminIssuePromoResponse, |
| 16 | + AdminSetTierInput, AdminSetTierResponse, |
14 | 17 | } from './types' |
15 | 18 |
|
16 | 19 | export * from './types' |
@@ -159,6 +162,10 @@ export async function fetchMe(): Promise<AuthMeResponse> { |
159 | 162 | * field entirely — callers must treat undefined as "no |
160 | 163 | * experiment, render control variant". */ |
161 | 164 | 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 |
162 | 169 | } |
163 | 170 | // No try/catch — errors propagate. The previous fixture fallback masked |
164 | 171 | // backend outages by serving the `aanya@acme.dev` mock identity, which |
@@ -190,6 +197,7 @@ export async function fetchMe(): Promise<AuthMeResponse> { |
190 | 197 | created_at: '', |
191 | 198 | }, |
192 | 199 | experiments: me.experiments, |
| 200 | + is_platform_admin: me.is_platform_admin === true, |
193 | 201 | } |
194 | 202 | } |
195 | 203 |
|
@@ -1219,3 +1227,98 @@ export type QuotaWallResponse = { |
1219 | 1227 | export async function fetchQuotaWall(): Promise<QuotaWallResponse> { |
1220 | 1228 | return call<QuotaWallResponse>('/api/v1/usage/wall') |
1221 | 1229 | } |
| 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 | +} |
0 commit comments