Skip to content
Strata

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.com before those people sign up: the database hook in src/lib/auth.ts assigns 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 --revoke refuses for the same reason unless you pass --force.
  • pnpm db:seed creates admin@example.com / password123 for 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 with guardAdminPage() and add a link in src/layouts/AdminLayout.astro. Do not change data while rendering; use an action.
  • Add actions under server.admin, start each handler with await requireAdmin(context) and return { notice } with a key from ADMIN_NOTICES.
  • For finer permissions define custom roles with Better Auth’s access control and pass them to admin({ ac, roles }) on the server and adminClient({ ac, roles }) on the client.
  • Impersonation is available through auth.api.impersonateUser if you need support flows.