Manual Recipes Admin Roles

Admin Roles

elements man recipes/admin-roles Read as markdown

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

elements create migration 'add users' -tables=users

app/migrations/<timestamp>-add-users.migration.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:

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

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:

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<AdminUser>(
    `select id, handle, role from users order by createdAt desc`,
  ).all();
}

app/pages/admin/index.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<AdminUser>(
    `select id, handle, role from users order by createdAt desc`,
  ).all();

  return new admin({ users });
}

app/pages/admin/template.html:

import "./style.css";
import { AdminUser, setRole } from "./services";

<html class="admin" (users: AdminUser[])>
  <h1>users</h1>

  <ul class="users">
    <li e:for={u of users}>
      <span class="handle">{u.handle}</span>
      <span class="role">{u.role}</span>

      <button e:if={u.role === 'user'}
              onclick={() => users = setRole(u.id, 'admin')}>
        promote
      </button>

      <button e:else
              onclick={() => users = setRole(u.id, 'user')}>
        demote
      </button>
    </li>
  </ul>
</html>

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:

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 <a e:if={session.isLoggedIn() && currentUserIsAdmin}>.... Pass currentUserIsAdmin: boolean from the route handler. Don't call isUserAdmin() from the template; it's server-only.