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.
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);
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.
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));
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.
js/auth/guards.jsexport const requireLogin = () => isAuthenticated();
export const requireAdmin = () => isAuthenticated() && isAdmin();
| Guard | Passes when… | Used by |
|---|---|---|
| none | always | #/, #/login, #/register, #/not-found |
requireLogin | a valid session exists, any role | #/elements, #/alloys, #/coins, and their detail pages |
requireAdmin | a valid session exists and its roles include Admin | #/admin and every #/admin/… route |
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.
| Folder | What it renders | Guard 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().
- Load: on bind, the shared code calls config.list() and renders one row per record, each with Edit and Delete buttons.
- 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.
- 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.