Building the Admin Dashboard
Building the Admin Dashboard
There's a moment in every early-access app where you realize you need to see what's happening. Who has signed up. What stones have been activated. Which teams exist. Whether anyone is actually using the thing you built.
Stone Maps has an admin dashboard. It lives at /admin. It required a real security model, and building it was one of those tasks that felt like infrastructure but turned out to inform product decisions in ways I didn't expect.
The Access Model
Admin access is not just a flag on the users table. It's a separate table:
// packages/db/src/schema/admin.ts
export const adminUsers = pgTable('admin_users', {
userId: uuid('user_id').references(() => users.id).notNull(),
role: text('role').notNull(), // 'super_admin' | 'admin' | 'moderator'
...
});
The separation matters. A regular user becoming an admin doesn't modify the users row at all — it creates a new row in admin_users. If you remove that row, the person becomes a regular user again without any change to their account. Clean separation.
The three admin roles have different permission sets:
const permissions = {
super_admin: [
'manage_admins', 'manage_users', 'manage_stones',
'manage_teams', 'view_analytics', 'moderate_content', 'generate_qr_codes',
],
admin: ['manage_users', 'manage_stones', 'view_analytics', 'moderate_content'],
moderator: ['moderate_content', 'view_analytics'],
};
The hasAdminPermission(role, action) function is the enforcement mechanism. Each admin route checks it before doing anything sensitive. A moderator who tries to hit a stone management API will get a 403.
Granting admin access is a CLI command:
npm run db:grant-admin -- <email> <role>
There's no UI for this. That's deliberate — managing who has admin access should require server access, not just a browser session.
The 2FA Gate
Any admin session — not just login — can be challenged for 2FA. The implementation uses TOTP (time-based one-time passwords) via a dedicated admin secret, separate from any user-facing 2FA.
The session timeout is configurable: ADMIN_SESSION_TIMEOUT defaults to 30 minutes. After that, any admin action re-challenges for 2FA even if you're still logged in as a regular user.
This separation (admin 2FA independent of user auth) means we could have a super_admin who is also a regular user of the app, with their Stone Maps journal active, but still requires a separate re-authentication challenge to take admin actions.
What the Dashboard Shows
The admin homepage is a grid of sections:
People & Teams — the most-used panel. Lists all users with their account status (active, suspended, deleted), join date, email, and associated teams. You can see at a glance who has a self-pair vs. who registered but didn't complete onboarding.
Stones — inventory of all stones and pebbles. Which are activated, which are unactivated (never been scanned), which are premapped. Individual stone pages show the genesis traits, stone traits, and conversation history metadata.
Campaigns — team campaigns are scoped journaling events. Campaigns go through states: staged → active → closed. The admin view shows all campaigns across all teams.
Orders — Stone Maps has a Stripe integration for purchasing physical stones. The orders panel pulls fulfillment data. This is read-only; actual order management happens in Stripe's dashboard.
Analytics — basic platform metrics. Users over time, posts over time, activation rate (users who registered vs. users who completed genesis), conversation volume.
Generate QR — a super_admin-only tool that creates new stone QR codes in bulk. You specify how many, what kind (stone or pebble), and optionally a premapped location ID. It writes to the stones table and generates a printable page of QR codes.
The Type Error That Broke Vercel
One thing worth documenting because it was subtle: TypeScript caught a bug in the admin users page that wasn't obvious in local development.
// This expression always returns null — TypeScript caught it
{(u.status === 'suspended' || u.status === 'deleted') && u.status !== 'active' && u.status !== 'deleted' ? null : null}
The u.status type is 'suspended' | 'deleted' in that branch (after the first condition narrows it), so comparing it to 'active' is always false and TypeScript correctly flags the comparison as unintentional. Dead code that does nothing, but Vercel runs tsc in CI and won't deploy if it errors.
The fix was just deleting the expression. It did nothing. But it took a Vercel build failure to surface it, which is exactly what TypeScript strict mode and CI are for.
What's Missing
Admin-initiated account suspension. Right now, admins can see that an account is suspended or deleted, but can't change that status themselves from the dashboard. That has to happen via a direct DB query. The API route exists (POST /api/users/me/account), but it's scoped to self-service. An admin version needs its own route with the right permission check.
Content moderation tooling. The moderate_content permission exists, but there's no UI yet for reviewing flagged posts or taking action on them. For 50 early-access users this hasn't been urgent. It will be before we open wider.
Audit log. Every admin action should be logged with who did it and when. The events table in the schema exists for this but isn't wired up yet. For a platform that handles people's private journals, this matters.