Manual Recipes Public and Admin Views

Public and Admin Views

elements man recipes/public-admin-views Read as markdown

Two routes over the same events data. The public route at /events lists upcoming events without auth. The admin route at /admin/events shows the same data plus a create form and per-row delete actions. Both pages bind to one shared LiveTable. Mutations are gated by the LiveTable's insert.auth, update.auth, and delete.auth callbacks: only admins succeed; the public page never tries to mutate, but if it did the LiveTable would reject the call server-side.

The recipe demonstrates the auth model where everyone reads through the same live primitive and a small set of users mutates it. The admin gate lives on the LiveTable (defense in depth) and on the admin route (page-render gate). The public route is unauthenticated and unauthorized.

Migration

elements create migration 'add events' -tables=events

app/migrations/<timestamp>-add-events.migration.sql:

-- add events

-- Auto-update updatedAt on row changes.
create or replace function touchUpdatedAt()
returns trigger
language plpgsql
as $$
begin
  new.updatedAt = now();
  return new;
end;
$$;

create table events (
  id uuid primary key default uuidGenerateV7(),
  createdAt timestamptz not null default now(),
  updatedAt timestamptz not null default now(),
  title text not null,
  description text not null default '',
  eventDate timestamptz not null
);

create index eventsEventDateIdx on events (eventDate asc);

create trigger eventsTouchUpdatedAt
  before update on events
  for each row execute function touchUpdatedAt();

The eventsEventDateIdx backs the where eventDate >= now() filter and the order by eventDate asc clause. description defaults to the empty string so the column stays not-null with no default text noise.

Services

Two pages consume the events LiveTable: the public /events route and the admin /admin/events route. Because more than one page imports it, the file lives in app/shared/services/ rather than colocated with either page. If only the admin page needed it, the file would be app/pages/admin-events/services.ts instead.

app/shared/services/events.ts:

import { LiveTable, sql } from "@elements/app";
import { isUserAdmin } from "#app/shared/services/admin";

export interface Event {
  id: string;
  createdAt: Date;
  updatedAt: Date;
  title: string;
  description: string;
  eventDate: Date;
}

export let events = new LiveTable<Event>({
  sort: "eventDate asc",
  select: () => sql<Event>(
    `select id, createdAt, updatedAt, title, description, eventDate from events
     where eventDate >= now() - interval '1 hour'
     order by eventDate asc`,
  ),
  insert: { auth: (item, s) => s.isLoggedIn() && isUserAdmin(s.getOrThrow('userId')) },
  update: { auth: (item, s) => s.isLoggedIn() && isUserAdmin(s.getOrThrow('userId')) },
  delete: { auth: (item, s) => s.isLoggedIn() && isUserAdmin(s.getOrThrow('userId')) },
});

The custom select keeps the live view limited to events from the last hour and into the future. The one-hour overlap gives "happening now" events a window before they fall off the list. Both the public and the admin route pass this same LiveTable to their templates; the difference is what each template does with it.

Every mutator auth callback runs two checks in order: signed in, and an admin. The s.isLoggedIn() guard short-circuits when there's no user, so the isUserAdmin(s.getOrThrow('userId')) call only runs when the id is non-null. Non-admin users (or anonymous browsers) calling events.insert(...) from a devtools console fail with AuthError.

The isUserAdmin helper is the one from elements man recipes admin-roles. Both the public and the admin route share these LiveTable configuration callbacks.

Public route

elements create page events

app/pages/events/index.ts:

import events from "./template";
import { events as eventsTable } from "#app/shared/services/events";

export default function route(req, res) {
  return new events({ events: eventsTable });
}

The public route is unauthenticated. Anyone can hit /events and see the upcoming events.

app/pages/events/template.html:

import "./style.css";
import { LiveTable } from "@elements/app";
import { Event } from "#app/shared/services/events";

<html class="events" (events: LiveTable<Event>)>
  <h1>upcoming events</h1>

  <ul class="events">
    <li e:for={e of events}>
      <h2>{e.title}</h2>
      <time>{e.eventDate.toLocaleString()}</time>
      <p e:if={e.description}>{e.description}</p>
    </li>
    <li e:if={events.length === 0}>no upcoming events.</li>
  </ul>
</html>

The public template reads the LiveTable. There are no mutation buttons. The LiveTable's auto-broadcast still patches the list when an admin adds, edits, or removes a row, so the public page updates live.

