Back to blog

Build a URL Shortener: Admin Panel — RBAC & User Management

typescriptnodejsadminrbacbackend
Build a URL Shortener: Admin Panel — RBAC & User Management

Our URL shortener has user accounts, authentication, and a working frontend. Users can register, shorten URLs, and track their click analytics. Everything works — but everyone is equal. There's no way to distinguish a regular user from an administrator.

What happens when someone creates thousands of spam links? Or when a user reports an abusive redirect? Right now, nobody has the authority to do anything about it. There's no moderation, no user management, no system-wide visibility. The app is a democracy with no government.

Time to change that. In this post, we'll add a proper role-based access control (RBAC) system, build admin middleware for protecting privileged routes, and implement a full user management API — including account suspension, role promotion, and search capabilities.

Time commitment: 2–3 hours
Prerequisites: Phase 9: Deployment & Production

What we'll build in this post:
✅ Role-based access control (RBAC) with admin and user roles
✅ Admin middleware for protecting admin-only routes
✅ User management API (list all users, view details, search)
✅ Account suspension and reactivation
✅ Admin promotion and demotion
✅ Admin activity logging foundation


Why RBAC?

You might be tempted to just add an isAdmin: boolean field to your User model and call it a day. It works for tiny apps, but it breaks down quickly.

What if you need a "moderator" who can review links but not manage users? What if you want a "super admin" who can promote other admins? A boolean gives you exactly two states — admin or not admin. That's not enough.

Here's how the common approaches compare:

ApproachFlexibilityComplexityBest For
Boolean flag (isAdmin)Low — only 2 statesVery lowToy projects
Enum role (USER, ADMIN, SUPER_ADMIN)Medium — multiple tiersLowMost applications
Permission-based (read_users, write_urls, etc.)Very high — granular controlHighEnterprise systems

We'll use the enum role approach — it's the sweet spot for our URL shortener. Three roles give us clear separation:

  • USER — regular users who can manage their own URLs
  • ADMIN — moderators who can manage users and review URLs
  • SUPER_ADMIN — full system access, can promote/demote admins

This scales well without the complexity of a full permission matrix.


RBAC Architecture

Our RBAC system builds on the existing JWT authentication from Phase 6. Every request passes through two layers of middleware before reaching the route handler:

The key insight: auth middleware verifies identity (who are you?), while role middleware verifies authorization (what can you do?). Keeping them separate means regular user routes only need auth, while admin routes stack both.

Here's the hierarchy of access levels:


Prisma Schema Updates

First, let's update our database schema. We need to add a Role enum and suspension-related fields to the User model.

Add the Role Enum

// prisma/schema.prisma
 
enum Role {
  USER
  ADMIN
  SUPER_ADMIN
}
 
model User {
  id            String    @id @default(uuid())
  email         String    @unique
  passwordHash  String
  name          String?
  role          Role      @default(USER)
  apiKey        String?   @unique
  suspendedAt   DateTime?
  suspendedBy   String?
  suspendReason String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
  urls          Url[]
  refreshTokens RefreshToken[]
 
  @@map("users")
}

The key additions:

  • role — defaults to USER, so existing accounts aren't affected
  • suspendedAt — null means active, a timestamp means suspended
  • suspendedBy — tracks which admin performed the suspension
  • suspendReason — explains why the account was suspended

Run the Migration

npx prisma migrate dev --name add-rbac-fields

This generates and applies the migration. Let's verify the SQL Prisma creates:

-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN', 'SUPER_ADMIN');
 
-- AlterTable
ALTER TABLE "users"
  ADD COLUMN "role" "Role" NOT NULL DEFAULT 'USER',
  ADD COLUMN "suspendedAt" TIMESTAMP(3),
  ADD COLUMN "suspendedBy" TEXT,
  ADD COLUMN "suspendReason" TEXT;

Because role defaults to USER, all existing users automatically get the correct role. No data migration needed.

Regenerate Prisma Client

npx prisma generate

Now Role.USER, Role.ADMIN, and Role.SUPER_ADMIN are available as typed constants.


Admin Middleware

The core of RBAC is a middleware that checks whether the authenticated user has the required role. We'll make it composable — a factory function that returns middleware for any combination of roles.

The requireRole Middleware

// src/middleware/rbac.ts
import { Request, Response, NextFunction } from 'express';
import { Role } from '@prisma/client';
import { AppError } from '../utils/errors';
 
/**
 * Factory middleware that restricts access to specific roles.
 * Must be used AFTER requireAuth middleware.
 */
export function requireRole(...allowedRoles: Role[]) {
  return (req: Request, _res: Response, next: NextFunction) => {
    const user = req.user;
 
    if (!user) {
      throw new AppError('Authentication required', 401);
    }
 
    // Check suspension before role — suspended admins shouldn't access anything
    if (user.suspendedAt) {
      throw new AppError(
        'Your account has been suspended. Contact support for assistance.',
        403
      );
    }
 
    if (!allowedRoles.includes(user.role as Role)) {
      throw new AppError(
        'You do not have permission to perform this action',
        403
      );
    }
 
    next();
  };
}
 
// ── Convenience helpers ──────────────────────────
// These read clearly in route definitions
 
