RBAC di Next.js: Implementasi Lengkap dari Database sampai UI
Role-Based Access Control sering cuma diimplementasi di client-side — hide tombol, hide menu. Tapi API endpoint-nya tetap bisa di-hit langsung. Ini breakdown cara implementasi RBAC di dua layer: server (security) dan client (UX), dengan pattern yang clean dan maintainable.
Gue sering lihat implementasi RBAC yang cuma di client-side — tombol disembunyikan, menu di-hide, tapi API endpoint-nya tetap terbuka. Siapapun yang bisa buka DevTools bisa bypass semua protection.
RBAC harus diimplementasi di dua layer: server-side (security) dan client-side (UX).
Gue bakal breakdown implementasi lengkapnya — dari Prisma enum sampai role-based UI rendering, dengan pattern yang bisa langsung lo pakai.
Struktur File
prisma/ │ └── schema.prisma app/ ├── api/ │ ├── auth/ │ │ ├── login/route.ts │ │ └── me/route.ts │ ├── orders/route.ts │ ├── menu/route.ts │ └── stock/route.ts ├── admin/ │ ├── components/ │ │ ├── AdminProtected.tsx │ │ └── DashboardSidebar.tsx │ ├── orders/page.tsx │ ├── menu/page.tsx │ ├── analytics/page.tsx │ └── users/page.tsx ├── services/ │ ├── rbacAuthService.ts │ └── orderService.ts ├── middleware/ │ └── authMiddleware.ts └── types/ └── order.ts
Konsep: RBAC di Dua Layer
Server-side (security): API route menolak request yang nggak punya permission. Ini yang benar-benar melindungi data.
Client-side (UX): UI menyembunyikan tombol dan menu yang nggak boleh diakses. Ini bukan security — ini cuma UX.
Keduanya harus ada. Server-side jadi security layer utama, client-side jadi UX layer.
Step 1: Database Schema dengan Prisma Enum
enum UserRole {
SUPERADMIN
ADMIN
KASIR
}model User { id String @id @default(uuid()) name String email String @unique role UserRole @default(KASIR) outletId String? outlet Outlet? @relation(fields: [outletId], references: [id]) }
model Admin { id String @id @default(uuid()) username String @unique password String // hashed role UserRole @default(ADMIN) outletId String? outlet Outlet? @relation(fields: [outletId], references: [id]) } ```
Role didefinisikan sebagai enum — bukan string bebas. cuma value yang valid yang bisa masuk ke database.
outletId yang nullable penting: SUPERADMIN nggak punya outlet (akses semua), ADMIN dan KASIR di-assign ke outlet tertentu.
Step 2: Login dan Token
// app/api/auth/login/route.ts
import { RBACAuthService } from '@/services/rbacAuthService'export async function POST(request: NextRequest) { const { username, password } = await request.json()
if (!username || !password) { return NextResponse.json( { success: false, message: 'Username and password are required' }, { status: 400 } ) }
const result = await RBACAuthService.login({ username, password })
if (!result.success) { return NextResponse.json( { success: false, message: result.message }, { status: 401 } ) }
return NextResponse.json({ success: true, data: { user: { id: result.user?.id, name: result.user?.name, role: result.user?.role, outletId: result.user?.outletId, }, }, token: result.token, }) } ```
Service layer handle hash comparison dan token generation.
Step 3: Server-Side Protection — Yang Paling Penting
Setiap API route yang sensitif harus cek role. Contoh: order creation dari POS.
// app/api/orders/route.ts (excerpt)
export async function POST(request: NextRequest) {
const body = await request.json()
const { source, outletId } = bodyif (source === 'POS') { const authResult = await authenticateAdmin(request) if (authResult) { return NextResponse.json( { error: 'Authentication required' }, { status: 401 } ) }
const userRole = request.user?.role const userOutletId = request.user?.outletId
// SUPERADMIN bisa akses semua outlet if (userRole !== 'SUPERADMIN') { // Role lain cuma bisa create order di outlet sendiri if (userOutletId && outletId !== userOutletId) { return NextResponse.json( { error: 'You can only create orders for your assigned outlet.' }, { status: 403 } ) } } }
// ... create order } ```
Pattern kunci: SUPERADMIN bisa akses semua, role lain cuma outlet sendiri. Di-enforce di server.
GET orders juga di-filter per role:
export async function GET(request: AuthenticatedRequest) {
const userRole = request.user?.role
const userOutletId = request.user?.outletId
let outletId = searchParams.get('outletId')// Override parameter dari client if (userRole !== 'SUPERADMIN') { outletId = userOutletId // ignore parameter, force ke outlet sendiri }
const orders = await OrderService.getAllOrders({ outletId }) return NextResponse.json({ data: orders }) } ```
Parameter outletId dari client di-override. KASIR nggak bisa bypass filter dengan ubah query parameter.
Step 4: Client-Side Guard Component
// app/admin/components/AdminProtected.tsx
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'interface UserData { user: { role: 'SUPERADMIN' | 'ADMIN' | 'KASIR' outletId: string | null } loginTime: string }
export default function AdminProtected({ children }) { const [authorized, setAuthorized] = useState<boolean | null>(null) const router = useRouter()
useEffect(() => { const checkAuth = async () => { const token = localStorage.getItem('auth_token') const userData = JSON.parse(localStorage.getItem('user_data') || '{}')
if (!token || !userData.user) { setAuthorized(false) return }
// Cek session expiry (24 jam) const hours = (Date.now() - new Date(userData.loginTime).getTime()) / (1000 * 60 * 60) if (hours > 24) { localStorage.removeItem('auth_token') setAuthorized(false) return }
// Optimistic auth — langsung authorize kalau data fresh const hasAccess = ['SUPERADMIN', 'ADMIN', 'KASIR'] .includes(userData.user.role) if (hasAccess && hours < 2) { setAuthorized(true) // Verify di background, jangan block UI setTimeout(() => verifyWithServer(token, userData), 1000) return }
// Kalau sudah lama, verify ke server dulu
const res = await fetch('/api/auth/me', {
headers: { Authorization: Bearer ${token} },
})
setAuthorized(res.ok)
}
checkAuth() }, [])
useEffect(() => { if (authorized === false) router.push('/admin/login') }, [authorized])
if (!authorized) return null return <>{children}</> } ```
Trick penting: optimistic authorization. Kalau data di localStorage masih fresh (<2 jam), langsung authorize tanpa nunggu server. Server verification jalan di background via setTimeout.
Kalau verification gagal, setAuthorized(false) dipanggil dan user di-redirect ke login. Security tetap terjaga.
Step 5: Role-Based Sidebar
const menuItems = [
{ label: 'Dashboard', href: '/admin', roles: ['SUPERADMIN', 'ADMIN', 'KASIR'] },
{ label: 'Orders', href: '/admin/orders', roles: ['SUPERADMIN', 'ADMIN', 'KASIR'] },
{ label: 'Menu', href: '/admin/menu', roles: ['SUPERADMIN', 'ADMIN'] },
{ label: 'Stock', href: '/admin/stock', roles: ['SUPERADMIN', 'ADMIN'] },
{ label: 'Outlets', href: '/admin/outlets', roles: ['SUPERADMIN'] },
{ label: 'Analytics', href: '/admin/analytics', roles: ['SUPERADMIN'] },
{ label: 'Users', href: '/admin/users', roles: ['SUPERADMIN'] },
]const visibleMenu = menuItems.filter(item => item.roles.includes(userRole)) ```
Ini murni UX — menu disembunyikan biar interface bersih. Server-side protection tetap jadi security layer utama.
Production Gotcha
localStorage vs httpOnly cookie. Untuk SPA yang berat (admin dashboard), localStorage lebih fleksibel. Trade-off: lebih rentan XSS. Mitigasi: Content Security Policy yang ketat.
Session expiry 24 jam max. Jangan biarkan session gantung selamanya.
Filter data di query level, bukan di UI. KASIR cuma bisa query data outlet sendiri di database level — bukan 'query semua lalu hide di frontend.'