Oolvay ships with a fully functional user administration system out of the box. Administrators can search accounts, manage roles, suspend access, delete users, email a user directly, and inspect authentication methods and activity history, all without additional setup.
Every administrative action runs as a server action and is checked against the current administrator’s role before it touches the database. The checks are not centralized behind a single policy function. Each action, role changes, deletion, and profile access, carries its own inline rules, and the rules differ from each other in ways this page documents precisely rather than summarizing into one shared table.
| Route | File | Description |
|---|---|---|
/admin/users | app/(protected)/admin/users/page.tsx | User directory and account management |
/admin/users/[id] | app/(protected)/admin/users/[id]/page.tsx | Individual user profile and account details |
The user directory provides operational controls. The profile page provides detailed account inspection and the per-user action buttons (email, suspend, delete).
The directory queries user directly with optional search and role filters combined through and().
0 / 2,000 characters
if (search) {
filters.push(
or(
ilike(user.email, `%${search}%`),
ilike(user.name, `%${search}%`),
ilike(user.username, `%${search}%`)
)
)
}
if (role) {
filters.push(eq(user.role, role))
}Search matches are case-insensitive across email, name, and username, and a role filter narrows the same query further when both are present.
Results are limited and offset using the configured admin page size (in config.site.ts), with totalPages computed from a parallel count() query against the same filter set.
pagination: {
admin: {
usersPageSize: 10,
},
},After the page fetches results, the calling component reorders them so the signed-in administrator’s own row appears first.
const sortedUsers = [
...users.filter((u) => u.id === session.user.id),
...users.filter((u) => u.id !== session.user.id),
]This is a client-visible sort applied after the database query returns, not a property of the query itself.
Role assignment is handled entirely inside actions/change-role.ts. There is no shared permission utility deciding who can change whose role. The rules below are the literal sequence of checks the action runs, in order.
The caller must be an admin or a super admin. Anyone else gets “Unauthorized.”
A caller cannot change their own role, regardless of which role they hold. This is a flat self-check, not a super admin-specific restriction.
The requested newRole must be one of the values defined in
db/types/roles.ts, or the action returns “Invalid role.”
The target user is loaded fresh from the database to read their current role at the moment of the request.
An admin (not a super admin) cannot modify a user whose current role is already admin.
An admin cannot assign the super admin role to anyone, including a standard user being promoted directly to super admin.
If every check passes, the role is updated, a member_role_updated audit
event is written with the user’s previous and new role, and /admin/users
is revalidated.
A super admin has none of the three target-role restrictions above. The only check that applies to a super admin is the universal self-change block in the second step, the same one that applies to an Admin.
| Scenario | Outcome |
|---|---|
| Admin promotes a user to admin | Allowed |
| Admin promotes a user to super admin | Blocked, only a super admin can assign super admin |
| Admin changes another Admin’s role to anything | Blocked, an admin cannot touch an existing admin |
| Admin changes a super admin’s role | Blocked |
| Super admin changes anyone else’s role, to anything | Allowed |
| Super admin changes their own role | Blocked, the same self-check everyone hits |
The member_role_updated event metadata records targetUserName, targetUserEmail, previousRole, and newRole.
Suspension is handled by SuspendUserButton, rendered in the profile header action row alongside the email and delete buttons. The placement of the underlying server action and the props it receives (currentRole, currentUserRole, isBanned) follow the same admin and super admin distinction used by role changes and deletion.
Suspending an account does more than set a flag. Active sessions are revoked immediately, so a suspended user loses access through any session they were already holding, not just on their next sign-in attempt.
A suspension can carry a banReason and a banExpires date. Both are surfaced in the profile header. When banExpires is absent, the suspension badge reads as indefinite. When banReason is present, it appears in a tooltip alongside the expiration detail.
<TooltipContent className="max-w-64 space-y-1 text-xs">
{user.banReason && (
<p>
<span className="font-medium">Reason: </span>
{user.banReason}
</p>
)}
<p>
{user.banExpires
? `Until ${formatDateTime(user.banExpires)}`
: "Indefinite suspension"}
</p>
</TooltipContent>The recorded audit events are user_suspended and user_unsuspended.
Oolvay comes pre-configured with a send email button that lets an administrator email any user directly from their profile page.
The button itself is SendEmailButton, rendered as the first action in the button row, before suspend and delete. Clicking it opens SendEmailDialog, a form with a subject field and a message field, both required.
The server action behind this feature, sendUserEmailAction, checks only the caller’s own role with isAdmin(). It does not check the target user’s role at all.
if (!isAdmin(currentUser.role as Role)) {
return { success: false, error: "Forbidden" }
}This means any admin or super admin can email any other user, including another admin or another super admin. There is no equivalent of the target-role restrictions found in Role management or Account deletion. Emailing a user is the one administrative action on this page with no hierarchy between admin and super admin, and no exemption protecting higher-privileged accounts from being emailed by a lower one.
The server action re-validates the form with its own Zod schema, independent of the dialog’s client-side validation, and looks up the recipient’s email address fresh from the database by userId rather than trusting whatever was passed into the dialog.
const foundUser = await db.query.user.findFirst({
where: eq(user.id, userId),
columns: { email: true, name: true },
})
await sendEmail({
to: foundUser.email,
from: `${siteConfig.emails.contact.sender} <${siteConfig.emails.contact.fromEmail}>`,
subject,
body: `<p>${message.replace(/\n/g, "<br />")}</p>`,
})The message is sent as plain text wrapped in a single paragraph, with line breaks converted to <br /> tags. It is not run through a templating system the way dunning emails are. The sender address comes from siteConfig.emails.contact, the same configured contact address used elsewhere in the application, not a dedicated admin-notification address.
Unlike role changes, suspensions, and deletions, sending an email through this action does not write to the audit log. logEvent() is never called. A successful send leaves no record in audit_log, and the only place a failure is recorded is in the application’s own error logging, not in anything visible from the admin interface.
Deletion is handled by actions/delete-user.ts. Before anything is removed, the action runs four independent checks, all of which must pass.
A super admin has neither of the last two restrictions and can delete any account other than their own.
Once the checks pass, deletion itself happens in a fixed order worth calling out on its own.
A user_deleted audit event is written first, capturing the user's name,
email, and role at the time of deletion.
Only after the audit event is written does the action call
auth.api.removeUser() to actually remove the account.
/admin/users is revalidated on success.The audit log entry is written before the Better Auth removal call, not after. If auth.api.removeUser() throws, the catch block logs the failure to Sentry and returns Failed to delete user, but the user_deleted audit event written in the prior step is not rolled back. A failed deletion attempt can therefore leave a user_deleted record for a user who, in fact, still exists. Anyone reconciling the audit log against the live user table should account for this gap rather than assume the two always agree.
Selecting a user from the directory opens /admin/users/[id], which loads the target’s full record, linked accounts, passkeys, and audit history through getUserById().
getUserById() includes a check that has no equivalent anywhere else in this system.
if (foundUser.id === currentUser.id) {
return { success: false, error: "Forbidden", code: "FORBIDDEN" }
}If an administrator’s own ID is requested, the action returns FORBIDDEN regardless of role. This applies to super admins as well as Admins. There is no self-profile view through /admin/users/[id].
Separately from the self-check above, an admin (not a super admin) requesting any profile whose role is not the standard user role also receives FORBIDDEN.
if (currentUser.role === ROLES.ADMIN && foundUser.role !== ROLES.USER) {
return { success: false, error: "Forbidden", code: "FORBIDDEN" }
}A super admin has no equivalent restriction and can load any profile other than their own.
The page itself maps getUserById()’s failure codes to two distinct outcomes. A FORBIDDEN code triggers Next.js’s unauthorized(). Any other failure, including NOT_FOUND, falls through to notFound().
UserProfileHeader renders identity, status, and the action row in a single component.
| Field | Source | Notes |
|---|---|---|
| Avatar | user.image, falling back to the first letter of user.name | The fallback is a colored circle, not a placeholder image |
| Name | user.name | |
| Username | user.username | Rendered with a leading @ |
user.email | A check icon appears beside it when user.emailVerified is true | |
| Role | RoleSelect | The control itself enforces who can change it, per Role management |
| Suspension badge | user.banned | Only rendered when true. Hovering shows reason and expiration |
| Online indicator | Derived, see below | A colored dot overlaid on the avatar |
| Joined, last login, last seen | user.createdAt, user.lastLoginAt, user.lastActiveAt | Rendered as a secondary line below the identity row |
Presence is computed in the component itself, not read from a stored boolean.
const isOnline =
user.lastActiveAt !== null &&
user.lastActiveAt !== undefined &&
now.getTime() - new Date(user.lastActiveAt).getTime() <
siteConfig.activity.onlineThresholdMsThe threshold itself comes from the same configuration file referenced in Pagination.
activity: {
heartbeatIntervalMs: 10 * 60 * 1000, // 10 minutes, how often the client pings
onlineThresholdMs: 15 * 60 * 1000, // 15 minutes, grace window before showing as offline
},A user with no lastActiveAt value at all is treated as offline, and the secondary line falls back to “Never active” rather than a last-seen timestamp.
Three buttons render in a fixed order, all positioned in the header rather than elsewhere on the page.
SendEmailButton, see Emailing a userSuspendUserButton, see Account suspensionDeleteUserButton, see Account deletionAll three receive currentRole and currentUserRole as props, which is how each button’s own internal logic enforces the same admin and super admin distinctions described in Role management and Account deletion without duplicating the server-side checks. The props decide what the button renders or allows the administrator to attempt. The server actions decide what is actually permitted, independently of what the button shows.
getUserById() loads three related record sets alongside the user itself, in parallel.
const [accounts, auditLogs, passkeys] = await Promise.all([
db.select().from(account).where(eq(account.userId, foundUser.id)),
db
.select()
.from(auditLog)
.where(
or(
eq(auditLog.actorUserId, foundUser.id),
eq(auditLog.targetUserId, foundUser.id)
)
)
.orderBy(desc(auditLog.createdAt))
.limit(50),
db.select().from(passkey).where(eq(passkey.userId, foundUser.id)),
])accounts covers every linked authentication method, OAuth providers, password, and any other account-table entry. passkeys is loaded separately from its own table. auditLogs returns up to fifty records, ordered newest first, matching the user as either actor or target, meaning the feed mixes actions the user took themselves with administrative actions taken against them.
There is no single access-control module governing user management. lib/auth/permissions.ts exports two narrow helpers.
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
}These two functions only answer "is this role at least an admin" and "is this role a super admin." Every other rule described on this page, who can modify whom, who can view whom, who can delete whom, is written inline inside the individual server action that enforces it, and the three actions do not call a shared rule set to do so.
lib/auth/permissions.ts also defines the access-control roles consumed by Better Auth’s admin plugin.
export const adminRole = ac.newRole({
...adminAc.statements,
})
export const superAdminRole = ac.newRole({
...adminAc.statements,
user: ["impersonate-admins", ...adminAc.statements.user],
})impersonate-admins is the single explicit capability that separates the super admin role from the admin role at the access-control layer. Every other admin-versus-super-admin distinction documented on this page lives in the hand-written if checks inside change-role.ts, delete-user.ts, and get-user-by-id.ts, not in this file.