Admin area
The built-in administration pages for contact messages, users and the audit log, and how authorization works.
Signed-in users with the admin role get an Admin link in the header that opens /admin.
Everything there is server rendered, works without JavaScript and is authorized twice: in the
page (guardAdminPage()) and in every action (requireAdmin() in src/actions/index.ts).
Both checks read the role from the database rather than from the session cookie cache, so removing the role, banning an account or signing someone out everywhere applies to that person’s next request. See sessions and cookies.
Becoming an administrator#
- Set
ADMIN_EMAILS=you@example.com,ops@example.combefore those people sign up: the database hook insrc/lib/auth.tsassigns the role at creation. - For existing accounts run
pnpm admin:promote user@example.com(or--revoke). - Administrators can promote or demote each other from the users page, but never themselves.
- The last active administrator cannot be demoted, banned or deleted, and cannot delete
their own account from the dashboard: the site would be left without anyone who can reach
/admin.pnpm admin:promote --revokerefuses for the same reason unless you pass--force. pnpm db:seedcreatesadmin@example.com/password123for local development.
Pages#
| Route | What it does |
|---|---|
/admin |
Counts, configuration health (email delivery, contact recipient), failed notifications, latest messages and activity |
/admin/messages |
Inbox with New / Read / Archived / All views, search by sender name or address, 25 per page; archive and delete in bulk |
/admin/messages/[id] |
Full message with read, archive and delivery timestamps; mark read or unread, reply by email, resend, delete |
/admin/users |
Every account with role and status; make or remove admin, ban, unban, sign out everywhere, delete |
/admin/audit |
The last 200 audit entries |
Destructive buttons ask for confirmation through a small processed script (inline handlers
are blocked by the CSP). After a successful action the page redirects with ?notice=<key> and
AdminLayout shows the matching confirmation; only the keys listed in src/lib/admin-page.ts
are ever rendered.
Rendering never changes data#
Opening a new message marks it read by calling the setMessageStatus action from a script in
src/pages/admin/messages/[id].astro, and the inbox links carry data-astro-prefetch="false".
Link prefetching, link previews and security scanners only issue plain GETs, so they leave
messages untouched. Without JavaScript the Mark as read button posts the same action.
Actions#
All admin operations are Astro Actions under server.admin
and are called from plain <form method="POST"> elements, so they are covered by Astro’s
origin check. The pages redirect after a successful action (POST → redirect → GET) so a
refresh never repeats it.
| Action | Backed by |
|---|---|
setMessageStatus |
contact_message.status (+ read_at / archived_at) |
deleteMessage |
Hard delete |
retryMessageDelivery |
deliverContactMessage() in src/lib/contact.ts |
setUserRole |
auth.api.setRole |
banUser / unbanUser |
auth.api.banUser / unbanUser (bans revoke all sessions) |
revokeUserSessions |
auth.api.revokeUserSessions |
removeUser |
auth.api.removeUser |
User operations go through Better Auth’s admin plugin, which checks the caller’s role again
against the database. Every action writes an audit entry and returns a notice key for the
confirmation.
Audit log#
writeAudit() and recordAudit() (src/lib/admin.ts) append to the audit_log table:
actor, action, target and small JSON details. Entries never contain message bodies, passwords
or tokens. The guarantee differs by kind of change:
- Message status changes and deletions run the change and
writeAudit()in one database transaction, so neither exists without the other. - Everything else (notification retries, user operations, exports and account deletions)
goes through Better Auth or an external service and cannot share a transaction. These use
recordAudit(), which is best-effort: the change stands even if the entry cannot be written, and the failure is logged as[audit] could not record entry. Watch for that line in your platform’s logs.
Extending#
- Add a table and a page under
src/pages/admin/, guard it withguardAdminPage()and add a link insrc/layouts/AdminLayout.astro. Do not change data while rendering; use an action. - Add actions under
server.admin, start each handler withawait requireAdmin(context)and return{ notice }with a key fromADMIN_NOTICES. - For finer permissions define custom roles with Better Auth’s access control and pass them
to
admin({ ac, roles })on the server andadminClient({ ac, roles })on the client. - Impersonation is available through
auth.api.impersonateUserif you need support flows.