/** Requires ADMIN or SUPER_ADMIN role */
export const requireAdmin = requireRole(Role.ADMIN, Role.SUPER_ADMIN);
 
/** Requires SUPER_ADMIN role only */
export const requireSuperAdmin = requireRole(Role.SUPER_ADMIN);

The beauty of this pattern is how cleanly it reads in route definitions:

// Anyone authenticated can access
router.get('/my-urls', requireAuth, getMyUrls);
 
// Only admins can access
router.get('/admin/users', requireAuth, requireAdmin, listUsers);
 
// Only super admins can access
router.patch('/admin/users/:id/role', requireAuth, requireSuperAdmin, updateRole);

Update the Express Types

We need to extend the Express Request type to include our user's role:

// src/types/express.d.ts
import { Role } from '@prisma/client';
 
declare global {
  namespace Express {
    interface Request {
      user?: {
        id: string;
        email: string;
        role: Role;
        suspendedAt: Date | null;
      };
    }
  }
}

Update JWT Payload

Our current JWT tokens only contain the user ID and email. We need to include the role so the middleware can check authorization without hitting the database on every request.

Updated Token Generation

// src/services/auth.service.ts
import { Role } from '@prisma/client';
import jwt from 'jsonwebtoken';
import { config } from '../config';
 
interface TokenPayload {
  userId: string;
  email: string;
  role: Role;
}
 
export function generateAccessToken(user: {
  id: string;
  email: string;
  role: Role;
}): string {
  const payload: TokenPayload = {
    userId: user.id,
    email: user.email,
    role: user.role,
  };
 
  return jwt.sign(payload, config.jwt.accessSecret, {
    expiresIn: config.jwt.accessExpiresIn,
  });
}
 
export function generateRefreshToken(user: {
  id: string;
  email: string;
  role: Role;
}): string {
  return jwt.sign(
    { userId: user.id, email: user.email, role: user.role },
    config.jwt.refreshSecret,
    { expiresIn: config.jwt.refreshExpiresIn }
  );
}

Updated Auth Middleware

The auth middleware now extracts the role from the JWT payload and attaches it to the request. It also checks suspension status from the database for critical operations:

// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config';
import { AppError } from '../utils/errors';
import { prisma } from '../lib/prisma';
 
interface JwtPayload {
  userId: string;
  email: string;
  role: string;
}
 
export async function requireAuth(
  req: Request,
  _res: Response,
  next: NextFunction
) {
  const authHeader = req.headers.authorization;
 
  if (!authHeader?.startsWith('Bearer ')) {
    throw new AppError('Access token required', 401);
  }
 
  const token = authHeader.split(' ')[1];
 
  try {
    const payload = jwt.verify(token, config.jwt.accessSecret) as JwtPayload;
 
    // Fetch fresh user data to check suspension status
    // This prevents suspended users from using pre-suspension tokens
    const user = await prisma.user.findUnique({
      where: { id: payload.userId },
      select: {
        id: true,
        email: true,
        role: true,
        suspendedAt: true,
      },
    });
 
    if (!user) {
      throw new AppError('User not found', 401);
    }
 
    if (user.suspendedAt) {
      throw new AppError(
        'Your account has been suspended. Contact support for assistance.',
        403
      );
    }
 
    req.user = user;
    next();
  } catch (error) {
    if (error instanceof AppError) throw error;
    throw new AppError('Invalid or expired access token', 401);
  }
}

Why fetch from the database? The role in the JWT might be stale — if an admin demotes a user, the user's existing tokens still contain the old role. Similarly, if a user is suspended, their active tokens should become invalid immediately. The database check ensures real-time accuracy.


User Management Endpoints

Now for the meat of this post — the admin API for managing users. We'll build five endpoints:

EndpointMethodRole RequiredDescription
/api/admin/usersGETADMINList all users with pagination and search
/api/admin/users/:idGETADMINGet detailed user profile with stats
/api/admin/users/:id/rolePATCHSUPER_ADMINPromote or demote a user
/api/admin/users/:id/suspendPOSTADMINSuspend a user account
/api/admin/users/:id/reactivatePOSTADMINReactivate a suspended account

Route Setup

// src/routes/admin.ts
import { Router } from 'express';
import { requireAuth } from '../middleware/auth';
import { requireAdmin, requireSuperAdmin } from '../middleware/rbac';
import * as adminController from '../controllers/admin.controller';
import { validate } from '../middleware/validate';
import {
  listUsersSchema,
  updateRoleSchema,
  suspendUserSchema,
} from '../validators/admin.validators';
 
const router = Router();
 
// All admin routes require authentication + admin role
router.use(requireAuth, requireAdmin);
 
// User management
router.get('/users', validate(listUsersSchema), adminController.listUsers);
router.get('/users/:id', adminController.getUserDetails);
router.patch(
  '/users/:id/role',
  requireSuperAdmin,
  validate(updateRoleSchema),
  adminController.updateUserRole
);
router.post(
  '/users/:id/suspend',
  validate(suspendUserSchema),
  adminController.suspendUser
);
router.post('/users/:id/reactivate', adminController.reactivateUser);
 
export default router;

Notice how router.use(requireAuth, requireAdmin) applies to all routes in this file, and requireSuperAdmin is added only to the role update endpoint. Clean layering.

