ScaleRocket/Web

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.json

Admin 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.com

The 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 admins table.

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_role key 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 dev

Or start only the admin panel:

pnpm --filter ops dev

Visit 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 admins table for database-level protection
  • Never expose service_role keys 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

Done reading? Mark this page as complete.

On this page