Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions modules/Admin/src/SimpleModule.Admin/Pages/Admin/Hub.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,15 +124,27 @@ function CardIcon({ icon }: { icon: string }) {
);
}

export default function Hub() {
interface HubProps {
availableUrls?: string[];
}

export default function Hub({ availableUrls }: HubProps) {
const available = new Set((availableUrls ?? []).map((u) => u.toLowerCase()));
const visibleGroups = groups
.map((group) => ({
...group,
items: group.items.filter((i) => available.has(i.url.toLowerCase())),
}))
.filter((group) => group.items.length > 0);

return (
<PageShell
title="Administration"
description="Manage your application settings, users, and system configuration"
breadcrumbs={[{ label: 'Home', href: '/' }, { label: 'Admin' }]}
>
<div className="space-y-8">
{groups.map((group) => (
{visibleGroups.map((group) => (
<section key={group.title}>
<h2 className="text-sm font-semibold uppercase tracking-wider text-text-muted mb-3">
{group.title}
Expand Down
47 changes: 46 additions & 1 deletion modules/Admin/src/SimpleModule.Admin/Pages/Admin/HubEndpoint.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,54 @@ public class HubEndpoint : IViewEndpoint
{
public const string Route = AdminConstants.Routes.Hub;

// Must stay in sync with the `url` values in Hub.tsx's `groups`. URLs not
// present in the app's endpoint table are filtered out so the hub doesn't
// surface 404 tiles for peer modules that aren't installed.
private static readonly string[] CandidateUrls =
[
"/admin/users",
"/admin/roles",
"/openiddict/clients",
"/tenants/manage",
"/pages/manage",
"/email/templates",
"/email/history",
"/settings/menus",
"/feature-flags/manage",
"/rate-limiting/manage",
"/admin/jobs",
"/audit-logs/browse",
"/settings/manage",
];

private string[]? _availableUrls;

public void Map(IEndpointRouteBuilder app)
{
app.MapGet(Route, () => Inertia.Render("Admin/Admin/Hub"))
app.MapGet(
Route,
(EndpointDataSource endpointDataSource) =>
{
_availableUrls ??= ComputeAvailableUrls(endpointDataSource);
return Inertia.Render(
"Admin/Admin/Hub",
new { availableUrls = _availableUrls }
);
}
)
.RequireAuthorization(policy => policy.RequireRole("Admin"));
}

private static string[] ComputeAvailableUrls(EndpointDataSource endpointDataSource)
{
var registered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var endpoint in endpointDataSource.Endpoints)
{
if (endpoint is RouteEndpoint route)
{
registered.Add("/" + route.RoutePattern.RawText?.TrimStart('/'));
}
}
return CandidateUrls.Where(registered.Contains).ToArray();
}
}
53 changes: 53 additions & 0 deletions packages/SimpleModule.Client/src/define-module-config.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,60 @@
import { readdirSync, readFileSync } from 'node:fs';
import { basename, resolve } from 'node:path';
import react from '@vitejs/plugin-react';
import type { UserConfig } from 'vite';
import { defineConfig } from 'vite';
import { defaultVendors } from './vite-plugin-vendor.ts';

/**
* Detect the case-insensitive filesystem trap where `Pages/index.ts` (the
* framework-required barrel) sits next to a page like `Pages/Index.tsx` and
* the barrel uses an extension-less dynamic import (`() => import('./Index')`).
*
* On macOS/Windows, Rolldown resolves `./Index` back to `./index.ts` (the
* barrel itself), producing a self-referential chunk that throws at runtime
* with `Cannot assign to property 'layout' of [object Module]`.
*
* Throw early with a clear, actionable error.
*/
function assertNoBarrelCollision(dir: string): void {
const pagesDir = resolve(dir, 'Pages');

let entries: string[];
try {
entries = readdirSync(pagesDir);
} catch {
return;
}
if (!entries.includes('index.ts')) return;

const offending = entries.find((f) => /^index\.(tsx|jsx|js)$/i.test(f) && f !== 'index.ts');
if (!offending) return;

let barrel: string;
try {
barrel = readFileSync(resolve(pagesDir, 'index.ts'), 'utf8');
} catch {
return;
}

// Match extension-less dynamic imports whose specifier case-insensitively
// resolves to the barrel itself (`import('./Index')` next to `index.ts`).
// On case-insensitive filesystems Rolldown silently picks `./index.ts`
// (the barrel) rather than the sibling `./Index.tsx`, producing a chunk
// that re-exports the barrel and crashes at runtime.
if (!/import\(\s*['"]\.\/index['"]\s*\)/i.test(barrel)) return;

const specifier = offending.replace(/\.(tsx|jsx|js)$/i, '');
throw new Error(
`[@simplemodule/client] Pages/index.ts contains \`import('./${specifier}')\` which collides ` +
`with the barrel on case-insensitive filesystems (macOS/Windows). Rolldown will silently ` +
`emit a self-referential chunk and the page will fail at runtime with ` +
`"Cannot assign to property 'layout' of [object Module]".\n\n` +
`Fix: use the explicit file extension, e.g. \`import('./${offending}')\`, or rename ` +
`Pages/${offending} to a name that does not case-insensitively match 'index'.`,
);
}

/**
* Unified Vite config for SimpleModule modules.
*
Expand All@@ -20,6 +71,8 @@ import { defaultVendors } from './vite-plugin-vendor.ts';
* ```
*/
export function defineModuleConfig(dir: string): UserConfig {
assertNoBarrelCollision(dir);

const name = basename(dir);
const isDev = process.env.VITE_MODE !== 'prod';

Expand Down
Loading