Pages & routes
The Lockstep VM frontend is an Astro app rendered in SSR mode (export const prerender = false where needed) that hydrates React islands. Every user-facing screen is a thin .astro file under src/pages/. That file imports a shared Layout.astro, wraps the content in a client-side auth gate (AuthCheck) and the app chrome (AppLayout), and mounts a single React feature component with client:load.
Astro owns routing (file-based), the HTML document, the anti-FOUC theme script, and dynamic-route params. React owns all interactivity, data fetching, and state. The .astro file is glue only — it contains no business logic.
The wrapper pattern
The canonical authenticated page looks like this. Every property is load-bearing: Layout renders the <html> document, AuthCheck redirects to /login if there is no session, AppLayout renders the sidebar + top bar + footer chrome, and the feature component is the React island.
---import Layout from '../layouts/Layout.astro';import { AuthCheck } from '../components/AuthCheck';import { AppLayout } from '../components/AppLayout';import { DevicesTable } from '../components/DevicesTable';---
<Layout title="Devices - Vulnerability Management"> <AuthCheck client:load> <AppLayout client:load> <DevicesTable client:load /> </AppLayout> </AuthCheck></Layout>Notes:
client:loadon every React component, includingAuthCheckandAppLayout. These are.tsxcomponents imported into.astro; without a client directive Astro would render them to static HTML with no hydration, so the auth gate and interactivity would never run.client:loadhydrates immediately on page load.Layoutis a.astrolayout, not a React component — it does not takeclient:load. It emits the<!doctype html>,<head>, the theme-restore inline script, and a<slot />for the children.- Public/auth pages skip
AuthCheckandAppLayout— they render the React form directly insideLayout(see Public & auth routes).
Layouts
Two layout files exist under src/layouts/:
| File | Props | Used by |
|---|---|---|
Layout.astro | title: string | All current pages. |
LayoutV2.astro | title: string, description?: string | Available; not referenced by current pages. Adds a per-page description meta. |
Both set <html lang="en" class="v2">, a color-scheme meta, the favicon, and run an inline is:inline script before hydration that reads localStorage['lsk:theme'] (default 'light') and toggles the .dark class on <html> to avoid a flash of the wrong theme (FOUC). The body is class="antialiased" with a single <slot />.
Complete route table
File-based routing maps src/pages/<path>.astro to the URL /<path> (and index.astro to /). Dynamic segments use [param].astro; the value is read from Astro.params in the frontmatter and passed as a prop to the React island.
Authenticated app routes
All of these use the full Layout > AuthCheck > AppLayout > <Component client:load> stack unless noted.
| Route | Astro file | React component | Purpose | Auth |
|---|---|---|---|---|
/ | index.astro | dashboard/DashboardPage | Main dashboard / overview | AuthCheck |
/devices | devices.astro | DevicesTable | Asset (device) inventory list | AuthCheck |
/device/[deviceName] | device/[deviceName].astro | AssetDetail (prop deviceId) | Single asset detail | AppLayout only — no AuthCheck wrapper (relies on AppShell’s own session check) |
/applications | applications.astro | ApplicationsTable | Software / application inventory | AuthCheck |
/application/[appName] | application/[appName].astro | ApplicationDetail (prop appName) | Single application detail | AuthCheck |
/users | users.astro | UsersPage | User inventory list | AuthCheck |
/user/[upn] | user/[upn].astro | UserDetail (prop upn) | Single user detail (by UPN) | AuthCheck |
/vulnerabilities | vulnerabilities.astro | VulnerabilitiesTable | Vulnerability (CVE) list | AuthCheck |
/vulnerabilities/[cve] | vulnerabilities/[cve].astro | VulnerabilityDetail (prop cveId) | CVE detail. Redirects to /vulnerabilities if cve is empty | AuthCheck |
/vulnerabilities/cve/[cveId] | vulnerabilities/cve/[cveId].astro | VulnerabilityDetail (prop cveId) | Alternate CVE detail path | AuthCheck |
/vulnerabilities/scans/[scanId] | vulnerabilities/scans/[scanId].astro | ScanDetail (prop scanId) | Single scan detail | AuthCheck |
/versions | versions.astro | (none — inline HTML) | Version tracking landing page | AuthCheck |
/weaknesses | weaknesses.astro | WeaknessesPage | CWE weaknesses catalog | AuthCheck |
/vendors | vendors.astro | VendorsProducts | Vendors & products catalog | AuthCheck |
/news | news.astro | NewsFeedPage | Security news feed | AuthCheck |
/advisories | advisories.astro | AdvisoryEmailPage | Security advisory emails | AuthCheck |
/threat-intel | threat-intel.astro | ThreatIntelPage | Threat intelligence aggregation | AuthCheck |
/integrations | integrations.astro | (none — inline HTML) | Integrations landing / cards | AuthCheck |
/sentinelone | sentinelone.astro | SentinelOnePage | SentinelOne EDR source | AuthCheck |
/rapid7 | rapid7.astro | Rapid7Page | Rapid7 InsightVM source | AuthCheck |
/intune | intune.astro | IntunePage | Microsoft Intune source | AuthCheck |
/entra | entra.astro | EntraPage | Microsoft Entra ID source | AuthCheck |
/connectwise | connectwise.astro | ConnectWisePage | ConnectWise PSA integration | AuthCheck |
/connectwise-automate | connectwise-automate.astro | ConnectWiseAutomatePage | ConnectWise Automate RMM source | AuthCheck |
/connectwise-bulk | connectwise-bulk.astro | CWBulkTicketsPage | ConnectWise bulk ticket archive | AuthCheck |
/inventory | inventory.astro | CsvSourcesHub | CSV / data-source upload hub | AuthCheck |
/csv-source/[key] | csv-source/[key].astro | CsvSourceDetail (prop sourceKey) | Single CSV data-source detail | AuthCheck |
/projects | projects.astro | ProjectsPage | Projects list | AuthCheck |
/project/[id] | project/[id].astro | ProjectDetail (prop projectId) | Single project detail. Redirects to /projects if id is empty | AuthCheck |
/reports | reports.astro | (none — inline HTML) | Report generation landing page | AuthCheck |
/statistics | statistics.astro | StatisticsPage | Statistics dashboard | AuthCheck |
/subscriptions | subscriptions.astro | SubscriptionsPage | Notification subscriptions | AuthCheck |
/organizations | organizations.astro | OrganizationsPage | Organization / tenant admin | AuthCheck |
/tags | tags.astro | TagsPage | Tag management | AuthCheck |
/profile | profile.astro | ProfilePage | Current-user profile | AuthCheck |
/billing | billing.astro | BillingPage | Usage & billing | AuthCheck |
/vulnerability-scanning | vulnerability-scanning.astro | (none — inline HTML) | Vulnerability-scanning explainer | AuthCheck |
Inline-HTML pages (
/versions,/integrations,/reports,/vulnerability-scanning) put static markup directly inside<AppLayout>instead of mounting a React island. They still get the auth gate and app chrome. If you need interactivity on one of these, extract the markup into a.tsxcomponent and mount it withclient:load.
/device/[deviceName]is the one exception to the wrapper pattern: it omits<AuthCheck>and rendersLayout > AppLayout > AssetDetail.AppShell(whichAppLayoutaliases) performs its ownrequireAuthsession check by default, so the route is still gated, just by a different mechanism. Prefer the full pattern (withAuthCheck) for new pages.
Public & auth routes
These render the React form directly inside Layout — no AuthCheck, no AppLayout, no sidebar. They are the unauthenticated surface.
| Route | Astro file | React component | Purpose | Auth |
|---|---|---|---|---|
/login | login.astro | Login | Sign in | Public |
/signup | signup.astro | Signup | Create account | Public |
/forgot-password | forgot-password.astro | ForgotPassword | Request password reset | Public |
/reset-password | reset-password.astro | ResetPassword | Complete password reset | Public |
/verify-email | verify-email.astro | VerifyEmail | Email verification landing | Public |
/two-factor | two-factor.astro | TwoFactor | Enter 2FA code during login | Public |
/settings/mfa | settings/mfa.astro | MfaSettings | Manage MFA/2FA enrollment | No AuthCheck/AppLayout wrapper (form manages its own session) |
API routes
| Route | File | Handler | Purpose |
|---|---|---|---|
/api/auth/* | api/auth/[...all].ts | Better Auth catch-all (auth.handler) | All auth endpoints: sign-in, sign-up, sign-out, get-session, callback, verify-email, etc. Exports GET/POST/PUT/DELETE/PATCH/OPTIONS, prerender = false. |
The auth-gate flow (AuthCheck)
src/components/AuthCheck.tsx is a client-side route guard. It renders a loading spinner, calls the session endpoint once on mount, and either renders its children or redirects to /login.
export function AuthCheck({ children }: { children: React.ReactNode }) { const [state, setState] = useState<'pending' | 'ok' | 'denied'>('pending');
useEffect(() => { let cancelled = false; (async () => { try { const res = await fetch('/api/auth/get-session', { credentials: 'include' }); const body: any = res.ok ? await res.json().catch(() => null) : null; if (cancelled) return; if (body?.user) setState('ok'); else { setState('denied'); window.location.href = '/login'; } } catch { if (!cancelled) { setState('denied'); window.location.href = '/login'; } } })(); return () => { cancelled = true; }; }, []);
if (state === 'pending') return /* spinner */; if (state === 'denied') return null; return <>{children}</>;}Key design decisions:
- It hits
/api/auth/get-sessionwithcredentials: 'include'so the session cookie is sent.body.usertruthy = authenticated. - It uses a plain
useEffect+fetch, deliberately notauthClient.useSession().useSession()produces different reactive state on SSR vs. first client render, which triggers React hydration mismatch errors (#418/#423). The effect approach renders identical output on server and first client paint. - While
state === 'pending'it shows a centered spinner (“Loading…”); on'denied'it rendersnulland navigates away.
The app chrome (AppLayout / AppShell)
src/components/AppLayout.tsx is a thin alias for AppShell:
export function AppLayout({ children }: { children: React.ReactNode }) { return <AppShell>{children}</AppShell>;}src/components/AppShell.tsx is the real v2 layout. It:
- Wraps everything in
ThemeProvider(dark/light +.v2class on<html>). - Renders the
Sidebar(left), theTopBar(top of the content column), the page<main>, and a footer. - Runs its own cookie-based session check (
requireAuth, defaulttrue) — the same/api/auth/get-sessionfetch. If nobody.user, it redirects to/login. This means authenticated pages are gated twice: once byAuthCheckand once byAppShell. On success it seedsuser = { email, full_name: name, role }and then best-effort enriches it viaauthApi.getCurrentUser()(a backend hiccup there never logs anyone out). - Renders
childrenonly oncereadyis true; otherwise a small “Loading…” placeholder. - Mounts a
<Toaster />for global toasts.
AppShell accepts currentPath? and requireAuth? props, but AppLayout passes neither, so pages get the defaults (requireAuth = true, currentPath inferred from window.location.pathname).
Sidebar
src/components/Sidebar.tsx renders a fixed 60-wide (w-60) dark rail with the “Lockstep VM” brand, grouped nav links (lucide icons), and a footer with Profile and Usage & Billing links. Groups and their items:
| Group | Items (label → href) |
|---|---|
| Overview | Dashboard → / |
| Inventory | Assets → /devices, Software → /applications, Users → /users, Vulnerabilities → /vulnerabilities, Version Tracking → /versions |
| Intelligence | Security News → /news, Advisories → /advisories, Threat Intel → /threat-intel, Weaknesses → /weaknesses, Vendors & Products → /vendors |
| Sources | SentinelOne → /sentinelone, Rapid7 → /rapid7, Intune → /intune, Entra ID → /entra, ConnectWise → /connectwise, CW Automate → /connectwise-automate, Upload Data → /inventory |
| Work | Projects → /projects, Reports → /reports, Statistics → /statistics, Subscriptions → /subscriptions |
| Administration | Organizations → /organizations, Tags → /tags |
Active-link detection: isActive(current, href) — exact match for /, otherwise current === href || current.startsWith(href + '/') (so /device/foo highlights nothing here since only /devices is listed; detail routes are not in the nav).
Adding a page does not automatically add a sidebar entry. If the new route should be navigable, add a
NavItemto the appropriateNavGroupinSidebar.tsx.
Top bar
src/components/TopBar.tsx renders a sticky 14-high header inside the content column with:
- A global search button (“Search assets, CVEs, software…”) showing a
⌘K/Ctrl Kkbd hint (platform-detected). Currently a button placeholder — the command palette is noted as “later”. - A
ThemeToggle, a notifications bell (with a red dot), and a userMenudriven by theuserprop fromAppShell. - The user menu items: Profile (
/profile), Settings (/organizations), separator, Sign out (callsauthClient.signOut(), clearsaccess_tokenfrom localStorage, redirects to/login).
How a page mounts its React island
- Astro resolves the request to a
.astrofile by path. For a dynamic route it extracts params from the URL intoAstro.params. - The frontmatter reads params and computes props/redirects (server-side, per request —
prerenderis off). - Astro renders
Layout.astro, which streams the<html>document. Theis:inlinetheme script runs first (no FOUC). AuthCheck,AppLayout, and the feature component are React islands. Because each carriesclient:load, Astro ships their JS and hydrates them immediately in the browser.AuthCheckandAppShellfetch the session; on success the feature component renders and takes over data fetching and interactivity.
How to add a new page — recipe
To add an authenticated page at /foo backed by a FooPage React component:
-
Build the React island. Create
src/components/FooPage.tsxexporting a named component. Do all data fetching, state, and UI here (see the components for API/client conventions).export function FooPage() {// fetch data, render UIreturn <div>…</div>;} -
Create the Astro route. Add
src/pages/foo.astrousing the canonical wrapper:---import Layout from '../layouts/Layout.astro';import { AuthCheck } from '../components/AuthCheck';import { AppLayout } from '../components/AppLayout';import { FooPage } from '../components/FooPage';---<Layout title="Foo - Vulnerability Management"><AuthCheck client:load><AppLayout client:load><FooPage client:load /></AppLayout></AuthCheck></Layout>Keep the
client:loadon all three React components. Use atitlein the form<Screen> - Vulnerability Management. -
For a dynamic route, name the file with a bracketed segment and pass the param as a prop. Read it in frontmatter from
Astro.paramsand guard/redirect empties:---import Layout from '../../layouts/Layout.astro';import { AuthCheck } from '../../components/AuthCheck';import { AppLayout } from '../../components/AppLayout';import { FooDetail } from '../../components/FooDetail';const { fooId } = Astro.params;if (!fooId) return Astro.redirect('/foos');---<Layout title={`Foo: ${fooId} - Vulnerability Management`}><AuthCheck client:load><AppLayout client:load><FooDetail fooId={fooId} client:load /></AppLayout></AuthCheck></Layout>Fix the relative import depth (
../per directory level). The component receives the param as a typed prop. -
Add navigation (optional but usually wanted). If the page should appear in the sidebar, add a
{ href: '/foo', icon: SomeIcon, label: 'Foo' }entry to the rightNavGroupinsrc/components/Sidebar.tsx(import the icon fromlucide-react). Detail routes are typically not added to the sidebar. -
For a public/unauthenticated page, skip
AuthCheckandAppLayout— render the component directly insideLayout(mirrorlogin.astro):---import Layout from '../layouts/Layout.astro';import { Foo } from '../components/Foo';---<Layout title="Foo - Vulnerability Management"><Foo client:load /></Layout> -
Verify. Run the dev server, hit
/foowhile logged out (should redirect to/login) and logged in (should render with sidebar + top bar). Confirm the theme script prevents any flash and the sidebar highlights the active item.