diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminPageSections.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminPageSections.tsx index 8cb0c028..04109aa9 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminPageSections.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminPageSections.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { usePageSections } from "../../hooks/usePageSections"; import { PageSectionItem } from "../../contexts/SidebarContext"; +import { route } from "../../lib/routes"; import { GearIcon, NoSymbolIcon, @@ -47,29 +48,29 @@ function GlobeIcon({ className = "w-5 h-5" }: { className?: string }) { export function AdminPageSections() { const items: PageSectionItem[] = useMemo(() => [ // Tenants - { id: "management", label: "Tenant Management", href: "/admin/tenants/management", group: "Tenants", groupIcon: }, - { id: "config-report", label: "Config Report", href: "/admin/tenants/config-report", group: "Tenants" }, + { id: "management", label: "Tenant Management", href: route("/admin/tenants/management"), group: "Tenants", groupIcon: }, + { id: "config-report", label: "Config Report", href: route("/admin/tenants/config-report"), group: "Tenants" }, // Metrics - { id: "platform-metrics", label: "Platform Metrics", href: "/admin/metrics/platform-metrics", group: "Metrics", groupIcon: }, - { id: "usage", label: "Platform Usage", href: "/admin/metrics/usage", group: "Metrics" }, - { id: "mcp-usage", label: "MCP Usage", href: "/admin/metrics/mcp-usage", group: "Metrics" }, + { id: "platform-metrics", label: "Platform Metrics", href: route("/admin/metrics/platform-metrics"), group: "Metrics", groupIcon: }, + { id: "usage", label: "Platform Usage", href: route("/admin/metrics/usage"), group: "Metrics" }, + { id: "mcp-usage", label: "MCP Usage", href: route("/admin/metrics/mcp-usage"), group: "Metrics" }, // Reports - { id: "session-reports", label: "Session Reports", href: "/admin/reports/session-reports", group: "Reports", groupIcon: }, - { id: "user-feedback", label: "User Feedback", href: "/admin/reports/user-feedback", group: "Reports" }, - { id: "session-export", label: "Session Export", href: "/admin/reports/session-export", group: "Reports" }, + { id: "session-reports", label: "Session Reports", href: route("/admin/reports/session-reports"), group: "Reports", groupIcon: }, + { id: "user-feedback", label: "User Feedback", href: route("/admin/reports/user-feedback"), group: "Reports" }, + { id: "session-export", label: "Session Export", href: route("/admin/reports/session-export"), group: "Reports" }, // Security - { id: "device-block", label: "Device Block", href: "/admin/security/device-block", group: "Security", groupIcon: }, - { id: "version-block", label: "Version Block", href: "/admin/security/version-block", group: "Security" }, - { id: "vulnerability-data", label: "Vulnerability Data", href: "/admin/security/vulnerability-data", group: "Security" }, + { id: "device-block", label: "Device Block", href: route("/admin/security/device-block"), group: "Security", groupIcon: }, + { id: "version-block", label: "Version Block", href: route("/admin/security/version-block"), group: "Security" }, + { id: "vulnerability-data", label: "Vulnerability Data", href: route("/admin/security/vulnerability-data"), group: "Security" }, // Settings - { id: "global", label: "Global Settings", href: "/admin/settings/global", group: "Settings", groupIcon: }, - { id: "diagnostics-log-paths", label: "Diagnostics Log Paths", href: "/admin/settings/diagnostics-log-paths", group: "Settings" }, - { id: "config-reseed", label: "Config Reseed", href: "/admin/settings/config-reseed", group: "Settings" }, - { id: "usage-plans", label: "Usage Plans", href: "/admin/settings/usage-plans", group: "Settings" }, + { id: "global", label: "Global Settings", href: route("/admin/settings/global"), group: "Settings", groupIcon: }, + { id: "diagnostics-log-paths", label: "Diagnostics Log Paths", href: route("/admin/settings/diagnostics-log-paths"), group: "Settings" }, + { id: "config-reseed", label: "Config Reseed", href: route("/admin/settings/config-reseed"), group: "Settings" }, + { id: "usage-plans", label: "Usage Plans", href: route("/admin/settings/usage-plans"), group: "Settings" }, // Ops (single page) { id: "ops", label: "Maintenance", href: "/admin/ops", group: "Ops", groupIcon: }, diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx index c3eacc68..d74e1629 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { sessionUrl } from "@/lib/routes"; +import { sessionUrl, deviceBlockUrl } from "@/lib/routes"; import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { api } from "@/lib/api"; @@ -558,7 +558,6 @@ export function OpsEventsSection({ const sessionId = extractSessionId(selectedEvent.details); if (!sessionId) return null; const reason = buildAutoReason(selectedEvent.eventType, sessionId); - const baseHref = `/admin/security/device-block?sessionId=${encodeURIComponent(sessionId)}&reason=${encodeURIComponent(reason)}`; return (

@@ -574,14 +573,14 @@ export function OpsEventsSection({ View session setSelectedEvent(null)} className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-orange-100 text-orange-800 hover:bg-orange-200 dark:bg-orange-900/40 dark:text-orange-200 dark:hover:bg-orange-900/60 border border-orange-300 dark:border-orange-700" > Block this device setSelectedEvent(null)} className="inline-flex items-center px-3 py-1.5 rounded-md text-xs font-medium bg-red-700 text-white hover:bg-red-800 dark:bg-red-700 dark:hover:bg-red-800" > diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx index c82aaaae..0f07e576 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx @@ -2,6 +2,7 @@ import { sessionUrl } from "@/lib/routes"; import { useRouter } from "next/navigation"; +import type { Route } from "next"; import { useState, useEffect, useRef, useMemo, useDeferredValue } from "react"; import { Session } from "../types"; import { trackEvent } from "@/lib/appInsights"; @@ -103,7 +104,7 @@ interface SessionTableProps { /** Builds the row's navigation target. Defaults to `/sessions/{id}`; a cross-tenant viewer overrides it to * append `?tenantId=` so a delegated viewer opens the session in the managed tenant's (read-only) context. * Receives the whole session because the target tenant is per-row (session.tenantId), not derivable from id. */ - sessionLinkTarget?: (session: Session) => string; + sessionLinkTarget?: (session: Session) => Route; } export function SessionTable({ diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx index 1c524a54..e97f1f7f 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useRef, useMemo } from "react"; import Link from "next/link"; +import type { Route } from "next"; import { ProtectedRoute } from "../../components/ProtectedRoute"; import { useSignalR } from "../../contexts/SignalRContext"; import { useTenant } from "../../contexts/TenantContext"; @@ -192,7 +193,7 @@ export default function FleetHealthPage() { // model. The model key is "{Manufacturer} {Model}", which the dashboard search matches // against its combined manufacturer+model text. Carry the selected tenant so a global // admin scoped to one tenant lands on that tenant's list rather than their default scope. - const dashboardModelHref = (model: string) => { + const dashboardModelHref = (model: string): Route => { const params = new URLSearchParams({ status: "Failed", search: model }); if (isGlobalAdmin && selectedTenantId) params.set("tenant", selectedTenantId); return `/dashboard?${params.toString()}`; diff --git a/src/Web/autopilot-monitor-web/components/ClientRedirect.tsx b/src/Web/autopilot-monitor-web/components/ClientRedirect.tsx index ea0dba19..66313c98 100644 --- a/src/Web/autopilot-monitor-web/components/ClientRedirect.tsx +++ b/src/Web/autopilot-monitor-web/components/ClientRedirect.tsx @@ -2,13 +2,14 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; +import type { Route } from "next"; /** * Client-side replacement for server `redirect()` index pages — those are not * supported under `output: 'export'`. Renders nothing and replaces the history * entry on mount (same UX as the role-conditional shell in app/settings/page.tsx). */ -export function ClientRedirect({ to }: { to: string }) { +export function ClientRedirect({ to }: { to: Route }) { const router = useRouter(); useEffect(() => { router.replace(to); diff --git a/src/Web/autopilot-monitor-web/components/LegacyPathRedirect.tsx b/src/Web/autopilot-monitor-web/components/LegacyPathRedirect.tsx index f7892757..45b82d89 100644 --- a/src/Web/autopilot-monitor-web/components/LegacyPathRedirect.tsx +++ b/src/Web/autopilot-monitor-web/components/LegacyPathRedirect.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { usePathname, useRouter } from "next/navigation"; +import type { Route } from "next"; import { appDetailUrl, backupUrl, @@ -27,7 +28,7 @@ import { * the target shapes. */ -function legacyTarget(pathname: string, search: URLSearchParams, hash: string): string | null { +function legacyTarget(pathname: string, search: URLSearchParams, hash: string): Route | null { const seg = pathname.replace(/\/+$/, "").split("/").filter(Boolean).map(decodeURIComponent); // /sessions/{id}/inspector diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx index ff15ee4e..393cec59 100644 --- a/src/Web/autopilot-monitor-web/components/Navbar.tsx +++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx @@ -7,6 +7,7 @@ import { useTenantNotifications } from '@/contexts/TenantNotificationContext'; import { useTheme } from '@/contexts/ThemeContext'; import { useState, useRef, useEffect } from 'react'; import Link from 'next/link'; +import { trustedRoute } from '@/lib/routes'; import { usePathname, useRouter } from 'next/navigation'; import { BrandMark } from './BrandMark'; import { trackEvent } from '@/lib/appInsights'; @@ -138,7 +139,7 @@ export default function Navbar() { // so it doesn't linger over the target page. const openNotificationHref = (href: string) => { setShowNotifications(false); - router.push(href); + router.push(trustedRoute(href)); }; const getUserInitials = () => { @@ -330,7 +331,7 @@ export default function Navbar() {

{formatTime(new Date(tn.createdAt))}

{tn.href && ( { e.stopPropagation(); setShowNotifications(false); }} className="text-[10px] text-green-700 hover:text-green-800 font-medium underline" @@ -367,7 +368,7 @@ export default function Navbar() {

{formatTime(new Date(gn.createdAt))}

{gn.href && ( { e.stopPropagation(); setShowNotifications(false); }} className="text-[10px] text-green-700 hover:text-green-800 font-medium underline" @@ -401,7 +402,7 @@ export default function Navbar() {

{formatTime(notification.timestamp)}

{notification.href && ( { e.stopPropagation(); markAsRead(notification.id); setShowNotifications(false); }} className="text-[10px] text-green-700 hover:text-green-800 font-medium underline" diff --git a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx index a195522d..207917a4 100644 --- a/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx +++ b/src/Web/autopilot-monitor-web/components/landing/AuthGate.tsx @@ -3,6 +3,8 @@ import { useAuth } from "../../contexts/AuthContext"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; +import type { Route } from "next"; +import { trustedRoute } from "../../lib/routes"; import { consumePostLoginReturnUrl } from "../../lib/postLoginReturn"; import { PORTAL_HOST, shouldCrossOriginToPortal } from "../../lib/hostRouting"; @@ -20,12 +22,12 @@ export function AuthGate() { // Always consume (read + clear) so a stale deep link can't misroute a later // sign-in; only honor it when the user isn't preview-gated. const returnUrl = consumePostLoginReturnUrl(); - let target: string; + let target: Route; if (isPreviewBlocked) { target = "/preview"; } else if (returnUrl) { // Restore the deep link the user originally opened before re-auth. - target = returnUrl; + target = trustedRoute(returnUrl); } else if (user.isDelegated && !user.isTenantAdmin && !user.isGlobalAdmin && !user.isGlobalReader && user.role !== 'Operator') { // A delegated ("MSP") admin with no own-tenant/platform role manages a fleet → land on /fleet. target = "/fleet"; diff --git a/src/Web/autopilot-monitor-web/contexts/SidebarContext.tsx b/src/Web/autopilot-monitor-web/contexts/SidebarContext.tsx index 7e9a539b..f1c97926 100644 --- a/src/Web/autopilot-monitor-web/contexts/SidebarContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/SidebarContext.tsx @@ -1,13 +1,14 @@ "use client"; import { createContext, useContext, useState, useCallback, ReactNode } from "react"; +import type { Route } from "next"; import { useSidebarState, CollapseState } from "../hooks/useSidebarState"; export interface PageSectionItem { id: string; label: string; icon?: ReactNode; - href?: string; + href?: Route; /** Optional group name — items sharing the same group are rendered under a collapsible header */ group?: string; /** Icon for the group header (only needs to be set on the first item of a group) */ diff --git a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx index d553d5ae..6349c12d 100644 --- a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx +++ b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx @@ -12,6 +12,8 @@ import { FolderIcon, WrenchScrewdriverIcon, } from "./sidebarIcons"; +import type { Route } from "next"; +import { route } from "./routes"; // --- Icons defined inline (not in sidebarIcons) --- @@ -84,7 +86,7 @@ function WrenchIcon({ className = "w-5 h-5" }: { className?: string }) { export interface NavItem { id: string; label: string; - href: string; + href: Route; icon: React.ReactNode; } @@ -92,7 +94,7 @@ export interface NavItem { export interface ExpandableSubItem { id: string; label: string; - href: string; + href: Route; } /** An expandable group with icon + chevron, containing sub-items */ @@ -201,38 +203,38 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [ { id: "cfg-tenant", label: "Tenant", icon: , items: [ - { id: "cfg-access-mgmt", label: "Access Management", href: "/settings/tenant/access-management" }, - { id: "cfg-autopilot", label: "Autopilot Validation", href: "/settings/tenant/autopilot" }, - { id: "cfg-hardware", label: "Hardware Whitelist", href: "/settings/tenant/hardware-whitelist" }, - { id: "cfg-notifications", label: "Notifications", href: "/settings/tenant/notifications" }, - { id: "cfg-sla-targets", label: "SLA Targets", href: "/settings/tenant/sla-targets" }, - { id: "cfg-bootstrap-sessions", label: "Bootstrap Sessions", href: "/settings/tenant/bootstrap-sessions" }, - { id: "cfg-graph-permissions", label: "Optional Graph capabilities", href: "/settings/tenant/graph-permissions" }, - { id: "cfg-support", label: "Submit Logs", href: "/settings/tenant/support" }, - { id: "cfg-plan", label: "Plan", href: "/settings/tenant/plan" }, - { id: "cfg-contact", label: "Contact", href: "/settings/tenant/contact" }, + { id: "cfg-access-mgmt", label: "Access Management", href: route("/settings/tenant/access-management") }, + { id: "cfg-autopilot", label: "Autopilot Validation", href: route("/settings/tenant/autopilot") }, + { id: "cfg-hardware", label: "Hardware Whitelist", href: route("/settings/tenant/hardware-whitelist") }, + { id: "cfg-notifications", label: "Notifications", href: route("/settings/tenant/notifications") }, + { id: "cfg-sla-targets", label: "SLA Targets", href: route("/settings/tenant/sla-targets") }, + { id: "cfg-bootstrap-sessions", label: "Bootstrap Sessions", href: route("/settings/tenant/bootstrap-sessions") }, + { id: "cfg-graph-permissions", label: "Optional Graph capabilities", href: route("/settings/tenant/graph-permissions") }, + { id: "cfg-support", label: "Submit Logs", href: route("/settings/tenant/support") }, + { id: "cfg-plan", label: "Plan", href: route("/settings/tenant/plan") }, + { id: "cfg-contact", label: "Contact", href: route("/settings/tenant/contact") }, ], }, { id: "cfg-agent", label: "Agent", icon: , items: [ - { id: "cfg-agent-settings", label: "Agent Settings", href: "/settings/agent/settings" }, - { id: "cfg-agent-analyzers", label: "Agent Analyzers", href: "/settings/agent/analyzers" }, - { id: "cfg-diagnostics", label: "Diagnostics Package", href: "/settings/agent/diagnostics" }, - { id: "cfg-agent-unrestricted", label: "Unrestricted Mode", href: "/settings/agent/unrestricted-mode" }, + { id: "cfg-agent-settings", label: "Agent Settings", href: route("/settings/agent/settings") }, + { id: "cfg-agent-analyzers", label: "Agent Analyzers", href: route("/settings/agent/analyzers") }, + { id: "cfg-diagnostics", label: "Diagnostics Package", href: route("/settings/agent/diagnostics") }, + { id: "cfg-agent-unrestricted", label: "Unrestricted Mode", href: route("/settings/agent/unrestricted-mode") }, ], }, { id: "cfg-maintenance", label: "Maintenance", icon: , items: [ - { id: "cfg-data", label: "Data Management", href: "/settings/management/data" }, - { id: "cfg-offboarding", label: "Offboarding", href: "/settings/management/offboarding" }, + { id: "cfg-data", label: "Data Management", href: route("/settings/management/data") }, + { id: "cfg-offboarding", label: "Offboarding", href: route("/settings/management/offboarding") }, ], }, { id: "cfg-reporting", label: "Reporting", icon: , items: [ - { id: "cfg-mcp-usage", label: "MCP Usage", href: "/settings/reporting/mcp-usage" }, + { id: "cfg-mcp-usage", label: "MCP Usage", href: route("/settings/reporting/mcp-usage") }, ], }, ], @@ -246,34 +248,34 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [ { id: "ga-tenants", label: "Tenants", icon: , items: [ - { id: "ga-tenant-mgmt", label: "Tenant Management", href: "/admin/tenants/management" }, - { id: "ga-config-report", label: "Config Report", href: "/admin/tenants/config-report" }, + { id: "ga-tenant-mgmt", label: "Tenant Management", href: route("/admin/tenants/management") }, + { id: "ga-config-report", label: "Config Report", href: route("/admin/tenants/config-report") }, ], }, { id: "ga-metrics", label: "Metrics", icon: , items: [ - { id: "ga-platform-metrics", label: "Platform Metrics", href: "/admin/metrics/platform-metrics" }, - { id: "ga-usage", label: "Platform Usage", href: "/admin/metrics/usage" }, + { id: "ga-platform-metrics", label: "Platform Metrics", href: route("/admin/metrics/platform-metrics") }, + { id: "ga-usage", label: "Platform Usage", href: route("/admin/metrics/usage") }, { id: "ga-active-users", label: "Active Users", href: "/admin/presence" }, - { id: "ga-mcp-usage", label: "MCP Usage", href: "/admin/metrics/mcp-usage" }, + { id: "ga-mcp-usage", label: "MCP Usage", href: route("/admin/metrics/mcp-usage") }, ], }, { id: "ga-reports", label: "Reports", icon: , items: [ - { id: "ga-session-reports", label: "Session Reports", href: "/admin/reports/session-reports" }, - { id: "ga-distress-reports", label: "Distress Reports", href: "/admin/reports/distress-reports" }, - { id: "ga-user-feedback", label: "User Feedback", href: "/admin/reports/user-feedback" }, - { id: "ga-session-export", label: "Session Export", href: "/admin/reports/session-export" }, + { id: "ga-session-reports", label: "Session Reports", href: route("/admin/reports/session-reports") }, + { id: "ga-distress-reports", label: "Distress Reports", href: route("/admin/reports/distress-reports") }, + { id: "ga-user-feedback", label: "User Feedback", href: route("/admin/reports/user-feedback") }, + { id: "ga-session-export", label: "Session Export", href: route("/admin/reports/session-export") }, ], }, { id: "ga-security", label: "Security", icon: , items: [ - { id: "ga-device-block", label: "Device Block", href: "/admin/security/device-block" }, - { id: "ga-version-block", label: "Version Block", href: "/admin/security/version-block" }, - { id: "ga-vulnerability", label: "Vulnerability Data", href: "/admin/security/vulnerability-data" }, + { id: "ga-device-block", label: "Device Block", href: route("/admin/security/device-block") }, + { id: "ga-version-block", label: "Version Block", href: route("/admin/security/version-block") }, + { id: "ga-vulnerability", label: "Vulnerability Data", href: route("/admin/security/vulnerability-data") }, ], }, { @@ -281,13 +283,13 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [ // are mutation surfaces; the reader gets redacted/read-only tenant config elsewhere). id: "ga-settings", label: "Settings", icon: , visibility: "globalAdminOnly", items: [ - { id: "ga-global", label: "Global Settings", href: "/admin/settings/global" }, - { id: "ga-diag-paths", label: "Diagnostics Log Paths", href: "/admin/settings/diagnostics-log-paths" }, - { id: "ga-mcp-users", label: "MCP Users", href: "/admin/settings/mcp-users" }, - { id: "ga-delegated-admins", label: "Delegated Admins", href: "/admin/settings/delegated-admins" }, - { id: "ga-tenant-groups", label: "Tenant Groups", href: "/admin/settings/tenant-groups" }, - { id: "ga-config-reseed", label: "Config Reseed", href: "/admin/settings/config-reseed" }, - { id: "ga-usage-plans", label: "Usage Plans", href: "/admin/settings/usage-plans" }, + { id: "ga-global", label: "Global Settings", href: route("/admin/settings/global") }, + { id: "ga-diag-paths", label: "Diagnostics Log Paths", href: route("/admin/settings/diagnostics-log-paths") }, + { id: "ga-mcp-users", label: "MCP Users", href: route("/admin/settings/mcp-users") }, + { id: "ga-delegated-admins", label: "Delegated Admins", href: route("/admin/settings/delegated-admins") }, + { id: "ga-tenant-groups", label: "Tenant Groups", href: route("/admin/settings/tenant-groups") }, + { id: "ga-config-reseed", label: "Config Reseed", href: route("/admin/settings/config-reseed") }, + { id: "ga-usage-plans", label: "Usage Plans", href: route("/admin/settings/usage-plans") }, ], }, { diff --git a/src/Web/autopilot-monitor-web/lib/routes.ts b/src/Web/autopilot-monitor-web/lib/routes.ts index 1b008bbc..183a5452 100644 --- a/src/Web/autopilot-monitor-web/lib/routes.ts +++ b/src/Web/autopilot-monitor-web/lib/routes.ts @@ -12,38 +12,41 @@ * Rule: the fragment (#...) always comes AFTER the query string. */ -function withQuery( - path: string, +import type { Route } from "next"; + +function withQuery( + path: Route, params: Record, hash?: string, -): string { +): Route { const qs = Object.entries(params) .filter(([, v]) => v !== undefined && v !== "") .map(([k, v]) => `${k}=${encodeURIComponent(v as string)}`) .join("&"); const fragment = hash ? (hash.startsWith("#") ? hash : `#${hash}`) : ""; - return `${path}${qs ? `?${qs}` : ""}${fragment}`; + // Appending a query/fragment to an already-validated Route keeps it valid. + return `${path}${qs ? `?${qs}` : ""}${fragment}` as Route; } export function sessionUrl( sessionId: string, opts?: { tenantId?: string; hash?: string }, -): string { +): Route { return withQuery("/sessions", { id: sessionId, tenantId: opts?.tenantId }, opts?.hash); } -export function inspectorUrl(sessionId: string, opts?: { tab?: string }): string { +export function inspectorUrl(sessionId: string, opts?: { tab?: string }): Route { return withQuery("/sessions/inspector", { id: sessionId, tab: opts?.tab }); } -export function diagnosisUrl(sessionId: string): string { +export function diagnosisUrl(sessionId: string): Route { return withQuery("/diagnosis", { id: sessionId }); } export function appDetailUrl( appName: string, opts?: { days?: string; tenantId?: string }, -): string { +): Route { return withQuery("/apps/detail", { name: appName, days: opts?.days, @@ -51,13 +54,43 @@ export function appDetailUrl( }); } -export function backupUrl(backupId: string): string { +export function backupUrl(backupId: string): Route { return withQuery("/admin/backups/detail", { id: backupId }); } -export function customsArchiveUrl(tenantId: string, historyRowKey: string): string { +export function deviceBlockUrl( + sessionId: string, + reason: string, + action?: "Block" | "Kill", +): Route { + return withQuery("/admin/security/device-block", { sessionId, reason, action }); +} + +export function customsArchiveUrl(tenantId: string, historyRowKey: string): Route { return withQuery("/admin/customs-archive/detail", { tenantId, rowKey: historyRowKey, }); } + +/** + * Compile-time validates a route literal that targets a DYNAMIC route (e.g. + * `/admin/settings/[section]`): the bare `Route` type only covers static + * routes, so dynamic-section literals — nav configs, section index redirects — + * go through this identity helper, where inference of `T` runs the literal + * against the generated route union. A typo'd prefix is a compile error. + */ +export function route(href: Route): Route { + return href as Route; +} + +/** + * Brands an in-app href that only exists at runtime (backend-emitted + * notification deep links, the persisted post-login return URL) for the typed + * router APIs. The compiler cannot validate data, only literals — producers of + * these hrefs are responsible for emitting canonical shapes (ideally via the + * builders above). Keep every such cast behind this single seam. + */ +export function trustedRoute(href: string): Route { + return href as Route; +} diff --git a/src/Web/autopilot-monitor-web/next.config.ts b/src/Web/autopilot-monitor-web/next.config.ts index 3c476864..97aa232a 100644 --- a/src/Web/autopilot-monitor-web/next.config.ts +++ b/src/Web/autopilot-monitor-web/next.config.ts @@ -25,6 +25,10 @@ const nextConfig: NextConfig = { // all client state (e.g. the sidebar collapses on each click). ...(isDev ? { skipTrailingSlashRedirect: true } : {}), reactStrictMode: true, + // Statically typed links: `next typegen` derives a Route union from the app + // tree, so a broken internal /router.push target is a compile error — + // the failure class behind the recent RSC/routing incidents. + typedRoutes: true, experimental: { // Rewrite these heavy packages to per-module imports so unused exports are // tree-shaken out of the route chunks that touch them.