Super-admin interface for operating the multi-tenant LMS SaaS. Completely separate from the per-school /dashboard/admin experience.
All routes live under app/[locale]/platform/:
/platform → Overview dashboard (MRR, tenant counts, plan distribution)
/platform/tenants → All schools: search, filter, actions
/platform/tenants/[id] → Tenant detail: stats, subscription, admin users, transactions
/platform/billing → Manual bank-transfer payment requests (confirm / reject)
/platform/plans → Edit platform_plans pricing and feature limits
/platform/referrals → Manage referral codes and track conversions
These routes are completely separate from /dashboard/admin — they use a dedicated layout (app/[locale]/platform/layout.tsx) and sidebar (components/platform-sidebar.tsx).
Super admins are stored in the super_admins table:
SELECT user_id FROM super_admins WHERE user_id = auth.uid();This is authoritative. isSuperAdmin() in lib/supabase/get-user-role.ts queries the DB directly — it does NOT trust JWT claims.
For any path starting with /platform, the middleware performs a raw REST call to check super_admins:
async function checkSuperAdmin(userId: string): Promise<boolean> {
const res = await fetch(
`${SUPABASE_URL}/rest/v1/super_admins?user_id=eq.${userId}&select=user_id&limit=1`,
{ headers: { apikey: SERVICE_KEY, Authorization: `Bearer ${SERVICE_KEY}` } }
)
const rows = await res.json()
return Array.isArray(rows) && rows.length > 0
}Non-super-admin users are redirected to /[locale]/auth/login.
INSERT INTO super_admins (user_id)
VALUES ('your-user-uuid-here');Never add super admin rows via user-facing UI — only via direct DB access or a protected admin CLI.
Platform pages use createAdminClient() (service role key, bypasses RLS) for all queries. This is intentional — super admins need cross-tenant visibility.
import { createAdminClient } from '@/lib/supabase/admin'
const adminClient = createAdminClient()Important gotcha: PostgREST FK embedding (table(related_col)) silently fails with the admin client for some FK relationships. Always fetch related records in a separate query using .in('id', ids).
PostgREST caps every response at the project's configured API "Max rows". supabase/config.toml sets max_rows = 1000 for the local stack; the cloud project has no pgrst.db_max_rows role override, so it runs on Supabase's hosted default, also 1000. A capped response is an ordinary 200 with fewer rows in it — there is no error, no flag, and nothing downstream can tell it apart from a complete result.
That makes it lethal for platform pages, which read whole cross-tenant relations and then do arithmetic over them. getPayoutsOwed() used to read transactions and payouts unbounded: past 1000 rows it would have underpaid or overpaid schools by however much fell off the end, confidently and silently.
So: any read whose rows are summed, counted, or iterated for side effects must use fetchAllRows() (lib/supabase/fetch-all-rows.ts) rather than a bare .select().
import { fetchAllRows } from '@/lib/supabase/fetch-all-rows'
const rows = await fetchAllRows('transactions', (from, to) =>
admin
.from('transactions')
.select('tenant_id, amount', { count: 'exact' }) // required — the completeness check compares against it
.eq('status', 'successful')
.order('transaction_id') // required — a unique key, or pages overlap/skip
.range(from, to)
)It pages until the relation is exhausted, shrinks its page size to whatever the server actually returns (so it is correct for any cap without knowing the number), and throws — naming the relation and both counts — if it ends up with fewer rows than the server said exist. A wrong total is worse than a failed page, so it fails.
Reads that are already bounded (.limit(), .single(), a tenant-scoped page query) don't need it, and neither do aggregations done in SQL: get_platform_stats() and get_platform_revenue() sum inside Postgres and return one row, which is immune by construction and remains the better option for a large relation.
File: app/[locale]/platform/page.tsx
Uses the get_platform_stats() SECURITY DEFINER RPC for a single-round-trip aggregation:
| Metric | Source |
|---|---|
| MRR | platform_subscriptions JOIN platform_plans |
| Total tenants | tenants WHERE status='active' |
| New tenants 30d | tenants WHERE created_at > now()-30d |
| Total students | tenant_users WHERE role='student' |
| Pending payments | platform_payment_requests WHERE status='pending' |
| Referral codes/redemptions | referral_codes, referral_redemptions |
testids: platform-overview, platform-metrics, metric-{kebab-title}, metric-value, plan-distribution
Files: app/[locale]/platform/tenants/page.tsx, tenants/[tenantId]/page.tsx
- List: filterable by name (ilike), plan, status. Shows student count, course count, plan badge.
- Detail: tenant stats, active subscription, admin users list, last 20 transactions.
- Actions (TenantActionsMenu): View Details · Impersonate User · Change Plan · Suspend/Reactivate
Server actions in app/actions/platform/plans.ts:
forceTenantPlanChange(tenantId, planSlug) // updates tenants.plan + revenue_splits
suspendTenant(tenantId, isSuspending) // sets tenants.status = 'suspended' | 'active'testids: platform-tenants-page, tenants-count, tenants-filter-form, tenants-search, tenants-plan-filter, tenants-status-filter, tenants-filter-submit, tenants-table, tenant-row[data-tenant-id], tenant-detail-page, tenant-stats
Files: app/[locale]/platform/tenants/impersonate-dialog.tsx, app/actions/platform/impersonate.ts
The impersonation flow uses Supabase's admin magic link API:
- Super admin opens Impersonate User dialog on a tenant.
- Dialog calls
getTenantUsersForImpersonation(tenantId)— returns active users with name, email, role. - Super admin clicks Sign in as →
impersonateUser(userId, tenantId)is called. - Server action calls
supabaseAdmin.auth.admin.generateLink({ type: 'magiclink', email }). - The
hashed_tokenfrom the response is assembled into a Supabase confirm URL. - An audit row is inserted into
impersonation_log(super_admin_id, target_user_id, tenant_id). - The browser is redirected to the magic link URL — the super admin is now signed in as the target user.
- To exit: call
supabase.auth.signOut()and navigate back to/platform/tenants.
Audit table:
SELECT * FROM impersonation_log ORDER BY created_at DESC;testids: impersonate-dialog, impersonate-user-list, impersonate-user-row[data-user-id][data-role], impersonate-signin-btn, impersonate-cancel-btn, impersonate-loading, impersonate-empty
Files: app/[locale]/platform/billing/page.tsx, billing/billing-actions.tsx
Shows all platform_payment_requests across all tenants. Tabs: Pending · Confirmed · Rejected · All.
The Confirm button calls the existing confirmManualPayment(requestId) action from app/actions/admin/billing.ts — this:
- Sets
platform_payment_requests.status = 'confirmed' - Updates
tenants.planto the requested plan - Updates
tenants.billing_statusandbilling_period_end
The Reject button calls rejectManualPayment(requestId, reason) in app/actions/platform/plans.ts.
testids: platform-billing-page, billing-tabs, billing-tab-{value}[data-active], billing-requests-table, billing-request-row[data-request-id][data-status], confirm-payment-btn, reject-payment-btn, reject-payment-dialog, reject-reason-input, confirm-reject-btn
Files: app/[locale]/platform/plans/page.tsx, plans/plan-editor.tsx
CRUD interface for platform_plans. Each plan card shows: name, slug, monthly/yearly price, transaction fee %, limits (JSONB), active status.
Edit: Opens a dialog to update name, prices, transaction fee %, sort order, and limits JSON. Deactivate: Blocked if the plan has active subscribers (returns an error toast).
Server actions in app/actions/platform/plans.ts:
updatePlatformPlan(planId, updates)togglePlanActive(planId, isActive)— checks active subscriber count before deactivating
testids: platform-plans-page, plan-card[data-plan-slug], plan-edit-btn, plan-toggle-btn, plan-edit-dialog, plan-price-monthly-input, plan-save-btn
Files: app/[locale]/platform/referrals/page.tsx, referrals/generate-code-form.tsx, app/actions/platform/referrals.ts
referral_codes (
code_id uuid PK,
code text UNIQUE, -- e.g. "ACME25"
tenant_id uuid → tenants, -- owner school (NULL = platform-level)
discount_months int, -- free months for new school (referee)
referrer_reward_months int,-- free months for referring school
max_uses int, -- NULL = unlimited
used_count int,
is_active bool,
created_at timestamptz
)
referral_redemptions (
redemption_id uuid PK,
code_id uuid → referral_codes,
redeemed_by_tenant_id uuid → tenants,
referrer_rewarded bool, -- set true after first payment
referee_rewarded bool,
created_at timestamptz
)| Event | Action |
|---|---|
New school signs up with ?ref=CODE |
Insert referral_redemptions row; extend referee's billing_period_end by discount_months |
| New school makes first payment | Call rewardReferrer(); extend referrer's billing_period_end by referrer_reward_months; set referrer_rewarded=true |
Referrer reward is delayed until first payment to prevent abuse.
app/[locale]/create-school/page.tsx— unified create-school flow with cross-subdomain authentication; reads?ref=CODEfrom query paramsapp/actions/onboarding.ts— callsapplyReferralCode(code, newTenantId)after tenant creation; also handles the onboarding wizard that guides new school admins through initial setupapp/api/stripe/platform-webhook/route.ts— callsrewardReferrer()oninvoice.paid- School settings page — "Get your referral link" card (planned)
testids: platform-referrals-page, generate-code-form, referral-code-input, generate-code-submit, referral-codes-table, referral-code-row[data-code]
tests/playwright/platform-panel.spec.ts
| Account | Role | Notes |
|---|---|---|
owner@e2etest.com / password123 |
Super Admin | Has row in super_admins table |
student@e2etest.com / password123 |
Student | Should be blocked from /platform |
creator@codeacademy.com / password123 |
School Admin | Should be blocked from /platform |
| Group | Tests | Priority |
|---|---|---|
| Platform Security Guard | Unauth redirect, student blocked, admin blocked, super admin allowed | P0 |
| Platform Overview | Metrics render, MRR card, plan distribution, sidebar links | P0 |
| Platform Tenants | Table loads, search filter, plan filter, link to detail | P1 |
| Tenant Detail | Stats cards, heading, link from list | P1 |
| Platform Billing | Tabs, table, confirm/reject buttons visible | P1 |
| Platform Plans | Plan cards, Edit dialog opens, price save, toggle button | P1 |
| Platform Referrals | Generate form, code creation, code appears in table | P1 |
| Impersonation Dialog | Dialog opens, user list renders, Sign in as button | P2 |
base-ui's DropdownMenu and Select components do not open in Playwright headless mode due to pointer-capture differences. Tests that require those (tenant actions dropdown, select dropdowns) either:
- Use direct URL navigation to bypass the dropdown
- Use
page.evaluate()to click via DOM (fragile — fallback pattern) - Are annotated with a skip-reason and should be verified in headed mode (
npx playwright test --headed)
Run headed: npx playwright test tests/playwright/platform-panel.spec.ts --headed
| File | Purpose |
|---|---|
app/[locale]/platform/layout.tsx |
Platform layout — verifies super admin, fetches pending count |
app/[locale]/platform/page.tsx |
Overview dashboard |
app/[locale]/platform/tenants/page.tsx |
Tenant list with filter |
app/[locale]/platform/tenants/[tenantId]/page.tsx |
Tenant detail |
app/[locale]/platform/tenants/tenant-actions-menu.tsx |
Dropdown actions per tenant |
app/[locale]/platform/tenants/impersonate-dialog.tsx |
Impersonation user picker |
app/[locale]/platform/billing/page.tsx |
Payment requests UI |
app/[locale]/platform/billing/billing-actions.tsx |
Confirm/reject buttons |
app/[locale]/platform/plans/page.tsx |
Plans list |
app/[locale]/platform/plans/plan-editor.tsx |
Edit dialog + toggle active |
app/[locale]/platform/referrals/page.tsx |
Referral dashboard |
app/[locale]/platform/referrals/generate-code-form.tsx |
Code generation form |
components/platform-sidebar.tsx |
Platform nav sidebar |
app/actions/platform/impersonate.ts |
Magic link impersonation + audit log |
app/actions/platform/plans.ts |
Plan CRUD, tenant suspend, payment reject |
app/actions/platform/referrals.ts |
Referral code generation and redemption |
lib/supabase/get-user-role.ts |
isSuperAdmin() — queries super_admins table |
proxy.ts |
Middleware guard for /platform/* |