Register Admin Routes

// src/app.ts (add to existing route setup)
import adminRoutes from './routes/admin';
 
// ... existing routes ...
app.use('/api/admin', adminRoutes);

Request Validation

Before implementing controllers, let's define validation schemas for the admin endpoints:

// src/validators/admin.validators.ts
import { z } from 'zod';
import { Role } from '@prisma/client';
 
export const listUsersSchema = z.object({
  query: z.object({
    page: z.coerce.number().int().positive().default(1),
    limit: z.coerce.number().int().min(1).max(100).default(20),
    search: z.string().optional(),
    role: z.nativeEnum(Role).optional(),
    status: z.enum(['active', 'suspended']).optional(),
    sortBy: z.enum(['createdAt', 'email', 'name', 'role']).default('createdAt'),
    sortOrder: z.enum(['asc', 'desc']).default('desc'),
  }),
});
 
export const updateRoleSchema = z.object({
  body: z.object({
    role: z.nativeEnum(Role, {
      errorMap: () => ({
        message: 'Role must be USER, ADMIN, or SUPER_ADMIN',
      }),
    }),
  }),
});
 
export const suspendUserSchema = z.object({
  body: z.object({
    reason: z
      .string()
      .min(10, 'Suspension reason must be at least 10 characters')
      .max(500, 'Suspension reason must be at most 500 characters'),
  }),
});

Controller Implementation

Here's the full admin controller with all five endpoints. Each method follows a consistent pattern: validate input, perform the operation, return a structured response.

List Users

// src/controllers/admin.controller.ts
import { Request, Response } from 'express';
import { prisma } from '../lib/prisma';
import { Role } from '@prisma/client';
import { AppError } from '../utils/errors';
 
/**
 * GET /api/admin/users
 * List all users with pagination, search, and filters.
 */
export async function listUsers(req: Request, res: Response) {
  const {
    page = 1,
    limit = 20,
    search,
    role,
    status,
    sortBy = 'createdAt',
    sortOrder = 'desc',
  } = req.query as {
    page?: number;
    limit?: number;
    search?: string;
    role?: Role;
    status?: 'active' | 'suspended';
    sortBy?: string;
    sortOrder?: 'asc' | 'desc';
  };
 
  // Build filter conditions
  const where: Record<string, unknown> = {};
 
  if (search) {
    where.OR = [
      { email: { contains: search, mode: 'insensitive' } },
      { name: { contains: search, mode: 'insensitive' } },
    ];
  }
 
  if (role) {
    where.role = role;
  }
 
  if (status === 'active') {
    where.suspendedAt = null;
  } else if (status === 'suspended') {
    where.suspendedAt = { not: null };
  }
 
  // Execute count and find in parallel
  const [total, users] = await Promise.all([
    prisma.user.count({ where }),
    prisma.user.findMany({
      where,
      select: {
        id: true,
        email: true,
        name: true,
        role: true,
        suspendedAt: true,
        suspendReason: true,
        createdAt: true,
        updatedAt: true,
        _count: {
          select: { urls: true },
        },
      },
      orderBy: { [sortBy]: sortOrder },
      skip: (page - 1) * limit,
      take: limit,
    }),
  ]);
 
  const totalPages = Math.ceil(total / limit);
 
  res.json({
    data: users.map((user) => ({
      ...user,
      urlCount: user._count.urls,
      _count: undefined,
    })),
    pagination: {
      page,
      limit,
      total,
      totalPages,
      hasNext: page < totalPages,
      hasPrev: page > 1,
    },
  });
}

Get User Details

/**
 * GET /api/admin/users/:id
 * Get detailed user profile including URL stats.
 */
export async function getUserDetails(req: Request, res: Response) {
  const { id } = req.params;
 
  const user = await prisma.user.findUnique({
    where: { id },
    select: {
      id: true,
      email: true,
      name: true,
      role: true,
      suspendedAt: true,
      suspendedBy: true,
      suspendReason: true,
      createdAt: true,
      updatedAt: true,
      _count: {
        select: {
          urls: true,
          refreshTokens: true,
        },
      },
      urls: {
        select: {
          id: true,
          shortCode: true,
          originalUrl: true,
          clicks: true,
          createdAt: true,
          expiresAt: true,
        },
        orderBy: { createdAt: 'desc' },
        take: 10, // Most recent 10 URLs
      },
    },
  });
 
  if (!user) {
    throw new AppError('User not found', 404);
  }
 
  // Aggregate URL stats
  const urlStats = await prisma.url.aggregate({
    where: { userId: id },
    _sum: { clicks: true },
    _count: { id: true },
    _max: { createdAt: true },
  });
 
  res.json({
    data: {
      ...user,
      urlCount: user._count.urls,
      activeSessions: user._count.refreshTokens,
      stats: {
        totalUrls: urlStats._count.id,
        totalClicks: urlStats._sum.clicks ?? 0,
        lastUrlCreated: urlStats._max.createdAt,
      },
      recentUrls: user.urls,
      _count: undefined,
    },
  });
}

Update User Role

/**
 * PATCH /api/admin/users/:id/role
 * Promote or demote a user. SUPER_ADMIN only.
 */
