feat(web): typed routes — broken internal links are now compile errors (#115)

typedRoutes: true (stable in Next 16; `next typegen` already runs in CI
before tsc since #114). A typo'd <Link>/router.push target now fails the
build instead of 404ing at runtime — the failure class behind the recent
RSC/routing incidents.

- lib/routes.ts: registry helpers return `Route`; withQuery validates the
  base path literal. New: deviceBlockUrl (OpsEventsSection previously
  hand-built /admin/security/device-block against the registry rule),
  route() to compile-check dynamic-section literals (nav configs,
  section index redirects), trustedRoute() as the single named seam for
  runtime-data hrefs (backend-emitted notification deep links, persisted
  post-login return URL).
- Nav types (NavItem, ExpandableSubItem, PageSectionItem) carry Route
  hrefs — every sidebar/navbar entry is compile-checked.
- ClientRedirect is generic so dynamic-section literals infer.

Verified: tsc clean; negative test (typo'd static + dynamic routes)
fails compilation as intended; 611/611 vitest; static export builds
with an unchanged route list.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-31 00:55:26 +02:00
committed by GitHub
parent cf45911b69
commit d65157a092
12 changed files with 125 additions and 78 deletions
@@ -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: <BuildingOfficeIcon /> },
{ 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: <BuildingOfficeIcon /> },
{ 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: <ChartBarIcon /> },
{ 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: <ChartBarIcon /> },
{ 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: <DocumentTextIcon /> },
{ 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: <DocumentTextIcon /> },
{ 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: <ShieldCheckIcon /> },
{ 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: <ShieldCheckIcon /> },
{ 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: <GearIcon /> },
{ 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: <GearIcon /> },
{ 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: <WrenchIcon /> },
@@ -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 (
<div className="mt-5 pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
@@ -574,14 +573,14 @@ export function OpsEventsSection({
View session
</Link>
<Link
href={`${baseHref}&action=Block`}
href={deviceBlockUrl(sessionId, reason, "Block")}
onClick={() => 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
</Link>
<Link
href={`${baseHref}&action=Kill`}
href={deviceBlockUrl(sessionId, reason, "Kill")}
onClick={() => 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"
>
@@ -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({
@@ -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()}`;
@@ -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<T extends string>({ to }: { to: Route<T> }) {
const router = useRouter();
useEffect(() => {
router.replace(to);
@@ -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
@@ -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() {
<p className="text-[10px] text-gray-400">{formatTime(new Date(tn.createdAt))}</p>
{tn.href && (
<Link
href={tn.href}
href={trustedRoute(tn.href)}
prefetch={false}
onClick={(e) => { 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() {
<p className="text-[10px] text-gray-400">{formatTime(new Date(gn.createdAt))}</p>
{gn.href && (
<Link
href={gn.href}
href={trustedRoute(gn.href)}
prefetch={false}
onClick={(e) => { 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() {
<p className="text-[10px] text-gray-400">{formatTime(notification.timestamp)}</p>
{notification.href && (
<Link
href={notification.href}
href={trustedRoute(notification.href)}
prefetch={false}
onClick={(e) => { e.stopPropagation(); markAsRead(notification.id); setShowNotifications(false); }}
className="text-[10px] text-green-700 hover:text-green-800 font-medium underline"
@@ -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";
@@ -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) */
@@ -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: <BuildingOfficeIcon />,
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: <GearIcon />,
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: <WrenchScrewdriverIcon />,
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: <ChartBarIcon />,
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: <BuildingOfficeIcon />,
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: <ChartBarIcon />,
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: <DocumentTextIcon />,
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: <ShieldCheckIcon />,
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: <GearIcon />, 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") },
],
},
{
+43 -10
View File
@@ -12,38 +12,41 @@
* Rule: the fragment (#...) always comes AFTER the query string.
*/
function withQuery(
path: string,
import type { Route } from "next";
function withQuery<T extends string>(
path: Route<T>,
params: Record<string, string | undefined>,
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<T extends string>(href: Route<T>): 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;
}
@@ -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 <Link>/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.