Admin Panel
How the admin panel works — managing users, subscriptions, content, and dashboard analytics.
Admin Panel
ScaleRocket includes a full admin panel (apps/ops) built with Vite + React. It gives you a private dashboard to manage users, monitor subscriptions, edit blog content, and view analytics.
How the Ops App Works
The admin panel is a separate Vite application that runs on port 5174. It shares packages with the main dashboard (packages/ui, packages/types, packages/config) but has its own routes and pages.
apps/ops/
├── src/
│ ├── routes/
│ │ ├── __root.tsx # Root layout with sidebar
│ │ ├── index.tsx # Dashboard stats
│ │ ├── users/
│ │ │ ├── index.tsx # Users list
│ │ │ └── $userId.tsx # User detail
│ │ ├── subscriptions/
│ │ │ └── index.tsx # Subscriptions list
│ │ └── blog/
│ │ ├── index.tsx # Blog posts list
│ │ └── editor.tsx # Blog post editor
│ ├── lib/
│ │ ├── supabase.ts # Supabase client
│ │ └── api.ts # API utilities
│ └── main.tsx
├── .env.example
└── package.jsonAdmin Whitelist
Access to the admin panel is restricted by email whitelist. Only emails in the whitelist can log in.
Configure the Whitelist
Set the admin emails in your environment:
# apps/ops/.env.local
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=eyJ...
VITE_ADMIN_EMAILS=you@yourdomain.com,cofounder@yourdomain.comThe root route checks the whitelist on load:
// apps/ops/src/routes/__root.tsx
const adminEmails = import.meta.env.VITE_ADMIN_EMAILS?.split(",") || [];
export const Route = createRootRoute({
beforeLoad: async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user || !adminEmails.includes(user.email)) {
throw redirect({ to: "/unauthorized" });
}
},
});Note: For extra security, you can also enforce admin access at the database level with RLS policies that check against an
adminstable.
Dashboard Stats
The admin dashboard home page shows key metrics at a glance:
- Total users — count of all registered users
- Active subscriptions — count by plan
- Monthly revenue — calculated from active subscriptions
- New signups — users registered in the last 7 days
- Credit usage — total credits consumed this month
// apps/ops/src/routes/index.tsx
import { supabase } from "../lib/supabase";
async function getDashboardStats() {
const [
{ count: totalUsers },
{ count: activeSubscriptions },
{ data: recentSignups },
] = await Promise.all([
supabase.from("profiles").select("*", { count: "exact", head: true }),
supabase
.from("subscriptions")
.select("*", { count: "exact", head: true })
.eq("status", "active"),
supabase
.from("profiles")
.select("id, email, created_at")
.gte("created_at", new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString())
.order("created_at", { ascending: false }),
]);
return { totalUsers, activeSubscriptions, recentSignups };
}Managing Users
The users page lists all registered users with search and pagination:
- View user details — email, signup date, plan, credit balance
- View subscription — current plan, billing period, next renewal
- View credits — current balance, monthly allowance, usage history
User Detail Page
Click on any user to see their full profile:
// apps/ops/src/routes/users/$userId.tsx
async function getUserDetail(userId: string) {
const [
{ data: profile },
{ data: subscription },
{ data: credits },
] = await Promise.all([
supabase.from("profiles").select("*").eq("id", userId).single(),
supabase.from("subscriptions").select("*").eq("user_id", userId).single(),
supabase.from("credits").select("*").eq("user_id", userId).single(),
]);
return { profile, subscription, credits };
}Note: The admin panel uses the
service_rolekey via Edge Functions for operations that need to bypass RLS (like viewing other users' data). Direct Supabase queries from the ops app use RLS policies scoped to admin users.
Managing Subscriptions
The subscriptions page shows all active, cancelled, and past-due subscriptions:
- Filter by status (active, cancelled, past_due, trialing)
- View subscription details (plan, amount, billing cycle)
- Link to the customer in Stripe Dashboard
// Fetch subscriptions with user info
const { data: subscriptions } = await supabase
.from("subscriptions")
.select(`
*,
profiles:user_id (email, full_name)
`)
.order("created_at", { ascending: false });Blog Editor
The admin panel includes a blog editor for publishing content to the marketing site:
List Blog Posts
const { data: posts } = await supabase
.from("blog_posts")
.select("id, title, slug, status, published_at, created_at")
.order("created_at", { ascending: false });Create/Edit a Post
The blog editor supports:
- Title and slug (auto-generated from title)
- Content in Markdown
- Status — draft or published
- Cover image upload (stored in Supabase Storage)
- SEO — meta title and meta description
// Save a blog post
async function savePost(post: BlogPost) {
const { data, error } = await supabase
.from("blog_posts")
.upsert({
id: post.id,
title: post.title,
slug: post.slug,
content: post.content,
status: post.status,
cover_image: post.coverImage,
meta_title: post.metaTitle,
meta_description: post.metaDescription,
published_at: post.status === "published" ? new Date().toISOString() : null,
})
.select()
.single();
if (error) throw error;
return data;
}Running the Admin Panel
Start all apps from the root:
pnpm devOr start only the admin panel:
pnpm --filter ops devVisit http://localhost:5174 and log in with an admin-whitelisted email.
Security Considerations
- The admin whitelist is a first layer of defense, not the only one
- Use RLS policies with an
adminstable for database-level protection - Never expose
service_rolekeys to the client - Consider adding audit logging for admin actions
- In production, restrict the admin panel to a separate subdomain (e.g.,
admin.yourdomain.com)
Next Steps
- Deploy all apps — including the admin panel
- Set up the credits system — manage from the admin panel
- Configure Stripe — subscriptions shown in admin
Done reading? Mark this page as complete.