QUESTPIE
Admin

Authentication

The admin admits one kind of session. A Better Auth session whose user holds the admin role. This page covers where that role comes from, what checks it, and how the first admin gets created.

View markdown

Nobody has an account yet and the panel needs one. Then a second person joins and needs one too. Both have a built-in answer. Neither one is a row you insert by hand.

The rule

The panel reads one field. session.user.role.

The session/admin
role: "admin"In.
role: "user"Redirected to the login page.
No role at allRedirected to the login page.
No sessionRedirected to the login page.
A banned userBetter Auth refuses to open the session.

A signed-in user is not an admin user. Every check in the panel draws that line the same way. The client guard sends the browser back to the login page. The panel boots from one route, getAdminConfig. That route allows the admin role only. Your own code calls the helpers in Protecting your own routes.

Turn it on

Register the module, then declare your auth config.

src/questpie/server/modules.ts
import { adminModule } from "@questpie/admin/modules/admin";

export default [adminModule] as const;
src/questpie/server/config/auth.ts
import { admin, bearer } from "better-auth/plugins";
import { authConfig } from "questpie/app";

export default authConfig({
	plugins: [admin(), bearer()],
	emailAndPassword: {
		enabled: true,
		requireEmailVerification: false,
	},
});
questpie generate

The generator folds the two together and rewrites AppSession and AppSessionUser. After that session.user.role is typed everywhere. In route handlers, hooks, access rules and services.

The admin() plugin contributes role, so keep it on. It also stamps role: "user" on every user Better Auth creates, unless the caller names a role. bearer() is what lets the panel's own user actions forward your session as a token.

What runs with no `config/auth.ts` at all

adminModule pulls in starterModule. That module already ships admin(), bearer() and openAPI(), with email and password on and email verification required. Your own file merges on top. Plugins dedupe by id, so repeating one is safe.

The first admin

The login page asks the server one question before anything else. Does any user hold the admin role? If none does, it sends you to /admin/setup.

Setup wants a name, an email and a password of at least eight characters. It creates the user through Better Auth, sets role to "admin", and marks the email verified. That last step is why a strict requireEmailVerification cannot lock you out of your own panel. Then it returns you to the login page. You are not signed in yet. Sign in.

Once one admin exists, setup is shut. createFirstAdmin counts admin users first and refuses when the count is above zero. The route itself is public, and that count is the only thing guarding it.

The auth pages you get

PathWhat it is
/admin/loginSign in.
/admin/setupThe first admin, while none exists.
/admin/forgot-passwordAsks for a reset email.
/admin/reset-passwordCompletes the reset from the link.
/admin/oauth/consentApproves scopes for an OAuth client.

The first four render on their own. No sidebar, no header, no session needed. The consent screen is different. It sits behind the same admin check as the rest of the panel. MCP OAuth covers what it is for.

All five share one layout. To replace it, put a component at src/questpie/admin/components/admin-auth-layout.tsx. The generator registers it under adminAuthLayout, and every page in the table picks it up.

Forgot password does nothing until you wire the email

Better Auth rejects the request with RESET_PASSWORD_DISABLED unless emailAndPassword.sendResetPassword is set. QUESTPIE does not set it for you. Add it in config/auth.ts and send through your own email adapter.

Everyone after the first

Setup is shut, so the next admin comes from the panel itself. Open /admin/collections/user. The list header has a Create user button. It asks for a name, an email, a password and a role. Pick the admin role and that person is an admin.

The button is not a plain insert. It calls Better Auth's createUser, so the password is hashed the way a sign-up hashes it. It forwards your own session as a bearer token. That is why bearer() has to stay in your plugin list.

Better Auth leaves emailVerified false on a user made this way. So keep requireEmailVerification: false, as the config above does, or send a verification email. Otherwise the new admin cannot sign in.

A user's own form carries a Reset password action, calling setUserPassword. Reach for it when someone is locked out and your project has no reset email yet.

`InvitePage` and `AcceptInvitePage` do not work

Both are exported from @questpie/admin/client, and neither is registered as a page. They call authClient.admin.createInvitation, getInvitation and acceptInvitation. Better Auth's admin() plugin has no such endpoints. Invitations live in its organization() plugin.

Checking the role yourself

Inside a collection or a global, an access rule reads the session directly.

.access({
	update: ({ session }) => session?.user?.role === "admin",
	delete: ({ session }) => session?.user?.role === "admin",
})

Do not write (session?.user as any)?.role. The field is on the generated type already. If it is missing, your module list or your config/auth.ts is wrong. A cast hides that instead of fixing it.

Where each topic lives

TopicPage
Guarding a route or a page of your ownProtecting your own routes
What the auth tables expose, and what they shutIdentity tables
An auth write and a verification job as oneAuth writes that queue a job
Rules that allow, deny or filter a collectionAccess control
Mounting the panel, branding and navigationConfiguration
The consent screen and the scopes behind itMCP OAuth
Who changed what, once they are inAudit

Next

Protecting your own routes covers the guards in @questpie/admin/server, and the one case where they let a request through instead of stopping it.

On this page