export async function updateUserRole(req: Request, res: Response) {
  const { id } = req.params;
  const { role } = req.body as { role: Role };
  const currentUser = req.user!;
 
  // Prevent self-demotion (avoid locking yourself out)
  if (id === currentUser.id) {
    throw new AppError(
      'You cannot change your own role. Ask another super admin.',
      400
    );
  }
 
  // Verify target user exists
  const targetUser = await prisma.user.findUnique({
    where: { id },
    select: { id: true, email: true, role: true },
  });
 
  if (!targetUser) {
    throw new AppError('User not found', 404);
  }
 
  // Prevent demoting another SUPER_ADMIN (only they can demote themselves, which we also block)
  if (targetUser.role === Role.SUPER_ADMIN && role !== Role.SUPER_ADMIN) {
    throw new AppError(
      'Cannot demote a SUPER_ADMIN. They must be demoted by direct database access.',
      403
    );
  }
 
  const updatedUser = await prisma.user.update({
    where: { id },
    data: { role },
    select: {
      id: true,
      email: true,
      name: true,
      role: true,
      updatedAt: true,
    },
  });
 
  // Log the role change (foundation for audit log)
  console.log(
    JSON.stringify({
      event: 'admin.role_change',
      adminId: currentUser.id,
      adminEmail: currentUser.email,
      targetUserId: id,
      targetEmail: targetUser.email,
      previousRole: targetUser.role,
      newRole: role,
      timestamp: new Date().toISOString(),
    })
  );
 
  res.json({
    message: `User role updated to ${role}`,
    data: updatedUser,
  });
}

Suspend User

/**
 * POST /api/admin/users/:id/suspend
 * Suspend a user account with a reason.
 */
export async function suspendUser(req: Request, res: Response) {
  const { id } = req.params;
  const { reason } = req.body as { reason: string };
  const currentUser = req.user!;
 
  // Prevent self-suspension
  if (id === currentUser.id) {
    throw new AppError('You cannot suspend your own account', 400);
  }
 
  const targetUser = await prisma.user.findUnique({
    where: { id },
    select: { id: true, email: true, role: true, suspendedAt: true },
  });
 
  if (!targetUser) {
    throw new AppError('User not found', 404);
  }
 
  if (targetUser.suspendedAt) {
    throw new AppError('User is already suspended', 400);
  }
 
  // Admins cannot suspend other admins — only SUPER_ADMIN can
  if (
    targetUser.role === Role.ADMIN ||
    targetUser.role === Role.SUPER_ADMIN
  ) {
    if (currentUser.role !== Role.SUPER_ADMIN) {
      throw new AppError(
        'Only SUPER_ADMIN can suspend admin accounts',
        403
      );
    }
  }
 
  // Cannot suspend SUPER_ADMIN at all
  if (targetUser.role === Role.SUPER_ADMIN) {
    throw new AppError('Cannot suspend a SUPER_ADMIN account', 403);
  }
 
  const updatedUser = await prisma.user.update({
    where: { id },
    data: {
      suspendedAt: new Date(),
      suspendedBy: currentUser.id,
      suspendReason: reason,
    },
    select: {
      id: true,
      email: true,
      name: true,
      role: true,
      suspendedAt: true,
      suspendReason: true,
    },
  });
 
  // Invalidate all refresh tokens for the suspended user
  await prisma.refreshToken.deleteMany({
    where: { userId: id },
  });
 
  // Log the suspension
  console.log(
    JSON.stringify({
      event: 'admin.user_suspended',
      adminId: currentUser.id,
      adminEmail: currentUser.email,
      targetUserId: id,
      targetEmail: targetUser.email,
      reason,
      timestamp: new Date().toISOString(),
    })
  );
 
  res.json({
    message: 'User account suspended',
    data: updatedUser,
  });
}

Reactivate User

/**
 * POST /api/admin/users/:id/reactivate
 * Reactivate a suspended user account.
 */
export async function reactivateUser(req: Request, res: Response) {
  const { id } = req.params;
  const currentUser = req.user!;
 
  const targetUser = await prisma.user.findUnique({
    where: { id },
    select: { id: true, email: true, suspendedAt: true },
  });
 
  if (!targetUser) {
    throw new AppError('User not found', 404);
  }
 
  if (!targetUser.suspendedAt) {
    throw new AppError('User is not suspended', 400);
  }
 
  const updatedUser = await prisma.user.update({
    where: { id },
    data: {
      suspendedAt: null,
      suspendedBy: null,
      suspendReason: null,
    },
    select: {
      id: true,
      email: true,
      name: true,
      role: true,
      suspendedAt: true,
    },
  });
 
  // Log the reactivation
  console.log(
    JSON.stringify({
      event: 'admin.user_reactivated',
      adminId: currentUser.id,
      adminEmail: currentUser.email,
      targetUserId: id,
      targetEmail: targetUser.email,
      timestamp: new Date().toISOString(),
    })
  );
 
  res.json({
    message: 'User account reactivated',
    data: updatedUser,
  });
}

Suspended User Handling

Suspension needs to be enforced everywhere — login, URL creation, API access. A suspended user should be completely locked out.

Block Login for Suspended Users

Update the login service to reject suspended accounts:

