Hands-on

Admin Roles & Authorization

Configure read-only and read/write admin roles, protect admin APIs, and understand the authentication pipeline used by this template.

Authentication & Admin

This template uses Better Auth + Drizzle for authentication and sessions. A simple, DB‑backed RBAC layer adds two admin roles on top of regular users. Roles are stored in the database only (no env-based elevation).

Basics: Authentication vs. Authorization

  • Authentication: Proves who you are (sign‑in/sign‑up). The result is a session.
  • Authorization: Decides what you can do after sign‑in. In this template, a role on the users table controls access.

Roles we use:

  • user – default for everyone.
  • admin_ro – read‑only admin (can view admin data, no writes).
  • admin_rw – read + write admin (includes granting credits and other admin actions).

A simple analogy

At an airport, showing your ID to security is authentication (proving identity). Presenting your boarding pass at the gate is authorization (verifying you’re allowed on that flight).

Quick comparison

TopicAuthenticationAuthorization
PurposeVerify identity (who you are)Verify permissions (what you can do)
WhenBefore authorizationAfter successful authentication
Typical inputsCredentials (password, email link, social sign‑in)Policies/roles (e.g., user, admin_ro, admin_rw)
OutputA session/identityAllowed actions and protected routes
Common standards (general)OpenID Connect (OIDC), ID tokensOAuth 2.0, access scopes

In this template: we use secure, HttpOnly session cookies on the server (not front‑end tokens) and a simple role field on users to drive authorization checks.

What is Better Auth?

Better Auth is a lightweight, TypeScript‑first auth library for Next.js.

  • Storage: Uses Drizzle ORM + Postgres via the adapter configured in src/lib/auth.ts.
  • Data model: users, sessions, accounts, verifications in src/db/schema.ts.
  • Sessions: Sets an HttpOnly cookie and persists session rows in the sessions table; server routes read the session via auth.api.getSession({ headers }).
  • Extra fields: We expose uuid and role on the session through additionalFields for easy server checks.

Secrets and URLs (entry‑level)

  • BETTER_AUTH_SECRET: A long, random server‑only secret used to sign/verify auth tokens and secure cookies. Generate one time and keep it private.
    • Generate: openssl rand -base64 32
    • Rotation note: Changing it logs out all users because existing sessions can no longer be verified.
  • BETTER_AUTH_URL: Server base URL used by the auth library (usually your site URL).
  • NEXT_PUBLIC_AUTH_BASE_URL: Client‑side base (keep same as site URL in most cases).
  • Cookies are HttpOnly and secure in production (see advanced.defaultCookieAttributes in src/lib/auth.ts).

If sign‑in seems to work only once or sessions vanish after restart, double‑check these envs and restart the dev server.

Auth Pipeline Overview

  • Library: Better Auth (src/lib/auth.ts) with Drizzle adapter to Postgres (src/db/schema.ts).
  • Users are stored in users. Sessions, accounts, and verifications live in sessions, accounts, verifications.
  • The session is fetched in App Router via auth.api.getSession({ headers }).
  • Custom user fields are exposed to the session via additionalFields (we include uuid and role).

Roles

  • user: default for everyone.
  • admin_ro: read‑only admin; can list users, list orders, and view a user’s credit summary.
  • admin_rw: read + operation admin; includes all read‑only powers and can grant credits to users (trans_type = system_add).

Table changes (migration 0001_add_user_role.sql) add users.role with default user.

How Authorization Works

  • Helpers in src/lib/authz.ts derive the current user’s role from the session/DB only:
    • requireAdminRead() → allows admin_ro/admin_rw.
    • requireAdminWrite() → allows admin_rw.

Admin APIs

All endpoints are server‑only and role‑guarded:

  • GET /api/admin/users → list users. Query: page, limit.
  • GET /api/admin/orders → list paid orders. Query: page, limit.
  • GET /api/admin/users/:uuid/credits → credit summary + recent ledger.
  • POST /api/admin/credits/grant → grant credits as system_add.
