Skip to content

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:load on every React component, including AuthCheck and AppLayout. These are .tsx components 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:load hydrates immediately on page load.
  • Layout is a .astro layout, not a React component — it does not take client:load. It emits the <!doctype html>, <head>, the theme-restore inline script, and a <slot /> for the children.
  • Public/auth pages skip AuthCheck and AppLayout — they render the React form directly inside Layout (see Public & auth routes).

Layouts

Two layout files exist under src/layouts/:

FilePropsUsed by
Layout.astrotitle: stringAll current pages.
LayoutV2.astrotitle: string, description?: stringAvailable; 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.

RouteAstro fileReact componentPurposeAuth
/index.astrodashboard/DashboardPageMain dashboard / overviewAuthCheck
/devicesdevices.astroDevicesTableAsset (device) inventory listAuthCheck
/device/[deviceName]device/[deviceName].astroAssetDetail (prop deviceId)Single asset detailAppLayout only — no AuthCheck wrapper (relies on AppShell’s own session check)
/applicationsapplications.astroApplicationsTableSoftware / application inventoryAuthCheck
/application/[appName]application/[appName].astroApplicationDetail (prop appName)Single application detailAuthCheck
/usersusers.astroUsersPageUser inventory listAuthCheck
/user/[upn]user/[upn].astroUserDetail (prop upn)Single user detail (by UPN)AuthCheck
/vulnerabilitiesvulnerabilities.astroVulnerabilitiesTableVulnerability (CVE) listAuthCheck
/vulnerabilities/[cve]vulnerabilities/[cve].astroVulnerabilityDetail (prop cveId)CVE detail. Redirects to /vulnerabilities if cve is emptyAuthCheck
/vulnerabilities/cve/[cveId]vulnerabilities/cve/[cveId].astroVulnerabilityDetail (prop cveId)Alternate CVE detail pathAuthCheck
/vulnerabilities/scans/[scanId]vulnerabilities/scans/[scanId].astroScanDetail (prop scanId)Single scan detailAuthCheck
/versionsversions.astro(none — inline HTML)Version tracking landing pageAuthCheck
/weaknessesweaknesses.astroWeaknessesPageCWE weaknesses catalogAuthCheck
/vendorsvendors.astroVendorsProductsVendors & products catalogAuthCheck
/newsnews.astroNewsFeedPageSecurity news feedAuthCheck
/advisoriesadvisories.astroAdvisoryEmailPageSecurity advisory emailsAuthCheck
/threat-intelthreat-intel.astroThreatIntelPageThreat intelligence aggregationAuthCheck
/integrationsintegrations.astro(none — inline HTML)Integrations landing / cardsAuthCheck
/sentinelonesentinelone.astroSentinelOnePageSentinelOne EDR sourceAuthCheck
/rapid7rapid7.astroRapid7PageRapid7 InsightVM sourceAuthCheck
/intuneintune.astroIntunePageMicrosoft Intune sourceAuthCheck
/entraentra.astroEntraPageMicrosoft Entra ID sourceAuthCheck
/connectwiseconnectwise.astroConnectWisePageConnectWise PSA integrationAuthCheck
/connectwise-automateconnectwise-automate.astroConnectWiseAutomatePageConnectWise Automate RMM sourceAuthCheck
/connectwise-bulkconnectwise-bulk.astroCWBulkTicketsPageConnectWise bulk ticket archiveAuthCheck
/inventoryinventory.astroCsvSourcesHubCSV / data-source upload hubAuthCheck
/csv-source/[key]csv-source/[key].astroCsvSourceDetail (prop sourceKey)Single CSV data-source detailAuthCheck
/projectsprojects.astroProjectsPageProjects listAuthCheck
/project/[id]project/[id].astroProjectDetail (prop projectId)Single project detail. Redirects to /projects if id is emptyAuthCheck
/reportsreports.astro(none — inline HTML)Report generation landing pageAuthCheck
/statisticsstatistics.astroStatisticsPageStatistics dashboardAuthCheck
/subscriptionssubscriptions.astroSubscriptionsPageNotification subscriptionsAuthCheck
/organizationsorganizations.astroOrganizationsPageOrganization / tenant adminAuthCheck
/tagstags.astroTagsPageTag managementAuthCheck
/profileprofile.astroProfilePageCurrent-user profileAuthCheck
/billingbilling.astroBillingPageUsage & billingAuthCheck
/vulnerability-scanningvulnerability-scanning.astro(none — inline HTML)Vulnerability-scanning explainerAuthCheck

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 .tsx component and mount it with client:load.

