TutorialsMetals UI › How It's Put Together

Metals UI · Section 1 of 2

Routing, Guards, and the Admin Screens

Every page in this app is rendered by the same small pipeline: restore whatever session exists, resolve the current hash to a route, check that route's guard, and render its view. That one pipeline is the entire reason a logged-out visitor never sees the catalog, and a logged-in Customer never sees a delete button.

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
Entry point → js/app.js Router → js/router.js Guards → js/auth/guards.js Session → js/auth/session.js

Purpose of this page

Follow one page load from the moment the browser opens index.html to a rendered view, and see exactly where access gets decided.

Codey typing on a laptop
two lines, at the bottom of app.js

App startup js/app.js

Every route is registered up front with registerRoute(path, view, guard), then the app does exactly two things before anything is on screen:

js/app.jsawait restoreSession();
startRouter(render);
restoreSession()js/auth/session.js

Looks for an access token in sessionStorage. If there isn't one, it does nothing — the visitor is treated as logged out. If there is one, it calls GET /api/auth/me to ask the API who that token belongs to and stores the result in application state. If the API rejects the token (expired, invalid), the catch block clears the session instead of leaving the app in a half-logged-in state.

startRouter(render)js/router.js

Resolves whatever route the current URL hash points to and renders it, then listens for future hashchange events (clicking a link, using the back button) and re-resolves each time.

How a route resolves js/router.js

resolveRoute() runs on load and on every hash change. It reads the path after the #, finds the matching registered route (exact match first, then a parameterized pattern like /elements/:atomicNumber), and checks that route's guard before rendering anything.

js/router.jsconst route = findRoute(path) ?? { ...routes.get("/not-found"), params: {} };

if (route.guard && !route.guard()) {
  navigate("/login");
  return;
}

render(route.view(route.params));

Notice the guard check happens before route.view() is ever called. A guarded view's code never runs for a visitor who fails its guard — they're redirected to #/login instead, and nothing about the protected page (not even its markup) is built.

The two guards js/auth/guards.js

Both guards are one-line functions built from the same two state checks, and every route in the app is registered with one of them, or neither.

Codey holding up a sticky note
two checks, reused everywhere
js/auth/guards.jsexport const requireLogin = () => isAuthenticated();
export const requireAdmin = () => isAuthenticated() && isAdmin();
GuardPasses when…Used by
nonealways#/, #/login, #/register, #/not-found
requireLogina valid session exists, any role#/elements, #/alloys, #/coins, and their detail pages
requireAdmina valid session exists and its roles include Admin#/admin and every #/admin/… route

The UI's guards are a convenience, not a security boundary. They only decide what gets rendered in this browser tab. The real enforcement is still on the server: every write request the admin screens send is checked again by the API's own @require_auth and @require_roles("Admin") decorators, covered in the Metals API tutorial. A guard failing here just means a nicer redirect than a raw 403.

Catalog pages vs. admin pages the split

Elements, alloys, and coins each exist twice in js/views/: once under catalog/ as a read-only browsing page, and once under admin/ as a management page with a form and delete buttons. Both read from the same API endpoints; only the admin versions write to them.

FolderWhat it rendersGuard on its routes
js/views/catalog/List and detail pages — no create, edit, or delete controls at all.requireLogin
js/views/admin/A form for create/edit plus a list with Edit and Delete buttons on every row.requireAdmin

The admin create/edit/delete flow js/views/admin/crud-admin.js

Elements, alloys, coins, and users don't each reimplement their own admin screen. elements-admin-view.js, alloys-admin-view.js, and coins-admin-view.js each hand a small config object (field list, and list/create/update/remove functions from the matching api/ module) to one shared pair of functions, adminCrudView() and bindAdminCrud().

Codey thinking through a problem
one form, four resources
  1. Load: on bind, the shared code calls config.list() and renders one row per record, each with Edit and Delete buttons.
  2. Create or edit: submitting the form calls config.create(), or config.update() if a row's Edit button set the form into edit mode first. Either way the list reloads afterward.
  3. Delete: the Delete button asks for confirmation, then calls config.remove() and reloads the list.

Every one of those calls goes straight to the Metals API with the visitor's stored Bearer token attached. If the token's roles don't actually include Admin — for example, an Admin flag revoked in another tab — the API returns 403 Forbidden and the shared code surfaces that error message in the form instead of pretending the change worked.

Codey giving a thumbs up

Routing and guards covered — next, actually serving this folder and pointing it at a running API.