Admin route

elements create page admin-events

app/pages/admin-events/index.ts:

import { isUserAdminOrThrow } from "#app/shared/services/admin";
import adminEvents from "./template";
import { events as eventsTable } from "#app/shared/services/events";

export default function route(req, res) {
  isUserAdminOrThrow();

  return new adminEvents({ events: eventsTable });
}

The admin route checks admin status before passing the LiveTable to the template. The page never renders for a non-admin; the LiveTable's mutator checks are the secondary gate.

app/pages/admin-events/template.html:

import "./style.css";
import { LiveTable } from "@elements/app";
import { Event } from "#app/shared/services/events";

interface EventInput {
  title: string;
  description: string;
  eventDate: string;
}

function emptyForm(): EventInput {
  return { title: "", description: "", eventDate: "" };
}

function insert(events: LiveTable<Event>, form: { value: EventInput }) {
  if (form.value.title.trim().length === 0) {
    return;
  }
  if (form.value.eventDate.length === 0) {
    return;
  }

  events.insert(
    {
      title: form.value.title.trim(),
      description: form.value.description.trim(),
      eventDate: new Date(form.value.eventDate),
    },
    () => form.value = emptyForm(),
  );
}

function onDelete(events: LiveTable<Event>, event: Event) {
  if (!confirm(`delete "${event.title}"?`)) {
    return;
  }
  events.delete(event);
}

<html class="admin-events"
      (events: LiveTable<Event>,
       private form: { value: EventInput } = { value: emptyForm() })>
  <h1>events (admin)</h1>

  <form class="new" onsubmit={() => insert(events, form)}>
    <input type="text" value={form.value.title} placeholder="event title" required>
    <input type="datetime-local" value={form.value.eventDate} required>
    <textarea value={form.value.description} placeholder="description (optional)" rows="3"/>
    <button type="submit">add event</button>
  </form>

  <ul class="events">
    <li e:for={e of events}>
      <h2>{e.title}</h2>
      <time>{e.eventDate.toLocaleString()}</time>
      <p e:if={e.description}>{e.description}</p>
      <button class="danger" onclick={() => onDelete(events, e)}>delete</button>
    </li>
    <li e:if={events.length === 0}>no upcoming events.</li>
  </ul>
</html>

The admin template adds a creation form at the top and a delete button per row. insert calls events.insert(...) with the form's three fields converted to the row shape (the <input type="datetime-local"> value is a string; new Date(...) turns it into the Date the column needs). onDelete uses the browser's confirm() dialog before calling events.delete(event).

Both mutator calls hit the LiveTable's auth callback before any SQL runs. A non-admin who somehow reaches the admin template (a stale session, a bug in the route guard) would still fail at the LiveTable layer with AuthError. The two layers carry the same check.

Routes

Register both routes in index.ts:

import events from "#app/pages/events";
import adminEvents from "#app/pages/admin-events";

// ...
app.route("/events", events);
app.route("/admin/events", adminEvents);

Notes

  • One LiveTable, two templates. The shared LiveTable is the single source of truth. Both the public and the admin view subscribe to the same broadcast channel, so an admin insert or delete instantly appears on every public browser without any extra plumbing. The data flow is identical regardless of which page the viewer is on.
  • Auth at two layers. The admin route's isUserAdminOrThrow blocks page rendering. The LiveTable's per-mutator auth callbacks block the underlying operation. The route gate is the UX; the LiveTable gate is the security boundary.
  • Editing existing events. Add an update button per row that calls events.update({ ...event, title, description, eventDate }) and either a modal form or a separate /admin/events/:id route. The LiveTable's update.auth is already gated on admin.
  • Past events. The custom select filters to events within the last hour or future. To let admins manage archived events, add an @rpc listPastEvents() that returns a regular array (not a LiveTable), and render it in a separate "archived" section on the admin page. Past events don't need real-time sync.
  • Multiple admin actions. A "feature" toggle, a "highlight" flag, or any other per-event mutation is just another column on the table plus an update call. The LiveTable's auth gate covers every mutation kind uniformly.
  • Public read without LiveTable. If realtime on the public page isn't valuable (the events list barely changes), swap the LiveTable read for a static SQL load in the public route handler. The admin page keeps the LiveTable for the live add/delete UX. The two pages no longer share infra, but the admin write path is the same.