Oolvay uses Role-Based Access Control (RBAC) on top of Better Auth. Authentication answers who the user is. RBAC answers what that user is allowed to do after the session exists.
The system ships with three roles, one database column, and explicit server-side checks at every privileged boundary. This page covers how roles are stored, how the Better Auth admin plugin is configured, and how to protect pages, route handlers, and server actions.
user: standard signed-in accountadmin: can access admin surfaces and manage userssuper_admin: can manage admins and access system controlsRBAC is enforced on the server. Client components can hide buttons, links, and sidebar items, but they are never the source of authority.
Roles are defined in db/types/roles.ts. This file owns the role values, labels, badge styles, and icons used across the application.
0 / 2,000 characters
export const ROLES = {
USER: "user",
ADMIN: "admin",
SUPER_ADMIN: "super_admin",
} as const
export type Role = (typeof ROLES)[keyof typeof ROLES]The selected role is stored directly on the user table.
role: text("role").$type<Role>().default(ROLES.USER).notNull(),Every new account starts as user. The only time an automatic promotion happens is when the new account’s email matches siteConfig.authAndSession.initialSuperAdminEmail, in which case it is promoted to super_admin on sign-up.
if (
user.email.toLowerCase() ===
siteConfig.authAndSession.initialSuperAdminEmail.toLowerCase()
) {
await db
.update(schema.user)
.set({
role: ROLES.SUPER_ADMIN,
})
.where(eq(schema.user.id, user.id))
}This keeps the first privileged account reproducible through configuration instead of requiring a manual database edit.
Set initialSuperAdminEmail before the first user signs up. Every new account
starts as user, and the only automatic promotion happens when the signup
email exactly matches this value. That account becomes the first super admin
and can promote other users to admin from the admin UI.
Better Auth is configured in lib/auth/auth.ts. The admin plugin receives the default role, the list of roles considered admin roles, and the access-control map from lib/auth/permissions.ts.
admin({
defaultRole: ROLES.USER,
adminRoles: [ROLES.ADMIN, ROLES.SUPER_ADMIN],
ac,
roles: {
[ROLES.USER]: userRole,
[ROLES.ADMIN]: adminRole,
[ROLES.SUPER_ADMIN]: superAdminRole,
},
})The access-control object is created in lib/auth/permissions.ts from Better Auth’s default admin statements.
const statement = {
...defaultStatements,
} as const
export const ac = createAccessControl(statement)userRole has no admin capabilities. adminRole receives Better Auth’s built-in admin statements. superAdminRole receives the same statements plus the ability to impersonate admins.
export const userRole = ac.newRole({})
export const adminRole = ac.newRole({
...adminAc.statements,
})
export const superAdminRole = ac.newRole({
...adminAc.statements,
user: ["impersonate-admins", ...adminAc.statements.user],
})Better Auth permissions are only part of the system. Oolvay still performs explicit role checks in pages, route handlers, and server actions. Keep both layers in place.
Most application code should not compare admin roles manually. Use the helpers from lib/auth/permissions.ts.
export function isAdmin(role?: Role): boolean {
return role === ROLES.ADMIN || role === ROLES.SUPER_ADMIN
}
export function isSuperAdmin(role?: Role): boolean {
return role === ROLES.SUPER_ADMIN
}Use isAdmin() when both admins and super admins should pass. Use isSuperAdmin() when the operation is reserved for the highest privilege tier.
The admin route group is protected in app/(protected)/admin/layout.tsx. Every admin page passes through this layout before rendering.
const session = await getServerSession()
const user = session?.user
if (!session || !user) {
redirect("/login")
}
if (!isAdmin(user.role as Role)) {
unauthorized()
}This gives the admin section one shared gate. A signed-out visitor is redirected to login. A signed-in user without an admin role receives the app’s unauthorized page.
Pages that need stricter access still check again. The system page is super-admin only.
if (user.role !== ROLES.SUPER_ADMIN) {
unauthorized()
}The sidebar follows the same rule visually. Common admin links are shown to admins and super admins. The System link is appended only for super admins.
navItems={[
...navItems,
...(user.role === ROLES.SUPER_ADMIN ? superAdminNavItems : []),
]}The sidebar is convenience. The page check is enforcement.
Authenticated server actions use guardAction() from lib/guard-action.ts. It verifies the session and applies the configured Arcjet server-action rate limit before returning the current user.
guardAction() does not decide whether a user is an admin. It only proves that a valid user session exists and that the request passed action-level protection. The action must still check the role it needs.
"use server"
import { guardAction } from "@/lib/guard-action"
import { isAdmin } from "@/lib/auth/permissions"
import type { Role } from "@/db/types/roles"
export async function yourAction() {
const { error, user } = await guardAction({ type: "write" })
if (error) return { success: false, error }
if (!isAdmin(user.role as Role)) {
return { success: false, error: "Unauthorized" }
}
// privileged work
}User management has stricter rules than simple admin access. These rules are enforced in server actions such as changeRole(), deleteUser(), suspendUser(), and unsuspendUser().
| Role | Modify users | Modify admins | Modify super admins | Promote to super admin |
|---|---|---|---|---|
| User | ✗ | ✗ | ✗ | ✗ |
| Admin | ✓ | ✗ | ✗ | ✗ |
| Super admin | ✓ | ✓ | ✓ | ✓ |
Admins cannot change their own role, delete their own account from the admin users page, or suspend their own account. The same safeguards exist in the server actions, not only in the UI.
if (currentUser.id === userId) {
return { success: false, error: "You cannot change your own role." }
}
if (!isCurrentSuperAdmin && targetRole === ROLES.ADMIN) {
return {
success: false,
error: "You cannot modify another admin.",
}
}
if (!isCurrentSuperAdmin && newRole === ROLES.SUPER_ADMIN) {
return {
success: false,
error: "Only super admins can assign the super admin role.",
}
}The client mirrors these rules in components such as RoleSelect, DeleteUserButton, and SuspendUserButton so the interface does not offer actions that will fail. The server remains authoritative.
RBAC is also used outside the admin dashboard. Blog editing is admin-only, but ownership still matters.
canEditPost() allows a super admin to edit any post. An admin can edit only their own post.
export async function canEditPost(authorId: string) {
const session = await auth.api.getSession({
headers: await headers(),
})
if (!session?.user) return false
const role = session.user.role as Role
const isSuperAdmin = role === ROLES.SUPER_ADMIN
const isAdminUser = role === ROLES.ADMIN
const isOwner = session.user.id === authorId
return isSuperAdmin || (isAdminUser && isOwner)
}This is the pattern to follow when a role alone is not enough. Check the role first, then check ownership or resource-specific rules.
Gate the page or route on the server.
import { unauthorized } from "next/navigation"
import { getServerSession } from "@/lib/auth/get-server-session"
import { isAdmin } from "@/lib/auth/permissions"
import type { Role } from "@/db/types/roles"
export default async function ExampleAdminPage() {
const session = await getServerSession()
if (!isAdmin(session?.user?.role as Role)) {
unauthorized()
}
Protect every server action that mutates or reads privileged data.
"use server"
import { guardAction } from "@/lib/guard-action"
import { isAdmin } from "@/lib/auth/permissions"
import type { Role } from "@/db/types/roles"
export async function exampleAdminAction() {
const
If the feature belongs in the admin sidebar, register it in app/(protected)/admin/components/admin-page-sidebar.tsx.
Add it to navItems when admins and super admins should see it. Add it to superAdminNavItems when only super admins should see it.
If the feature changes a user’s privilege, suspension state, or access to sensitive data, log it through logEvent() so it appears in the admin activity trail.
Adding a role is possible, but it is not a one-file change. A role affects the database type, Better Auth permissions, admin UI, filters, badges, and every branch that assumes the current three-tier hierarchy.
Add the role value, label, style, and icon in db/types/roles.ts.
lib/auth/permissions.ts.Register the role in the admin() plugin configuration inside
lib/auth/auth.ts.
Update admin UI rules in role selectors, tables, filters, and sidebar visibility.
Review server actions that compare roles directly, especially user management actions.
Do not add a role only to the dropdown. If the server actions do not understand the new hierarchy, the UI and backend will disagree about what the role can do.
They are authenticated but do not have admin or super_admin in the user.role column. Promote the account through an existing super admin, or confirm that the account email matches authAndSession.initialSuperAdminEmail before the first sign-in.
The promotion runs only during user creation. If the account already existed before initialSuperAdminEmail was set correctly, the hook will not run again for that row. Promote the user manually in the database or from another super admin account.
That is expected. UI visibility is not authorization. Always repeat the role check inside the page, route handler, or server action that performs the privileged work.
Use isAdmin(role) unless you intentionally want to exclude super admins. Direct equality with ROLES.ADMIN admits admins only.