/device/[deviceName] is the one exception to the wrapper pattern: it omits <AuthCheck> and renders Layout > AppLayout > AssetDetail. AppShell (which AppLayout aliases) performs its own requireAuth session check by default, so the route is still gated, just by a different mechanism. Prefer the full pattern (with AuthCheck) 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.

RouteAstro fileReact componentPurposeAuth
/loginlogin.astroLoginSign inPublic
/signupsignup.astroSignupCreate accountPublic
/forgot-passwordforgot-password.astroForgotPasswordRequest password resetPublic
/reset-passwordreset-password.astroResetPasswordComplete password resetPublic
/verify-emailverify-email.astroVerifyEmailEmail verification landingPublic
/two-factortwo-factor.astroTwoFactorEnter 2FA code during loginPublic
/settings/mfasettings/mfa.astroMfaSettingsManage MFA/2FA enrollmentNo AuthCheck/AppLayout wrapper (form manages its own session)

API routes

RouteFileHandlerPurpose
/api/auth/*api/auth/[...all].tsBetter 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-session with credentials: 'include' so the session cookie is sent. body.user truthy = authenticated.
  • It uses a plain useEffect + fetch, deliberately not authClient.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 renders null and 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:

  1. Wraps everything in ThemeProvider (dark/light + .v2 class on <html>).
  2. Renders the Sidebar (left), the TopBar (top of the content column), the page <main>, and a footer.
  3. Runs its own cookie-based session check (requireAuth, default true) — the same /api/auth/get-session fetch. If no body.user, it redirects to /login. This means authenticated pages are gated twice: once by AuthCheck and once by AppShell. On success it seeds user = { email, full_name: name, role } and then best-effort enriches it via authApi.getCurrentUser() (a backend hiccup there never logs anyone out).
  4. Renders children only once ready is true; otherwise a small “Loading…” placeholder.
  5. 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).

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:

GroupItems (label → href)
OverviewDashboard → /
InventoryAssets → /devices, Software → /applications, Users → /users, Vulnerabilities → /vulnerabilities, Version Tracking → /versions
IntelligenceSecurity News → /news, Advisories → /advisories, Threat Intel → /threat-intel, Weaknesses → /weaknesses, Vendors & Products → /vendors
SourcesSentinelOne → /sentinelone, Rapid7 → /rapid7, Intune → /intune, Entra ID → /entra, ConnectWise → /connectwise, CW Automate → /connectwise-automate, Upload Data → /inventory
WorkProjects → /projects, Reports → /reports, Statistics → /statistics, Subscriptions → /subscriptions
AdministrationOrganizations → /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 NavItem to the appropriate NavGroup in Sidebar.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 K kbd 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 user Menu driven by the user prop from AppShell.
  • The user menu items: Profile (/profile), Settings (/organizations), separator, Sign out (calls authClient.signOut(), clears access_token from localStorage, redirects to /login).

How a page mounts its React island

  1. Astro resolves the request to a .astro file by path. For a dynamic route it extracts params from the URL into Astro.params.
  2. The frontmatter reads params and computes props/redirects (server-side, per request — prerender is off).
  3. Astro renders Layout.astro, which streams the <html> document. The is:inline theme script runs first (no FOUC).
  4. AuthCheck, AppLayout, and the feature component are React islands. Because each carries client:load, Astro ships their JS and hydrates them immediately in the browser.
  5. AuthCheck and AppShell fetch 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:

  1. Build the React island. Create src/components/FooPage.tsx exporting a named component. Do all data fetching, state, and UI here (see the components for API/client conventions).

    export function FooPage() {
    // fetch data, render UI
    return <div></div>;
    }
  2. Create the Astro route. Add src/pages/foo.astro using 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:load on all three React components. Use a title in the form <Screen> - Vulnerability Management.

  3. For a dynamic route, name the file with a bracketed segment and pass the param as a prop. Read it in frontmatter from Astro.params and 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.

  4. 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 right NavGroup in src/components/Sidebar.tsx (import the icon from lucide-react). Detail routes are typically not added to the sidebar.

  5. For a public/unauthenticated page, skip AuthCheck and AppLayout — render the component directly inside Layout (mirror login.astro):

    ---
    import Layout from '../layouts/Layout.astro';
    import { Foo } from '../components/Foo';
    ---
    <Layout title="Foo - Vulnerability Management">
    <Foo client:load />
    </Layout>
  6. Verify. Run the dev server, hit /foo while 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.