// src/services/auth.service.ts (update login function)
export async function login(email: string, password: string) {
  const user = await prisma.user.findUnique({
    where: { email },
    select: {
      id: true,
      email: true,
      passwordHash: true,
      role: true,
      suspendedAt: true,
      suspendReason: true,
    },
  });
 
  if (!user) {
    throw new AppError('Invalid email or password', 401);
  }
 
  // Check suspension BEFORE password verification
  // This prevents timing attacks that could reveal account existence
  if (user.suspendedAt) {
    throw new AppError(
      `Account suspended: ${user.suspendReason || 'Contact support for details'}`,
      403
    );
  }
 
  const isValid = await bcrypt.compare(password, user.passwordHash);
  if (!isValid) {
    throw new AppError('Invalid email or password', 401);
  }
 
  const accessToken = generateAccessToken(user);
  const refreshToken = generateRefreshToken(user);
 
  // Store refresh token in database
  await prisma.refreshToken.create({
    data: {
      token: refreshToken,
      userId: user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
    },
  });
 
  return { accessToken, refreshToken, user };
}

Block Token Refresh for Suspended Users

// src/services/auth.service.ts (update refresh function)
export async function refreshAccessToken(refreshToken: string) {
  const stored = await prisma.refreshToken.findUnique({
    where: { token: refreshToken },
    include: {
      user: {
        select: {
          id: true,
          email: true,
          role: true,
          suspendedAt: true,
        },
      },
    },
  });
 
  if (!stored || stored.expiresAt < new Date()) {
    throw new AppError('Invalid or expired refresh token', 401);
  }
 
  if (stored.user.suspendedAt) {
    // Delete the refresh token — they shouldn't be able to retry
    await prisma.refreshToken.delete({ where: { id: stored.id } });
    throw new AppError('Account suspended', 403);
  }
 
  const newAccessToken = generateAccessToken(stored.user);
  return { accessToken: newAccessToken };
}

Block URL Creation for Suspended Users

The auth middleware already checks suspension status and rejects requests, so URL creation is automatically blocked. But let's add explicit handling in the URL service as a defense-in-depth measure:

// src/services/url.service.ts (add check at the top of createUrl)
export async function createUrl(
  userId: string,
  originalUrl: string,
  customAlias?: string,
  expiresAt?: Date
) {
  // Defense in depth — auth middleware should catch this,
  // but we double-check here
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: { suspendedAt: true },
  });
 
  if (user?.suspendedAt) {
    throw new AppError('Account suspended — cannot create URLs', 403);
  }
 
  // ... rest of URL creation logic
}

The flow for a suspended user looks like this:

Every entry point is covered. The user is truly locked out.


Seeding the First Admin

You have a chicken-and-egg problem: you need an admin to promote users to admin, but there are no admins yet. The solution is to seed the first SUPER_ADMIN directly into the database.

Prisma Seed Script

// prisma/seed.ts
import { PrismaClient, Role } from '@prisma/client';
import bcrypt from 'bcrypt';
 
const prisma = new PrismaClient();
 
async function main() {
  const adminEmail = process.env.ADMIN_EMAIL;
  const adminPassword = process.env.ADMIN_PASSWORD;
 
  if (!adminEmail || !adminPassword) {
    console.error('ADMIN_EMAIL and ADMIN_PASSWORD environment variables required');
    process.exit(1);
  }
 
  if (adminPassword.length < 12) {
    console.error('Admin password must be at least 12 characters');
    process.exit(1);
  }
 
  const hashedPassword = await bcrypt.hash(adminPassword, 12);
 
  const admin = await prisma.user.upsert({
    where: { email: adminEmail },
    update: {
      role: Role.SUPER_ADMIN,
    },
    create: {
      email: adminEmail,
      passwordHash: hashedPassword,
      name: 'Super Admin',
      role: Role.SUPER_ADMIN,
    },
  });
 
  console.log(`Super admin seeded: ${admin.email} (${admin.id})`);
}
 
main()
  .catch((error) => {
    console.error('Seed failed:', error);
    process.exit(1);
  })
  .finally(() => prisma.$disconnect());

Configure the Seed Command

// package.json (add to prisma section)
{
  "prisma": {
    "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
  }
}

Run the Seed

# Set admin credentials
export ADMIN_EMAIL="admin@yourdomain.com"
export ADMIN_PASSWORD="super-secure-password-here"
 
# Run the seed
npx prisma db seed

Alternative: CLI Promotion Script

If you prefer promoting an existing user, here's a one-off script:

// scripts/promote-admin.ts
import { PrismaClient, Role } from '@prisma/client';
 
const prisma = new PrismaClient();
 
async function main() {
  const email = process.argv[2];
 
  if (!email) {
    console.error('Usage: npx ts-node scripts/promote-admin.ts <email>');
    process.exit(1);
  }
 
  const user = await prisma.user.update({
    where: { email },
    data: { role: Role.SUPER_ADMIN },
  });
 
  console.log(`Promoted ${user.email} to SUPER_ADMIN`);
}
 
main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());
npx ts-node scripts/promote-admin.ts admin@yourdomain.com

API Summary

Here's a complete reference of all new endpoints added in this phase:

