Admin area
Manage users, organizations, feedback, announcements and the newsletter from the built-in platform admin area.
Launch Now ships a platform admin area at /admin. Platform admins can browse every user and organization, edit or ban accounts, impersonate users to debug issues, read in-app feedback, broadcast notifications and send the newsletter.
Platform admins are different from organization admins. A platform admin has role = "admin" on the user row (Better Auth's admin plugin). An organization admin has role = "admin" on a member row and only manages that organization. This page is about platform admins.
How it works
The admin area is a group of routes under app/(app)/admin. They share the signed-in app layout (sidebar, header, notification bell) and add one guard on top.
| Route | File | What it shows |
|---|---|---|
/admin/users | app/(app)/admin/users/page.tsx | Every account, with role, status, last activity and actions |
/admin/users/[userId] | app/(app)/admin/users/[userId]/page.tsx | One user: profile, sessions, organizations, subscription, danger zone |
/admin/organizations | app/(app)/admin/organizations/page.tsx | Every organization, with plan and member count |
/admin/organizations/[slug] | app/(app)/admin/organizations/[slug]/page.tsx | One organization: name, URL, members, subscription, delete |
/admin/feedbacks | app/(app)/admin/feedbacks/page.tsx | Feedback sent from the in-app dialog |
/admin/notifications | app/(app)/admin/notifications/page.tsx | Broadcast composer and history |
/admin/newsletter | app/(app)/admin/newsletter/page.tsx | Newsletter composer and history |
The rest of the code lives here:
lib/admin-actions.ts: server actions for users and organizations.lib/feedback-actions.ts: server actions for feedback.features/admin/*: tables, detail views, dialogs and the impersonation banner.components/feedback-dialog.tsx: the feedback dialog users open from the app header.components/layouts/nav-configs/admin.tsx: the admin sidebar links (navAdmin).
Access control
Access is checked at two levels. The admin layout redirects anyone who isn't a platform admin to /dashboard:
export default async function RouteLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getSession()
if (!session?.user || session.user.role !== "admin") {
redirect("/dashboard")
}
return <>{children}</>
}Every admin server action also checks the role itself, so a direct call from a non-admin fails with Not authorized:
function assertAdmin(ctx: { user: { role?: string | null } }) {
if (ctx.user.role !== "admin") throw new ActionError("Not authorized")
}Good to know: The layout only hides pages. The check inside each action is what protects the data, so keep it in any admin action you add.
The sidebar shows an Admin entry in the Workspace section only when session.user.role === "admin" (components/layouts/sidebar/sidebar-context-nav.tsx).
Becoming an admin
There is no environment variable or seed that promotes the first admin. New users get no role, and only an existing admin can promote someone from the UI. To create your first admin, set the role column of your user row to admin directly in the database.
Sign up with your own account
Start the app and sign in once so your user row exists.
Set the role in the database
Open Drizzle Studio and edit the role column of your row in the user table:
pnpm db:studioOr run SQL against your database (Neon SQL editor, psql, …):
update "user" set role = 'admin' where email = 'you@example.com';Reload the app
Sign out and back in, or reload. The Admin entry now appears in the sidebar.
After that, promote or demote other users from the UI with Make admin / Remove admin. Both call toggleAdminRole, which sets role to "admin" or "user".
assertNotSelf blocks it. Keep at least two admins so one can recover the other.Users
/admin/users lists every account, newest first, using listUsers. The action selects a safe set of columns (never password hashes or tokens) and the most recent session, used for the Last active column.
The table supports:
- Search by name, email or ID.
- Faceted filters for Role and Status.
- A row menu: View details, Copy user ID, Impersonate, Make admin / Remove admin, Ban / Unban, Sign out everywhere and Delete user. The destructive entries are hidden on your own row.
Every action opens the same confirmation dialog, features/admin/user-action-dialog.tsx, shared with the detail page.
Creating a user
The Create user button opens features/admin/create-user-sheet.tsx. It takes a name, an email, a password (at least 8 characters) and an optional avatar, then calls Better Auth's authClient.admin.createUser with role: "user". The avatar is uploaded first with the uploadImage action. The user can sign in right away with that email and password.
User detail
/admin/users/[userId] renders features/admin/user-detail-profile.tsx, which loads everything with getUserById:
| Tab | Content |
|---|---|
| Profile | Avatar, name and email cards, plus sign-in methods (linked accounts) |
| Sessions | Active sessions and past ones from session_log, with IP, device and an "Impersonated" marker. Revoke one or all |
| Organizations | Memberships with role and join date |
| Subscription | The personal subscription, preferring an active, trialing or past_due one |
| Danger zone | Make/remove admin, ban/unban, delete |
Editing the email with updateUser does not send a verification email. Avatars go through updateUserAvatar, which stores the image with uploadPublicImage from lib/storage.ts (PNG, JPG or WebP, up to 2MB, MAX_IMAGE_UPLOAD_BYTES).
Banning and deleting
- Ban (
toggleBanStatus) deletes all the user's sessions, then setsbanned = trueandbanReason = "Banned by admin". Better Auth refuses new sign-ins for banned users until you unban them. - Sign out everywhere (
revokeUserSessions) deletes every session for the user and returns the count. - Delete (
deleteUser) deletes theuserrow. Sessions, accounts and memberships cascade. Organizations they own stay in place.
Impersonation
Impersonation lets you browse the app as another user to reproduce a bug. It uses Better Auth's admin plugin:
const { error } = await authClient.admin.impersonateUser({
userId: user.id,
})
// ...
// Full reload so every server component picks up the new session.
window.location.assign("/dashboard")The Impersonate entry is hidden for admins, banned users and yourself. The confirmation dialog tells the admin the session lasts up to an hour and that everything is recorded against the user's account.
While impersonating, the session has an impersonatedBy value. The app layout checks it and renders a sticky banner above the header:
{session.session.impersonatedBy ? (
<ImpersonationBanner
name={session.user.name}
email={session.user.email}
/>
) : null}The banner (features/admin/impersonation-banner.tsx) shows "Viewing as …" and a Stop impersonating button. It calls authClient.admin.stopImpersonating() and then does a full reload to /admin/users, because every cached query belongs to the impersonated user.
Good to know: Impersonated sessions don't trigger the "new sign-in" security notification (notifyNewSignIn in lib/auth.ts returns early when impersonatedBy is set).
Organizations
/admin/organizations lists every organization with listOrganizations: member counts and subscriptions are loaded in two batched queries. The table supports search by name or URL, a Plan filter, and links to the detail page.
/admin/organizations/[slug] loads the organization, its members and its subscription with getOrganizationAdmin, then shows four sections:
- General: rename the organization or change its URL. The URL field checks availability live with
checkOrganizationSlugAdmin, andupdateOrganizationAdminchecks again on save (including a unique-violation race). - Members: every member with role and join date.
- Subscription: plan, status, period, trial end, seats and billing interval.
- Danger zone: delete the organization. You must type the slug to confirm.
deleteOrganizationAdmin refuses to delete an organization whose subscription is active, trialing or past_due. Otherwise it deletes subscriptions, members, invitations and the organization in a single transaction.
Feedback
Signed-in users send feedback from the message icon in the app header. components/feedback-dialog.tsx is rendered in app/(app)/layout.tsx and can also be opened in controlled mode from the command menu (components/layouts/sidebar/search-command.tsx):
type FeedbackDialogProps = {
/** Controlled mode, e.g. opened from the command menu. */
open?: boolean
onOpenChange?: (open: boolean) => void
/** Render the icon trigger button. Defaults to true. */
showTrigger?: boolean
}The dialog has a message field and an optional mood picker (very-happy, happy, bad-mood, cry), stored as the category. On submit it calls createFeedback, which:
- Inserts a
feedbackrow with the user's ID, email and name, andstatus: "new". - Sends an
admin.feedback_receivednotification to all platform admins with a 140-character excerpt. See Notifications.
/admin/feedbacks lists feedback with listFeedback. The table supports search by user or email, a Status filter, single and bulk delete (deleteFeedback, deleteFeedbackBulk) and status changes with updateFeedbackStatus. Statuses are new, read, acknowledged and resolved.
Notifications and newsletter
Two admin pages send messages to users:
/admin/notificationssends in-app announcements to everyone, admins, one organization or one user, optionally by email. See Notifications./admin/newsletterwrites and schedules newsletter broadcasts through Resend. See Newsletter.
Customizing
Adding an admin page
- Create the route under
app/(app)/admin/your-page/page.tsx. The admin layout already guards it. - Add a link to
navAdminincomponents/layouts/nav-configs/admin.tsx:
export const navAdmin: NavItem[] = [
{
title: "Users",
url: "/admin/users",
icon: Users,
},
// ...
]- Check the role in every server action the page uses, like
assertAdmindoes.
Good to know: pnpm resource:generate can scaffold an admin-only CRUD page under /admin/... and add it to navAdmin for you. See Resource generator.
Changing the feedback moods
Edit the moods array in components/feedback-dialog.tsx. Each id must match an icon in components/svgs/svgs.