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-typesSign 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
/authenticateand stores the 7-day JWT in the httpOnlyadmin_tokencookie. 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.tsonly checks that the cookie exists. Every page, layout and server action callsrequireAdmin(), which asksGET /meand checksrole === "admin". The API enforces the same withauthenticated+requireAdminon every/admin/*route. - Analytics hygiene: requests carry
x-client-platform: admin, so admin traffic never counts as product usage.
Features
| Page | API | Notes |
|---|---|---|
| Overview | GET /admin/analytics/{overview,timeseries,retention,funnel,breakdown} | DAU/WAU/MAU, stickiness, onboarding, weekly retention, activation funnel, platform/app version |
| Users | GET /admin/users, GET /admin/users/:id | Search by name, @username, email or id |
| User actions | POST …/suspend, …/unsuspend, …/revoke-sessions, PATCH …/role, DELETE /admin/users/:id | Can't act on yourself; the last active admin can't be removed |
| Audit log | GET /admin/audit-log | Every admin action, newest first |
| Reports | GET /admin/reports, POST /admin/reports/:id/resolve | Dismiss, remove content, or suspend the user |
| User coins | GET /admin/users/:id/coins, POST …/coins/{adjust,write-off,freeze,unfreeze}, POST …/revoke-playpal, POST /admin/tips/:id/reverse | Card on the user page. Every action needs a reason; see Monetization |
| Fraud flags | GET /admin/fraud-flags, POST /admin/fraud-flags/:id/resolve | Open / cleared / actioned tabs |
| Reconciliation | GET/POST /admin/coins/reconciliation | Recent runs and their checks; run the ledger checks now |
| Sessions | GET /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 earnings | GET /admin/users/:id/earnings | USD earnings card on a PlayPal's user page (the Studio's view), with a link to their sessions |
| Waitlist | GET /admin/waitlist?page&pageSize&q&role, GET /admin/waitlist/export?q&role | Marketing-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 |
| Jobs | GET /admin/jobs, POST /admin/jobs/:queue/:job/run | Every 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 packages | GET/POST /admin/coin-packages, PATCH/DELETE /admin/coin-packages/:productId | The 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 tiers | GET /admin/price-tiers?kind=VIP_PASS|SESSION|EDATE, POST /admin/price-tiers, PATCH/DELETE /admin/price-tiers/:productId | The 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.statusisactive | suspended | deleted. Login refuses anything butactive(403), and theauthenticatedmiddleware rejects it (401). Suspend, delete and "sign out everywhere" all bumptokenVersion, 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 theauthenticatedmiddleware (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 sendsx-client-platformandx-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 ofmetadata: it outlives anonymization.- Removing a post sets
status = "removed", which hides it from every read that filters onready. Trending has no moderation gate yet (see Moderation).
Deploying
Production runs at admin.playpalz.gg:
| Piece | Location |
|---|---|
Deployment + Service (playpalz-admin:3000, 2 replicas), release playpalz-admin | infra/helm/values/production/admin.yaml |
Ingress (playpalz-admin-ingress, own TLS cert), release playpalz-platform | infra/helm/values/production/platform.yaml |
| DNS A record | infra/terraform/dns.tf (digitalocean_record.admin) |
| Image build | scripts/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/deployingAPI_URLis the in-cluster Servicehttp://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 inapps/admin/next.config.ts, because ingress-nginx disables snippet annotations. - Recommended: uncomment
whitelist-source-rangeon the admin ingress to restrict the panel to office/VPN IPs.
