Public and Admin Views
elements man recipes/public-admin-views Read as markdownTwo 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
isUserAdminOrThrowblocks page rendering. The LiveTable's per-mutatorauthcallbacks block the underlying operation. The route gate is the UX; the LiveTable gate is the security boundary. - Editing existing events. Add an
updatebutton per row that callsevents.update({ ...event, title, description, eventDate })and either a modal form or a separate/admin/events/:idroute. The LiveTable'supdate.authis already gated on admin. - Past events. The custom
selectfilters 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
updatecall. 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.