Admin Roles
elements man recipes/admin-roles Read as markdownRole-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.
setRolerequires an existing admin to call it. Bootstrap by runningupdate 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 aconst ROLES = ['user', 'editor', 'admin'] as constand compare by index inside auserHasRoleOrThrow(role)helper. - Per-row permissions. Roles gate types of action; per-row ownership gates
specific rows. The two compose: call
isUserAdminOrThrow()and thenif (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}>.... PasscurrentUserIsAdmin: booleanfrom the route handler. Don't callisUserAdmin()from the template; it's server-only.