# Admin Roles Role-gated admin pages and rpc. The users table has a `role` column of an enum type. A shared helper reads that column on every request and throws if the caller is not an admin. The admin page route and the admin rpc both call the helper at the top of the function. The signin and signup pages come from the authentication recipe; this recipe layers role gating over them. ## Migration ```bash elements create migration 'add users' -tables=users ``` `app/migrations/-add-users.migration.sql`: ```sql -- add users -- Auto-update updatedAt on row changes. create or replace function touchUpdatedAt() returns trigger language plpgsql as $$ begin new.updatedAt = now(); return new; end; $$; create type userRole as enum ('user', 'admin'); create table users ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), handle text not null unique, passwordHash text not null, role userRole not null default 'user' ); create trigger usersTouchUpdatedAt before update on users for each row execute function touchUpdatedAt(); ``` The `userRole` Postgres enum type restricts the column to `'user'` and `'admin'`. The database rejects any other value at insert or update time. To add a role later, run `alter type userRole add value 'editor'` in a follow-up migration. ## Admin helpers `app/shared/services/admin.ts`: ```ts import { sql, session, ForbiddenError } from "@elements/app"; export function isUserAdmin(userId: string): boolean { return !sql(`select 1 from users where id = ${userId} and role = 'admin'`).empty(); } export function isUserAdminOrThrow() { session.isLoggedInOrThrow(); if (!isUserAdmin(session.getOrThrow('userId'))) { throw new ForbiddenError("admin access required"); } } ``` `isUserAdmin` returns a boolean for callers that need to branch on role. `isUserAdminOrThrow` is the guard for admin routes and rpc. It first calls `session.isLoggedInOrThrow()`, which throws (401) if the request has no session. If the request is signed in but the user is not an admin, it throws `ForbiddenError` (403). The two errors carry different meanings to the browser, so distinguishing them matters: `AuthError` says the user needs to sign in, `ForbiddenError` says the user is signed in but lacks the role. Both functions call `sql`, which is a server-only primitive, so the Elements build pipeline keeps both functions out of the browser bundle automatically. No build-tag annotation is needed on user code. ## Admin page ```bash elements create page admin ``` That writes `app/pages/admin/{index.ts, template.html, style.css, test.ts}` and prints the `/admin` route line to add to `index.ts`. Add a `services.ts` sibling by hand for the `setRole` rpc and the `AdminUser` interface. The route in `index.ts` and the view in `template.html` import from `./services`. `app/pages/admin/services.ts`: ```ts import { sql } from "@elements/app"; import { isUserAdminOrThrow } from "#app/shared/services/admin"; export interface AdminUser { id: string; handle: string; role: string; } /** @rpc */ export function setRole(userId: string, role: 'user' | 'admin'): AdminUser[] { isUserAdminOrThrow(); sql(`update users set role = ${role} where id = ${userId}`); return sql( `select id, handle, role from users order by createdAt desc`, ).all(); } ``` `app/pages/admin/index.ts`: ```ts import { sql } from "@elements/app"; import { isUserAdminOrThrow } from "#app/shared/services/admin"; import admin from "./template"; import { AdminUser } from "./services"; export default function route(req, res) { isUserAdminOrThrow(); let users = sql( `select id, handle, role from users order by createdAt desc`, ).all(); return new admin({ users }); } ``` `app/pages/admin/template.html`: ```html import "./style.css"; import { AdminUser, setRole } from "./services";

users

  • {u.handle} {u.role}
``` `isUserAdminOrThrow()` appears on the route handler and on the rpc. The route guard blocks the page render. The rpc guard blocks the operation. Each layer runs independently, so both carry the same check. The rpc returns the refreshed list. The template reassigns `users` from the return value, the `e:for` re-renders, and the new role shows up without a page reload. The template does not check `isUserAdmin` anywhere. By the time the template renders, `isUserAdminOrThrow()` has already let the request through or thrown. A non-admin never sees the rendered HTML. ## Routes Register the page in `index.ts`: ```ts import admin from "#app/pages/admin"; // ... app.route("/admin", admin); ``` ## Notes - **Creating the first admin.** `setRole` requires an existing admin to call it. Bootstrap by running `update users set role = 'admin' where handle = 'you'` once in psql, or seed it in a migration. After that, the first admin can promote others through the UI. - **Defense in depth.** Protect both routes and rpc functions. Routes guard the page render; rpc guard the operation itself. Apply the same role check at the top of each. - **More than two roles.** Add a value to the enum with `alter type userRole add value 'editor'` and update the helpers. For role hierarchies (admin can do everything an editor can), order them in a `const ROLES = ['user', 'editor', 'admin'] as const` and compare by index inside a `userHasRoleOrThrow(role)` helper. - **Per-row permissions.** Roles gate types of action; per-row ownership gates specific rows. The two compose: call `isUserAdminOrThrow()` and then `if (row.ownerId !== session.getOrThrow('userId')) { throw new AuthError(); }` for an admin route that still only edits rows the admin owns. - **Reactive hide in templates.** A signed-in non-admin browsing the public site can have admin-only links hidden with `...`. Pass `currentUserIsAdmin: boolean` from the route handler. Don't call `isUserAdmin()` from the template; it's server-only.