MethodEndpointRequired RoleDescription
GET/api/admin/usersADMINList users with pagination, search, filters
GET/api/admin/users/:idADMINGet user details with URL stats
PATCH/api/admin/users/:id/roleSUPER_ADMINChange user role
POST/api/admin/users/:id/suspendADMINSuspend a user account
POST/api/admin/users/:id/reactivateADMINReactivate suspended account

Query Parameters for GET /api/admin/users

ParameterTypeDefaultDescription
pagenumber1Page number
limitnumber20Items per page (max 100)
searchstringSearch by email or name
roleenumFilter by role: USER, ADMIN, SUPER_ADMIN
statusstringFilter: "active" or "suspended"
sortBystringcreatedAtSort field: createdAt, email, name, role
sortOrderstringdescSort direction: asc or desc

Example API Calls

# List all users (page 1, 20 per page)
curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/api/admin/users
 
# Search users by email
curl -H "Authorization: Bearer $TOKEN" \
  "http://localhost:3000/api/admin/users?search=john&role=USER"
 
# Get user details
curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/api/admin/users/abc-123-def
 
# Promote user to ADMIN
curl -X PATCH \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "ADMIN"}' \
  http://localhost:3000/api/admin/users/abc-123-def/role
 
# Suspend user
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Spamming malicious redirect URLs"}' \
  http://localhost:3000/api/admin/users/abc-123-def/suspend
 
# Reactivate user
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/api/admin/users/abc-123-def/reactivate

Security Considerations

Admin endpoints are high-value targets. Here are the security principles we follow:

1. Never Expose Role Changes in Regular User API

The user profile endpoint (GET /api/me) should return the role but never allow changing it:

// src/controllers/user.controller.ts
export async function updateProfile(req: Request, res: Response) {
  const { name } = req.body;
 
  // ONLY allow name updates — never role, email changes go through
  // a separate verification flow
  const user = await prisma.user.update({
    where: { id: req.user!.id },
    data: { name },
    select: { id: true, email: true, name: true, role: true },
  });
 
  res.json({ data: user });
}

2. Prevent Admin Lockout

A SUPER_ADMIN should never be able to demote or suspend themselves, because it could leave the system without any admins:

// Already implemented in updateUserRole and suspendUser above:
if (id === currentUser.id) {
  throw new AppError('You cannot change your own role', 400);
}

3. Rate Limit Admin Endpoints

Admin endpoints should have stricter rate limits than regular APIs — they're powerful and should be used deliberately:

// src/middleware/rateLimit.ts
import rateLimit from 'express-rate-limit';
 
export const adminRateLimit = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per 15 minutes
  message: {
    error: 'Too many admin requests, please try again later',
  },
  standardHeaders: true,
  legacyHeaders: false,
});
// src/routes/admin.ts
import { adminRateLimit } from '../middleware/rateLimit';
 
router.use(requireAuth, requireAdmin, adminRateLimit);

4. Log All Admin Actions

