# Public and Admin Views 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 ```bash elements create migration 'add events' -tables=events ``` `app/migrations/-add-events.migration.sql`: ```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`: ```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({ sort: "eventDate asc", select: () => sql( `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 ```bash elements create page events ``` `app/pages/events/index.ts`: ```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`: ```html import "./style.css"; import { LiveTable } from "@elements/app"; import { Event } from "#app/shared/services/events"; )>

upcoming events

  • {e.title}

    {e.description}

  • no upcoming events.
``` 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 ```bash elements create page admin-events ``` `app/pages/admin-events/index.ts`: ```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`: ```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, 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) { if (!confirm(`delete "${event.title}"?`)) { return; } events.delete(event); } , private form: { value: EventInput } = { value: emptyForm() })>

events (admin)

insert(events, form)}>