Skip to content

admin — Admin Panel ​

@playpals/admin · port 3001 · apps/admin

Next.js 16 (App Router, React 19.2, Tailwind 4, shadcn/ui) internal tool for analytics, user management and moderation. Ported from the react-native-api-base boilerplate and restyled with the PlayPalz design system (Chakra Petch / Outfit, violet + cyan on the "void" dark base).

Running it ​

bash
cp apps/admin/.env.example apps/admin/.env.local   # API_URL=http://localhost:4000/api/v1
pnpm admin                                         # turbo dev --filter=@playpals/admin
pnpm --filter @playpals/admin test                 # jest
pnpm --filter @playpals/admin check-types

Sign in with any account whose User.role is admin. The seed creates playpalz_admin@playpalz.gg. To promote an existing account:

sql
UPDATE "User" SET role = 'admin' WHERE email = 'you@example.com';

After that, admins can grant and revoke admin access from the panel itself.

How it talks to the API ​

The browser never calls the API. Every page is a server component and every mutation is a server action. Both call API_URL from the Next.js server through apiFetch (src/lib/api.ts).

  • Session: login posts to /authenticate and stores the 7-day JWT in the httpOnly admin_token cookie. There are no refresh tokens. A 401 from the API (expired, revoked, or the admin was suspended) redirects to /login?reason=expired.
  • Authorization: src/proxy.ts only checks that the cookie exists. Every page, layout and server action calls requireAdmin(), which asks GET /me and checks role === "admin". The API enforces the same with authenticated + requireAdmin on every /admin/* route.
  • Analytics hygiene: requests carry x-client-platform: admin, so admin traffic never counts as product usage.

Features ​

PageAPINotes
OverviewGET /admin/analytics/{overview,timeseries,retention,funnel,breakdown}DAU/WAU/MAU, stickiness, onboarding, weekly retention, activation funnel, platform/app version
UsersGET /admin/users, GET /admin/users/:idSearch by name, @username, email or id
User actionsPOST …/suspend, …/unsuspend, …/revoke-sessions, PATCH …/role, DELETE /admin/users/:idCan't act on yourself; the last active admin can't be removed
Audit logGET /admin/audit-logEvery admin action, newest first
ReportsGET /admin/reports, POST /admin/reports/:id/resolveDismiss, remove content, or suspend the user
User coinsGET /admin/users/:id/coins, POST …/coins/{adjust,write-off,freeze,unfreeze}, POST …/revoke-playpal, POST /admin/tips/:id/reverseCard on the user page. Every action needs a reason; see Monetization
Fraud flagsGET /admin/fraud-flags, POST /admin/fraud-flags/:id/resolveOpen / cleared / actioned tabs
ReconciliationGET/POST /admin/coins/reconciliationRecent runs and their checks; run the ledger checks now
SessionsGET /admin/sessions?filter=upcoming|live|past|all&q&kind&minors&userId&cursor, GET /admin/sessions/:id, POST /admin/sessions/:id/cancel {reason}Search by @username, email or session id; "Under-18 bookers" filter. The detail page shows both people (with whether each was under 18 at the start), payment, the PlayPal's earning, credits and the SessionEvent audit trail. Cancel (booked or live) closes the room, credits the booker, reverses the earning, notifies both and writes session.cancelled; it isn't a strike against the PlayPal
Creator earningsGET /admin/users/:id/earningsUSD earnings card on a PlayPal's user page (the Studio's view), with a link to their sessions
WaitlistGET /admin/waitlist?page&pageSize&q&role, GET /admin/waitlist/export?q&roleMarketing-site signups with per-role counts; search by email, source or campaign. The export is CSV (formula cells neutralised), streamed through the admin's /waitlist/export route handler, and writes waitlist.exported
JobsGET /admin/jobs, POST /admin/jobs/:queue/:job/runEvery API background job: health (healthy, failing, not running, no runs yet), schedule, last success, next run, recent runs with duration and result, and failed attempts still in the queue. "Run now" exists for the trending builds and the session sweep, and writes jobs.run_now. See API → Background jobs
Coin packagesGET/POST /admin/coin-packages, PATCH/DELETE /admin/coin-packages/:productIdThe CoinPackage catalogue behind Buy Tokens. Create (starts disabled), edit coins, bonus, label, badge and order, and enable or disable with PATCH {offered}. The product ID can't change. A package that's been bought can't be deleted (409 PRODUCT_IN_USE): the webhook credits purchases only for products that still have a row. Writes catalog.coin_package_{created,updated,deleted} with a field-by-field diff
Price tiersGET /admin/price-tiers?kind=VIP_PASS|SESSION|EDATE, POST /admin/price-tiers, PATCH/DELETE /admin/price-tiers/:productIdThe PriceTier catalogue, one tab per kind, with usage counts (store purchases, checkouts, credits, sessions, passes, CreatorOfferings, and PlayPals whose monthlyPrice matches a VIP tier). VIP passes need durationDays; sessions and e-dates have none. Once a tier is used, its price is locked (409 TIER_IN_USE) and it can't be deleted; disable it and create a new tier instead. Coin packages and tiers share one product ID space (409 PRODUCT_EXISTS). Writes catalog.price_tier_{created,updated,deleted}
Creator verifications/admin/creator-verifications/*ID images are streamed through a route handler and never cached; every view is logged

Backend pieces it relies on ​

  • User.status is active | suspended | deleted. Login refuses anything but active (403), and the authenticated middleware rejects it (401). Suspend, delete and "sign out everywhere" all bump tokenVersion, which invalidates every JWT the user holds.
  • Delete anonymizes the account rather than removing the row. Email becomes deleted+<id>@deleted.invalid, username/profile/password are cleared, and push tokens are deleted. Posts, ledger entries and the audit trail stay.
  • UserActivityDay: one row per user per UTC day, upserted fire-and-forget by the authenticated middleware (services/activity.ts). It is deduplicated in memory, so it costs at most one write per user per day per API pod. The mobile app sends x-client-platform and x-app-version (apps/mobile/lib/axios.ts) for the platform breakdown.
  • AdminAuditLog: written in the same transaction as each action, including creator approvals/rejections. Keep personal data out of metadata: it outlives anonymization.
  • Removing a post sets status = "removed", which hides it from every read that filters on ready. Trending has no moderation gate yet (see Moderation).

Deploying ​

Production runs at admin.playpalz.gg:

PieceLocation
Deployment + Service (playpalz-admin:3000, 2 replicas), release playpalz-admininfra/helm/values/production/admin.yaml
Ingress (playpalz-admin-ingress, own TLS cert), release playpalz-platforminfra/helm/values/production/platform.yaml
DNS A recordinfra/terraform/dns.tf (digitalocean_record.admin)
Image buildscripts/build-admin.sh (also in build-all.sh)
bash
./scripts/build-admin.sh                       # builds + pushes …/admin:<commit>
infra/helm/deploy.sh admin <commit>            # see /operations/deploying
  • API_URL is the in-cluster Service http://playpalz-api:4000/api/v1, not the public host. The pod needs no secrets: admins sign in with their own accounts.
  • Probes hit /login, which renders without a session or the API, so an API outage doesn't restart the admin pods.
  • The ingress limits each client IP to 20 req/s and caps bodies at 1 MB. Security headers (X-Frame-Options: DENY, noindex, HSTS) are set in apps/admin/next.config.ts, because ingress-nginx disables snippet annotations.
  • Recommended: uncomment whitelist-source-range on the admin ingress to restrict the panel to office/VPN IPs.

Internal documentation — PlayPalz platform