Every admin action produces a structured log entry. This is the foundation for a full audit log system (which we'll build in a future phase):

// Pattern used throughout the controllers:
console.log(
  JSON.stringify({
    event: 'admin.user_suspended',
    adminId: currentUser.id,
    adminEmail: currentUser.email,
    targetUserId: id,
    reason,
    timestamp: new Date().toISOString(),
  })
);

In production, these structured logs can be piped to a logging service (ELK, Datadog, etc.) for searching and alerting.

5. Never Trust the JWT Role Alone

As we mentioned, the auth middleware fetches fresh user data from the database. This prevents a race condition where:

  1. Admin demotes user to USER
  2. User still has a valid JWT with role: ADMIN
  3. User accesses admin endpoints with the old token

By checking the database, we ensure the role is always current.


Common Pitfalls

Pitfall 1: Checking Role in Every Controller

WRONG — duplicating authorization logic:

// ❌ Don't do this — role checking scattered across controllers
export async function listUsers(req: Request, res: Response) {
  if (req.user?.role !== 'ADMIN' && req.user?.role !== 'SUPER_ADMIN') {
    return res.status(403).json({ error: 'Forbidden' });
  }
  // ... rest of logic
}
 
export async function suspendUser(req: Request, res: Response) {
  if (req.user?.role !== 'ADMIN' && req.user?.role !== 'SUPER_ADMIN') {
    return res.status(403).json({ error: 'Forbidden' });
  }
  // ... rest of logic
}

CORRECT — centralized middleware:

// ✅ Role checking in middleware, controllers stay clean
router.use(requireAuth, requireAdmin);
 
router.get('/users', adminController.listUsers);
router.post('/users/:id/suspend', adminController.suspendUser);

The middleware handles authorization. Controllers only handle business logic.

Pitfall 2: Forgetting Suspension in Auth Flow

WRONG — only checking suspension in the RBAC middleware:

// ❌ Suspended user can still log in and get new tokens!
export async function login(email: string, password: string) {
  const user = await prisma.user.findUnique({ where: { email } });
  // No suspension check here — user gets a valid token
  return { token: generateAccessToken(user) };
}

CORRECT — check suspension at every entry point:

// ✅ Check suspension at login, token refresh, AND middleware
export async function login(email: string, password: string) {
  const user = await prisma.user.findUnique({ where: { email } });
 
  if (user?.suspendedAt) {
    throw new AppError('Account suspended', 403);
  }
 
  // ... verify password, generate tokens
}

Pitfall 3: Allowing Admin to Suspend Themselves

WRONG — no self-protection:

// ❌ Admin suspends themselves, now nobody can unsuspend them
export async function suspendUser(req: Request, res: Response) {
  const { id } = req.params;
  await prisma.user.update({
    where: { id },
    data: { suspendedAt: new Date() },
  });
}

CORRECT — block self-suspension:

// ✅ Prevent self-suspension
export async function suspendUser(req: Request, res: Response) {
  const { id } = req.params;
 
  if (id === req.user!.id) {
    throw new AppError('You cannot suspend your own account', 400);
  }
 
  await prisma.user.update({
    where: { id },
    data: { suspendedAt: new Date() },
  });
}

Pitfall 4: Returning Password Hash in Admin Responses

WRONG — exposing sensitive data:

// ❌ Password hash included in response!
const user = await prisma.user.findUnique({ where: { id } });
res.json({ data: user });

CORRECT — use select to pick only safe fields:

// ✅ Explicitly select safe fields
const user = await prisma.user.findUnique({
  where: { id },
  select: {
    id: true,
    email: true,
    name: true,
    role: true,
    createdAt: true,
    // No passwordHash, no apiKey
  },
});
res.json({ data: user });

Always use Prisma's select to whitelist fields instead of returning the full model.


Testing Admin Endpoints

Admin endpoints need thorough testing because they deal with authorization boundaries. Here are the key test cases using Vitest and Supertest.

Test Setup

// src/__tests__/admin.test.ts
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { prisma } from '../lib/prisma';
import { Role } from '@prisma/client';
import bcrypt from 'bcrypt';
 
// Helper to create users with specific roles
async function createUser(overrides: Partial<{
  email: string;
  role: Role;
  suspended: boolean;
}> = {}) {
  const email = overrides.email ?? `user-${Date.now()}@test.com`;
  const user = await prisma.user.create({
    data: {
      email,
      passwordHash: await bcrypt.hash('password123', 10),
      name: 'Test User',
      role: overrides.role ?? Role.USER,
      suspendedAt: overrides.suspended ? new Date() : null,
      suspendReason: overrides.suspended ? 'Test suspension' : null,
    },
  });
  return user;
}
 
// Helper to get auth token for a user
async function getToken(email: string): Promise<string> {
  const res = await request(app)
    .post('/api/auth/login')
    .send({ email, password: 'password123' });
  return res.body.accessToken;
}
 
describe('Admin API', () => {
  let superAdmin: { id: string; email: string };
  let admin: { id: string; email: string };
  let regularUser: { id: string; email: string };
  let superAdminToken: string;
  let adminToken: string;
  let userToken: string;
 
  beforeAll(async () => {
    // Clean up
    await prisma.user.deleteMany({});
 
    // Create test users
    superAdmin = await createUser({
      email: 'super@test.com',
      role: Role.SUPER_ADMIN,
    });
    admin = await createUser({
      email: 'admin@test.com',
      role: Role.ADMIN,
    });
    regularUser = await createUser({
      email: 'user@test.com',
      role: Role.USER,
    });
 
    // Get tokens
    superAdminToken = await getToken('super@test.com');
    adminToken = await getToken('admin@test.com');
    userToken = await getToken('user@test.com');
  });

Test: Authorization Boundaries

  describe('Authorization', () => {
    it('should reject unauthenticated requests', async () => {
      const res = await request(app).get('/api/admin/users');
      expect(res.status).toBe(401);
    });
 
    it('should reject regular users', async () => {
      const res = await request(app)
        .get('/api/admin/users')
        .set('Authorization', `Bearer ${userToken}`);
      expect(res.status).toBe(403);
      expect(res.body.error).toContain('permission');
    });
 
    it('should allow admin access', async () => {
      const res = await request(app)
        .get('/api/admin/users')
        .set('Authorization', `Bearer ${adminToken}`);
      expect(res.status).toBe(200);
      expect(res.body.data).toBeDefined();
    });
 
    it('should allow super admin access', async () => {
      const res = await request(app)
        .get('/api/admin/users')
        .set('Authorization', `Bearer ${superAdminToken}`);
      expect(res.status).toBe(200);
    });
  });

Test: Role Changes

  describe('PATCH /api/admin/users/:id/role', () => {
    it('should reject role changes from regular admin', async () => {
      const res = await request(app)
        .patch(`/api/admin/users/${regularUser.id}/role`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ role: 'ADMIN' });
      expect(res.status).toBe(403);
    });
 
    it('should allow super admin to promote user', async () => {
      const newUser = await createUser({ email: 'promote-me@test.com' });
      const res = await request(app)
        .patch(`/api/admin/users/${newUser.id}/role`)
        .set('Authorization', `Bearer ${superAdminToken}`)
        .send({ role: 'ADMIN' });
      expect(res.status).toBe(200);
      expect(res.body.data.role).toBe('ADMIN');
    });
 
    it('should prevent self-demotion', async () => {
      const res = await request(app)
        .patch(`/api/admin/users/${superAdmin.id}/role`)
        .set('Authorization', `Bearer ${superAdminToken}`)
        .send({ role: 'USER' });
      expect(res.status).toBe(400);
      expect(res.body.error).toContain('own role');
    });
 
    it('should reject invalid role values', async () => {
      const res = await request(app)
        .patch(`/api/admin/users/${regularUser.id}/role`)
        .set('Authorization', `Bearer ${superAdminToken}`)
        .send({ role: 'MEGADMIN' });
      expect(res.status).toBe(400);
    });
  });

Test: Suspension

  describe('POST /api/admin/users/:id/suspend', () => {
    it('should suspend a user with reason', async () => {
      const target = await createUser({ email: 'suspend-me@test.com' });
      const res = await request(app)
        .post(`/api/admin/users/${target.id}/suspend`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ reason: 'Posting spam links repeatedly' });
 
      expect(res.status).toBe(200);
      expect(res.body.data.suspendedAt).toBeDefined();
      expect(res.body.data.suspendReason).toBe('Posting spam links repeatedly');
    });
 
    it('should prevent self-suspension', async () => {
      const res = await request(app)
        .post(`/api/admin/users/${admin.id}/suspend`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ reason: 'Testing self-suspension' });
      expect(res.status).toBe(400);
    });
 
    it('should require suspension reason', async () => {
      const target = await createUser({ email: 'no-reason@test.com' });
      const res = await request(app)
        .post(`/api/admin/users/${target.id}/suspend`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({});
      expect(res.status).toBe(400);
    });
 
    it('should block login for suspended user', async () => {
      const target = await createUser({ email: 'blocked@test.com' });
 
      // Suspend the user
      await request(app)
        .post(`/api/admin/users/${target.id}/suspend`)
        .set('Authorization', `Bearer ${adminToken}`)
        .send({ reason: 'Abusive behavior detected' });
 
      // Try to login
      const loginRes = await request(app)
        .post('/api/auth/login')
        .send({ email: 'blocked@test.com', password: 'password123' });
 
      expect(loginRes.status).toBe(403);
      expect(loginRes.body.error).toContain('suspended');
    });
  });

Test: User Search and Pagination

  describe('GET /api/admin/users', () => {
    it('should paginate results', async () => {
      const res = await request(app)
        .get('/api/admin/users?page=1&limit=2')
        .set('Authorization', `Bearer ${adminToken}`);
 
      expect(res.status).toBe(200);
      expect(res.body.data.length).toBeLessThanOrEqual(2);
      expect(res.body.pagination).toMatchObject({
        page: 1,
        limit: 2,
        hasNext: expect.any(Boolean),
        hasPrev: false,
      });
    });
 
    it('should search by email', async () => {
      const res = await request(app)
        .get('/api/admin/users?search=super@test')
        .set('Authorization', `Bearer ${adminToken}`);
 
      expect(res.status).toBe(200);
      expect(res.body.data.length).toBeGreaterThanOrEqual(1);
      expect(res.body.data[0].email).toContain('super@test');
    });
 
    it('should filter by role', async () => {
      const res = await request(app)
        .get('/api/admin/users?role=ADMIN')
        .set('Authorization', `Bearer ${adminToken}`);
 
      expect(res.status).toBe(200);
      res.body.data.forEach((user: { role: string }) => {
        expect(user.role).toBe('ADMIN');
      });
    });
 
    it('should filter by suspension status', async () => {
      const res = await request(app)
        .get('/api/admin/users?status=suspended')
        .set('Authorization', `Bearer ${adminToken}`);
 
      expect(res.status).toBe(200);
      res.body.data.forEach((user: { suspendedAt: string | null }) => {
        expect(user.suspendedAt).not.toBeNull();
      });
    });
  });

Test: Reactivation

  describe('POST /api/admin/users/:id/reactivate', () => {
    it('should reactivate a suspended user', async () => {
      const target = await createUser({
        email: 'reactivate-me@test.com',
        suspended: true,
      });
 
      const res = await request(app)
        .post(`/api/admin/users/${target.id}/reactivate`)
        .set('Authorization', `Bearer ${adminToken}`);
 
      expect(res.status).toBe(200);
      expect(res.body.data.suspendedAt).toBeNull();
    });
 
    it('should reject reactivation of active user', async () => {
      const target = await createUser({ email: 'already-active@test.com' });
 
      const res = await request(app)
        .post(`/api/admin/users/${target.id}/reactivate`)
        .set('Authorization', `Bearer ${adminToken}`);
 
      expect(res.status).toBe(400);
      expect(res.body.error).toContain('not suspended');
    });
  });
});

Run the tests:

npx vitest run src/__tests__/admin.test.ts

What's Next

We have admin powers now — admins can view users, suspend abusers, and manage roles. But what about the URLs themselves? An admin needs to see all URLs across the system, flag malicious redirects, delete spam links, and perform bulk operations.

In Phase 11: URL Moderation & Bulk Operations, we'll build:

  • System-wide URL listing with search and filters
  • URL flagging and removal workflow
  • Bulk operations (delete, disable, transfer ownership)
  • Basic abuse detection patterns
  • Admin dashboard statistics

The user management from this phase combines with URL moderation in the next to form a complete admin panel.


Series: Build a URL Shortener
Previous: Phase 9: Deployment & Production
Next: Phase 11: URL Moderation & Bulk Operations

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.