// POST /api/admin/credits/grant body
{
  "userUuid": "<target-user-uuid>",
  "credits": 100,
  "expiredAt": null,     // optional ISO string or null
  "orderNo": "",         // optional
  "note": "promo Q4"     // reserved for future auditing
}

Setup Steps

  1. Migrate DB

Run migrations to add the role column:

pnpm drizzle-kit generate --config src/db/config.ts
pnpm drizzle-kit migrate --config src/db/config.ts

This repo includes src/db/migrations/0001_add_user_role.sql so a generate is optional if you don’t change the schema further.

  1. Verify endpoints
  • Call GET /api/admin/users as an admin to confirm access.
  • Call POST /api/admin/credits/grant with a target userUuid to add credits; the ledger shows trans_type = system_add.

Changing Roles

Update roles in the database (authoritative source):

  • SQL (direct):
    update users set role = 'admin_rw' where uuid = '<uuid>';
  • Update via code helper:
    • updateUserRole(uuid, 'admin_ro' | 'admin_rw' | 'user') in src/models/user.ts.

Notes & Tips

  • The existing dev endpoint POST /api/account/credits/grant is for local testing; lock it down or remove in production in favor of the admin endpoint above.
  • Consider adding an audit log table if you need to track who granted credits (operator UUID, timestamp, reason).
  • Protect admin accounts with MFA/SSO and shorter session TTLs.
  • Only expose admin UI/pages after guarding them on the server (check role in the server component and redirect if unauthorized).
  • Roles are not controlled by environment variables; use DB changes for assignment/revocation.

Social Sign‑In (Google)

  • The Google provider is configured in src/lib/auth.ts under socialProviders.google and reads GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET from .env.
  • Ensure these envs are set: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, plus BETTER_AUTH_URL and NEXT_PUBLIC_AUTH_BASE_URL (e.g. http://localhost:3000 in development).
  • In Google Cloud Console → Credentials, add the Authorized redirect URI:
    • http://localhost:3000/api/auth/callback/google (dev)
    • https://your-domain.com/api/auth/callback/google (prod)
  • The login screen at /[locale]/login shows a “Continue with Google” button that starts the OAuth flow.

Email & Password

This template enables Better Auth’s email & password authenticator and includes localized “Forgot password” and “Reset password” pages.

  • Server config lives in src/lib/auth.tsemailAndPassword is enabled and wired with sendResetPassword and onPasswordReset callbacks.
  • Emails are sent via Resend using src/services/email/send.ts and the template src/services/email/templates/reset-password.tsx.
  • UI routes are localized under the App Router:
    • /[locale]/forgot-password → request reset link
    • /[locale]/reset-password?token=... → set new password
  • All UI strings come from messages/*.json under the auth.* namespace (see messages/en.json).

Client usage

// Request a reset link
await authClient.requestPasswordReset({
  email: "john.doe@example.com",
  redirectTo: "/en/reset-password", // locale-aware in app
});

// Reset password (token comes from the reset link)
await authClient.resetPassword({
  newPassword: "newpassword1234",
  token,
});

Pages & Components

  • Components: src/components/auth/forgot-password-form.tsx, src/components/auth/reset-password-form.tsx (client components; i18n with next-intl).
  • Pages: thin wrappers at src/app/[locale]/(auth)/forgot-password/page.tsx and src/app/[locale]/(auth)/reset-password/page.tsx render the components.

Environment

  • Required: BETTER_AUTH_URL, RESEND_API_KEY, EMAIL_FROM.
  • Optional: NEXT_PUBLIC_AUTH_BASE_URL for client in edge cases.

Manual check

  1. Visit /[locale]/forgot-password, submit a known user email.
  2. Open the email and follow the link to reset-password?token=....
  3. Set a new password and sign in at /[locale]/login.