@nsxbet/admin-sdk
v0.8.1
Published
SDK for building NSX Admin modules with integrated shell
Maintainers
Readme
@nsxbet/admin-sdk
SDK for building admin modules for the NSX Admin platform.
Installation
bun add @nsxbet/admin-sdk @nsxbet/admin-uiPeer dependencies:
# Shared with shell - use exact versions to avoid conflicts
bun add [email protected] [email protected] [email protected] [email protected] [email protected]
# Styling - use exact versions for design system consistency
bun add -D [email protected] [email protected] [email protected] [email protected]
# Build tools - flexible versions are fine
bun add -D @vitejs/plugin-react vite typescriptWhy exact versions? React, React Router, and i18next are shared at runtime - mismatches cause conflicts. Tailwind/PostCSS versions ensure consistent styling with the design system.
Quick Start (CLI)
The fastest way to create a new module is using the CLI:
# Scaffold a new module
npx @nsxbet/admin-cli init --id "@admin/my-module" --title "My Module" --route "/my-module" --port 3010
# Generate a setup verification checklist
npx @nsxbet/admin-cli checklist --output CHECKLIST.md
# Validate an existing module
npx @nsxbet/admin-cli check
# Auto-fix common issues
npx @nsxbet/admin-cli check --fixThe CLI scaffolds all 17 required files (including 4 locale files) with correct configuration, dependency versions, and SDK integration. It also validates existing modules against the canonical reference.
Required for LLMs: After creating or modifying a module, run
npx @nsxbet/admin-cli checklist --output CHECKLIST.mdand verify every item in the generated file before considering the setup complete. The checklist is a self-contained verification guide—no CLI installation needed to consume it.
Static checklist: A ready-to-use
CHECKLIST.mdis also shipped inside the SDK package atnode_modules/@nsxbet/admin-sdk/CHECKLIST.md. You can use it directly without installing the CLI.
Quick Start
This SDK enables you to build admin modules that integrate with the NSX Admin shell. Modules are loaded dynamically via React.lazy and share the shell's Router context.
Key concepts:
- Modules export a default React component from
spa.tsx - Modules share the shell's
BrowserRouter- useuseNavigate()fromreact-router-domdirectly - Use
@nsxbet/admin-uifor consistent UI components - Define module metadata in
admin.module.json
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Shell (BrowserRouter) │
│ ├── TopBar, LeftNav, CommandPalette │
│ └── <Route path="/your-module/*"> │
│ └── React.lazy(() => import(baseUrl/spa.js)) │
│ └── Your App component (shares Router context) │
└─────────────────────────────────────────────────────────────────┘Two entry points pattern:
| File | Purpose | When used |
|------|---------|-----------|
| src/spa.tsx | Default export of App | Shell loads this via React.lazy |
| src/main.tsx | Full app with AdminShell wrapper | Local development (npm run dev) |
Complete Module Example
Below are all the files needed for a working module. Copy these and modify for your needs.
File: vite.config.ts
import { defineModuleConfig } from "@nsxbet/admin-sdk/vite";
import react from "@vitejs/plugin-react";
export default defineModuleConfig({
port: 3003,
plugins: [react()],
});File: admin.module.json
Title, description, and command titles must be localized objects with all 4 locales (en-US, pt-BR, es, ro):
{
"id": "@admin/my-module",
"title": {
"en-US": "My Module",
"pt-BR": "Meu Módulo",
"es": "Mi Módulo",
"ro": "Modulul Meu"
},
"description": {
"en-US": "Description of what this module does",
"pt-BR": "Descrição do que este módulo faz",
"es": "Descripción de lo que hace este módulo",
"ro": "Descrierea a ceea ce face acest modul"
},
"category": "Tools",
"icon": "clipboard-list",
"routeBase": "/my-module",
"keywords": ["my", "module", "example"],
"navigation": {
"style": "stacked",
"sections": [
{ "id": "general", "label": { "en-US": "General", "pt-BR": "Geral", "es": "General", "ro": "General" } }
]
},
"commands": [
{
"id": "list",
"title": {
"en-US": "List Items",
"pt-BR": "Listar Itens",
"es": "Listar Elementos",
"ro": "Lista Elemente"
},
"route": "/my-module/list",
"icon": "file-text"
},
{
"id": "new",
"title": {
"en-US": "New Item",
"pt-BR": "Novo Item",
"es": "Nuevo Elemento",
"ro": "Element Nou"
},
"route": "/my-module/new",
"icon": "plus"
}
],
"permissions": {
"view": ["admin.mymodule.view"],
"edit": ["admin.mymodule.edit"],
"delete": ["admin.mymodule.delete"]
},
"owners": {
"team": "Platform",
"supportChannel": "#platform-support"
}
}File: src/spa.tsx
/**
* Module entry point for shell mode.
* The module shares the shell's Router context.
*/
import { App } from "./App";
export default App;File: src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import {
AdminShell,
initI18n,
createInMemoryAuthClient,
createMockUsersFromRoles,
} from "@nsxbet/admin-sdk";
import type { AdminModuleManifest } from "@nsxbet/admin-sdk";
import { App } from "./App";
import manifest from "../admin.module.json";
import "./index.css";
// Initialize i18n BEFORE shell renders.
// The Vite plugin (admin-module-i18n) auto-injects module translation registration into spa.tsx and main.tsx.
initI18n();
const moduleManifest = manifest as AdminModuleManifest;
const mockUsers = createMockUsersFromRoles({
admin: ["admin.mymodule.view", "admin.mymodule.edit", "admin.mymodule.delete"],
editor: ["admin.mymodule.view", "admin.mymodule.edit"],
viewer: ["admin.mymodule.view"],
noAccess: [],
});
const authClient = createInMemoryAuthClient({ users: mockUsers });
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<AdminShell
modules={[moduleManifest]}
authClient={authClient}
>
<App />
</AdminShell>
</React.StrictMode>
);Module standalone dev uses the in-memory client and UserSelector, which fetches JWTs via GET {gateway}/auth/token. Do not pass bff here — BFF / Okta cookie auth is for the platform shell in production, not for local module dev servers.
File: src/App.tsx
import { Routes, Route, Navigate } from "react-router-dom";
import { ItemList } from "./ItemList";
import { NewItem } from "./NewItem";
export function App() {
return (
<Routes>
{/* Redirect root to list */}
<Route path="/" element={<Navigate to="list" replace />} />
<Route path="list" element={<ItemList />} />
<Route path="new" element={<NewItem />} />
</Routes>
);
}File: src/ItemList.tsx
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth, usePlatformAPI, useTelemetry } from "@nsxbet/admin-sdk";
import { Button, Card, CardContent, Badge } from "@nsxbet/admin-ui";
export function ItemList() {
const navigate = useNavigate();
const { hasPermission } = useAuth();
const { api } = usePlatformAPI();
const { track } = useTelemetry();
const canEdit = hasPermission("admin.mymodule.edit");
useEffect(() => {
// Set breadcrumbs
api?.nav.setBreadcrumbs([
{ label: "My Module" },
{ label: "List" }
]);
// Track page view
track("page.viewed", { page: "item_list" });
}, [api, track]);
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-3xl font-bold">Items</h1>
{canEdit && (
<Button onClick={() => navigate("/my-module/new")}>
New Item
</Button>
)}
</div>
<Card>
<CardContent className="p-4">
<p className="text-muted-foreground">No items yet.</p>
</CardContent>
</Card>
</div>
);
}File: src/index.css
@import "@nsxbet/admin-ui/styles.css";
@tailwind base;
@tailwind components;
@tailwind utilities;Directory: src/i18n/ (required for translations)
All 4 locale files are required (en-US, pt-BR, es, ro). The Vite plugin validates this at build time.
Structure:
src/i18n/
locales/
en-US.json
pt-BR.json
es.json
ro.jsonNo manual registration needed. The admin-module-i18n Vite plugin (included in defineModuleConfig) auto-injects registerModuleTranslations into spa.tsx and main.tsx at build/serve time. Just create the locale files.
Example src/i18n/locales/en-US.json (content translations only — nav titles live in admin.module.json):
{
"list": {
"title": "All Items",
"noItems": "No items found.",
"create": "Create Item"
},
"form": {
"titleLabel": "Title",
"titlePlaceholder": "Enter title",
"cancel": "Cancel",
"submit": "Submit"
}
}Use translations in components:
import { useI18n } from "@nsxbet/admin-sdk";
function MyComponent() {
const { t } = useI18n("my-module");
return <h1>{t("list.title")}</h1>;
}The namespace is derived from your module id (e.g. @admin/my-module → my-module).
File: tailwind.config.js
Use withAdminSdk which automatically includes the UI preset and SDK/UI content paths:
import { withAdminSdk } from "@nsxbet/admin-sdk/tailwind";
/** @type {import('tailwindcss').Config} */
export default withAdminSdk({
content: ["./index.html", "./src/**/*.{ts,tsx}"],
});File: package.json
{
"name": "@admin/my-module",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@nsxbet/admin-sdk": "latest",
"@nsxbet/admin-ui": "latest",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router-dom": "6.20.1",
"i18next": "25.0.0",
"react-i18next": "16.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "10.4.16",
"postcss": "8.4.32",
"tailwindcss": "3.4.0",
"tailwindcss-animate": "1.0.7",
"typescript": "^5.2.0",
"vite": "^5.0.0"
}
}Note: Exact versions for runtime deps (react, i18next) avoid conflicts with the shell. Exact versions for styling (tailwindcss, postcss) ensure design system consistency.
Note:
module.manifest.jsonis generated automatically bydefineModuleConfigat build time. No manual copy step is needed.
File: tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "admin.module.json"]
}File: index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Module - Standalone</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>File: postcss.config.js
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};File: src/globals.d.ts
TypeScript declarations for environment variables and platform API:
/// <reference types="vite/client" />
declare global {
interface ImportMetaEnv {
readonly VITE_ALLOWED_MODULE_ORIGINS?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
interface Window {
__ADMIN_PLATFORM_API__?: import("@nsxbet/admin-sdk").PlatformAPI;
__ENV__?: {
ENVIRONMENT: string;
};
}
}
export {};File: tsconfig.node.json
Separate TypeScript config for Vite configuration file:
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}File: .env.local (optional)
Environment configuration for development:
# Use mock authentication with user selector (default: true)
MOCK_AUTH=true
# Module URL allowlist (shell mode only). Comma-separated patterns.
# Supports *.domain wildcard (e.g. *.nsx.dev matches modules.nsx.dev, nsx.dev).
# In dev mode, localhost and 127.0.0.1 are always allowed.
# VITE_ALLOWED_MODULE_ORIGINS=*.nsx.dev,*.nsx.servicesManifest Schema (admin.module.json)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | string | ✅ | Unique identifier (e.g., @admin/tasks) |
| title | string | ✅ | Human-readable title |
| routeBase | string | ✅ | Base route path (must start with /) |
| description | string | | What the module does |
| category | string | | Navigation grouping |
| icon | string | | Lucide icon name in kebab-case |
| keywords | string[] | | Search keywords |
| navigation | object | | Navigation config (style, sections) |
| commands | Command[] | | Available actions |
| permissions | object | | Permission configuration |
| owners | object | | Team ownership info |
Command Schema
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| id | string | ✅ | Unique command identifier |
| title | string | ✅ | Command title |
| route | string | ✅ | Full route path |
| icon | string | | Lucide icon name |
| keywords | string[] | | Search keywords |
| section | string | | Section ID for stacked navigation grouping |
Stacked Navigation
Modules with many commands can use stacked navigation for a dedicated sidebar panel with sectioned command grouping.
Enable stacked navigation by adding a navigation field to admin.module.json:
{
"navigation": {
"style": "stacked",
"sections": [
{
"id": "general",
"label": { "en-US": "General", "pt-BR": "Geral", "es": "General", "ro": "General" }
},
{
"id": "advanced",
"label": { "en-US": "Advanced", "pt-BR": "Avançado", "es": "Avanzado", "ro": "Avansat" }
}
]
},
"commands": [
{ "id": "list", "title": {...}, "route": "/mod/list", "section": "general" },
{ "id": "settings", "title": {...}, "route": "/mod/settings", "section": "advanced" }
]
}style:"stacked"enables the dedicated panel;"collapsible"(default) keeps the inline expand behaviorsections: Array of section definitions withidand localizedlabel- Commands reference sections via the
sectionfield matching a sectionid - Commands without a
sectionappear in an implicit top-level group - The stacked panel is URL-driven: navigating to the module's
routeBaseactivates it
Icon Names
Use Lucide icon names in kebab-case:
| Category | Icons |
|----------|-------|
| Navigation | home, settings, menu, search, chevron-right |
| Actions | plus, edit, trash, copy, check |
| Content | file, file-text, folder, clipboard-list |
| Users | user, users |
| Status | alert-circle, info, ban, lock |
Vite Configuration
The SDK provides two ways to configure Vite for admin modules:
defineModuleConfig()— returns a complete Vite config. Best for new modules with no existing Vite setup.adminModule()— returns aPlugin[]you spread into an existing config. Best for Lovable projects or any existing Vite config.
Both produce identical build artifacts. Both support @vitejs/plugin-react and @vitejs/plugin-react-swc.
Note:
module.manifest.jsonis generated automatically at build time by bothdefineModuleConfigandadminModule. No manualcp admin.module.json dist/step is needed.
defineModuleConfig() — Full Config
import { defineModuleConfig } from "@nsxbet/admin-sdk/vite";
import react from "@vitejs/plugin-react";
export default defineModuleConfig({
port: 3003,
plugins: [react()], // REQUIRED: you must add a React plugin
});Important: Each module must use a unique port. The default is
8080(matching Lovable's default). Standard assignments:
- Shell: 3000
- API: 4000
- Modules: 3002, 3003, 3004, etc.
defineModuleConfig Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| port | number | 8080 | Dev server port |
| entry | string | "./src/spa.tsx" | Entry file path |
| outDir | string | "dist" | Output directory |
| plugins | Plugin[] | [] | Additional Vite plugins |
| additionalExternals | string[] | [] | Extra externals |
| overrides | object | {} | Override any Vite config |
adminModule() — Composable Plugin (Lovable / Custom Vite Config)
Use adminModule() when you already have a vite.config.ts and want to add admin module support without replacing it. This is the recommended approach for Lovable projects.
adminModule() only affects vite build — during vite dev, your app runs as a normal SPA with HMR working as expected.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { componentTagger } from "lovable-tagger";
import { adminModule } from "@nsxbet/admin-sdk/vite";
export default defineConfig(({ mode }) => ({
server: {
host: "::",
port: 8080,
hmr: { overlay: false },
},
plugins: [
react(),
mode === "development" && componentTagger(),
...adminModule(),
].filter(Boolean),
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}));adminModule Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| entry | string | "./src/spa.tsx" | Entry file path |
| outDir | string | "dist" | Output directory |
| additionalExternals | string[] | [] | Extra externals |
| gatewayUrl | string \| null | undefined | Admin gateway URL for env injection. undefined = auto-inject staging URL, string = inject custom URL, null = disable injection. Explicit env vars always take precedence. |
Automatic Environment Injection
adminModule() includes a plugin that serves /env.js in dev/preview and injects <script src="/env.js"> into index.html. The script sets window.__ENV__ (including ADMIN_GATEWAY_URL from your .env or a default staging URL). The SDK reads this via import { env } from "@nsxbet/admin-sdk".
Precedence order (highest to lowest):
- Explicit env var (
.env,.env.local, shell environment) gatewayUrloption passed toadminModule()ordefineModuleConfig()- Default:
https://admin-bff-stg.nsx.dev(staging)
Known gateway URLs:
| Environment | URL |
|---|---|
| Staging | https://admin-bff-stg.nsx.dev |
| Homol | https://admin-bff-homol.nsx.dev |
| Local | http://localhost:8080 |
Examples:
// Default: staging URL auto-injected
...adminModule()
// Custom URL
...adminModule({ gatewayUrl: "http://localhost:8080" })
// Disable injection entirely
...adminModule({ gatewayUrl: null })Shared Externals
These dependencies are provided by the shell (do not bundle them):
["react", "react-dom", "react-router-dom", "i18next", "react-i18next"]Getting Started in Lovable
Lovable projects come with a default vite.config.ts, index.html, and src/main.tsx entry point. To turn a Lovable project into an admin module:
- Install the SDK:
npm install @nsxbet/admin-sdk @nsxbet/admin-ui- Add
adminModule()to your existingvite.config.ts:
import { adminModule } from "@nsxbet/admin-sdk/vite";
// Inside your plugins array:
plugins: [
react(),
mode === "development" && componentTagger(),
...adminModule(),
].filter(Boolean),Create
admin.module.jsonat the project root with your module metadata.Create
src/spa.tsx— the shell entry point:
import { App } from "./App";
export default App;- Update
src/main.tsx— wrap your app withAdminShellfor standalone dev:
import { AdminShell, createInMemoryAuthClient, createMockUsersFromRoles } from "@nsxbet/admin-sdk";
import manifest from "../admin.module.json";
import { App } from "./App";
const mockUsers = createMockUsersFromRoles({
admin: ["admin.mymodule.view", "admin.mymodule.edit"],
editor: ["admin.mymodule.view", "admin.mymodule.edit"],
viewer: ["admin.mymodule.view"],
noAccess: [],
});
const authClient = createInMemoryAuthClient({ users: mockUsers });
ReactDOM.createRoot(document.getElementById("root")!).render(
<AdminShell modules={[manifest]} authClient={authClient}>
<App />
</AdminShell>
);- Build:
npm run buildproducesdist/assets/spa-[hash].js+dist/module.manifest.json
Your Lovable preview (vite dev) continues to work as before — adminModule() only changes the vite build output.
ESLint Plugin
Install @nsxbet/eslint-plugin-admin (requires ESLint 9+) to catch common mistakes at development time:
// eslint.config.js
import adminPlugin from "@nsxbet/eslint-plugin-admin";
export default [adminPlugin.configs.recommended];This enables all recommended rules:
@nsxbet/no-raw-fetch(error) — flags directfetch()/window.fetch()calls that bypass authentication. UseuseFetch()instead.@nsxbet/no-raw-date-format(warn) — flags direct date formatting (toLocaleDateString,toLocaleTimeString,Intl.DateTimeFormat, dayjs/moment.format(), date-fnsformat) that bypasses the platform timezone preference. Use<Timestamp />oruseTimestamp()instead.
SDK Hooks
useAuth()
Access authentication and permissions.
import { useAuth } from "@nsxbet/admin-sdk";
function MyComponent() {
const { hasPermission, getUser, getAccessToken, logout } = useAuth();
if (!hasPermission("admin.mymodule.view")) {
return <div>Access denied</div>;
}
const user = getUser();
console.log(user.email, user.displayName);
}| Method | Returns | Description |
|--------|---------|-------------|
| hasPermission(perm) | boolean | Check if user has permission. Returns false during auth initialization until auth completes. |
| getUser() | User | Get current user info |
| getAccessToken() | Promise<string> | Get JWT token |
| logout() | void | Log out user |
usePlatformAPI()
Access the shell's platform API.
import { usePlatformAPI } from "@nsxbet/admin-sdk";
function MyComponent() {
const { api, isShellMode } = usePlatformAPI();
useEffect(() => {
api?.nav.setBreadcrumbs([
{ label: "My Module" },
{ label: "Current Page" }
]);
}, [api]);
}useFetch()
Make authenticated API requests.
import { useFetch } from "@nsxbet/admin-sdk";
function MyComponent() {
const fetch = useFetch();
const loadData = async () => {
const response = await fetch("/api/data");
const data = await response.json();
};
}useTelemetry()
Track events and errors.
import { useTelemetry } from "@nsxbet/admin-sdk";
function MyComponent() {
const { track, trackError } = useTelemetry();
const handleClick = () => {
track("button.clicked", { buttonId: "save" });
};
const handleError = (error: Error) => {
trackError(error, { context: "save_operation" });
};
}useI18n()
Manage translations and locale.
import { useI18n } from "@nsxbet/admin-sdk";
function MyComponent() {
const { t, locale, setLocale } = useI18n();
return (
<div>
<h1>{t("common.title")}</h1>
<p>Current locale: {locale}</p>
</div>
);
}useTimestamp()
Timezone-aware date formatting that respects the shell's UTC/Local preference.
import { useTimestamp, Timestamp } from "@nsxbet/admin-sdk";
function MyComponent() {
const { mode, setMode, formatDate, timezone } = useTimestamp();
return (
<div>
<p>Timezone: {timezone} ({mode})</p>
<p>Formatted: {formatDate(new Date(), "datetime")}</p>
<Timestamp value={new Date()} format="date" />
</div>
);
}| Property | Type | Description |
|----------|------|-------------|
| mode | "utc" \| "local" | Current timezone mode |
| setMode | (mode) => void | Change the timezone mode |
| formatDate | (date, format?) => string | Format a date with current mode and locale |
| timezone | string | Resolved IANA timezone string ("UTC" or browser local) |
Format presets:
| Preset | Example (UTC, en-US) | Description |
|--------|----------------------|-------------|
| "datetime" (default) | "Mar 16, 2026, 2:30:05 PM UTC" | Full date and time |
| "date" | "Mar 16, 2026" | Date only |
| "time" | "2:30:05 PM UTC" | Time only |
| "relative" | "5 minutes ago" | Relative to now (timezone-independent) |
In shell mode, reads from window.__ADMIN_PLATFORM_API__.timestamp. In standalone mode, falls back to localStorage key admin-timezone-mode (default: "local").
<Timestamp /> Component
Renders a formatted date that automatically respects the shell's timezone preference.
import { Timestamp } from "@nsxbet/admin-sdk";
// Basic usage (datetime format)
<Timestamp value={new Date("2026-03-16T14:30:05Z")} />
// Date only
<Timestamp value={createdAt} format="date" />
// Relative time
<Timestamp value={updatedAt} format="relative" />
// String input (auto-parsed)
<Timestamp value="2026-03-16T14:30:05Z" format="time" />| Prop | Type | Default | Description |
|------|------|---------|-------------|
| value | Date \| string | required | The date to display |
| format | TimestampFormat | "datetime" | Format preset |
| className | string | | CSS class for the <time> element |
Renders a semantic <time> element with a dateTime attribute. Hovering shows a tooltip with the date in the opposite timezone mode.
Migration guide:
| Before | After |
|--------|-------|
| date.toLocaleDateString() | <Timestamp value={date} format="date" /> |
| date.toLocaleString() | <Timestamp value={date} /> |
| date.toLocaleTimeString() | <Timestamp value={date} format="time" /> |
| new Intl.DateTimeFormat(...).format(date) | <Timestamp value={date} /> |
useRegistryPolling()
Detect catalog changes via lightweight version polling. Used by the shell to show an "Updates available" banner without forcing a reload.
import { useRegistryPolling } from "@nsxbet/admin-sdk";
const { hasUpdates, dismiss } = useRegistryPolling({
registryClient,
initialVersion: catalog.version,
interval: 60000, // poll every 60s, 0 to disable
});| Property | Type | Description |
|----------|------|-------------|
| hasUpdates | boolean | true when the server version differs from the loaded version |
| dismiss | () => void | Hide the banner for the current version; re-shows on newer versions |
Options:
| Option | Type | Description |
|--------|------|-------------|
| registryClient | RegistryClient | The registry client instance |
| initialVersion | string | Version string from the initial catalog.get() response |
| interval | number | undefined | Polling interval in ms. 0 or undefined disables polling |
The hook integrates the Page Visibility API — polling pauses when the tab is hidden and resumes with an immediate check when the tab becomes visible again. Network errors are silently ignored.
catalog.version()
Both HTTP and in-memory registry clients expose a catalog.version() method:
const { version, generatedAt } = await registryClient.catalog.version();The HTTP client calls GET /api/catalog/version. The in-memory client derives a version from the local mutation state.
Environment configuration
The polling interval is resolved from REGISTRY_POLL_INTERVAL (milliseconds):
| Source | Example |
|--------|---------|
| Explicit registryPollInterval prop on AdminShell | Shell passes from env config |
| window.__ENV__.REGISTRY_POLL_INTERVAL | Fallback for non-shell consumers |
| Environment default | local: 60s, staging: 5min, production: 15min |
Set REGISTRY_POLL_INTERVAL=0 to disable polling entirely.
Navigation
Use useNavigate from react-router-dom directly:
import { useNavigate } from "react-router-dom";
function MyComponent() {
const navigate = useNavigate();
return (
<Button onClick={() => navigate("/my-module/new")}>
New Item
</Button>
);
}Custom Mock Users for Development
During standalone development, the SDK uses an in-memory auth client with default mock users. You can customize these users to match your module's specific permission requirements.
Using createMockUsersFromRoles()
The simplest way to create custom mock users is with the createMockUsersFromRoles() factory:
import {
AdminShell,
createInMemoryAuthClient,
createMockUsersFromRoles,
} from "@nsxbet/admin-sdk";
// Define roles for your module
const mockUsers = createMockUsersFromRoles({
admin: [
"admin.payments.view",
"admin.payments.edit",
"admin.payments.delete",
],
editor: ["admin.payments.view", "admin.payments.edit"],
viewer: ["admin.payments.view"],
noAccess: [],
});
// Create auth client with custom users
const authClient = createInMemoryAuthClient({ users: mockUsers });
// Use in main.tsx
ReactDOM.createRoot(document.getElementById("root")!).render(
<AdminShell authClient={authClient} modules={[manifest]}>
<App />
</AdminShell>
);This creates 4 standard user types with your custom roles:
- Admin User - Full access (all roles in
adminarray) - Editor User - View/edit access (roles in
editorarray) - Viewer User - View-only access (roles in
viewerarray) - No Access User - No permissions (roles in
noAccessarray)
Using Custom Users Directly
For more control, pass custom MockUser objects directly:
import { createInMemoryAuthClient, type MockUser } from "@nsxbet/admin-sdk";
const customUsers: MockUser[] = [
{
id: "super-admin",
email: "[email protected]",
displayName: "Super Admin",
roles: ["*"], // Wildcard: all permissions
},
{
id: "payments-admin",
email: "[email protected]",
displayName: "Payments Admin",
roles: ["admin.payments.view", "admin.payments.edit"],
},
];
const authClient = createInMemoryAuthClient({ users: customUsers });Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| users | MockUser[] | required | Mock users available for selection |
| storageKey | string | "@nsxbet/auth" | localStorage key for persistence |
| gatewayUrl | string | null | null | Admin gateway URL for real JWT tokens |
| tokenTimeout | number | 5000 | Timeout in ms for gateway token fetch |
Gateway Token Integration (BFF)
When gatewayUrl is passed explicitly, the InMemory auth client fetches real signed JWTs from the admin gateway instead of returning mock token strings. This enables end-to-end testing against backends that validate tokens.
How it works:
- Developer clicks a user card in the UserSelector
- The client calls
GET {gatewayUrl}/auth/token?sub=...&email=...&roles=...&scopes=... - The gateway returns a signed JWT which is cached in memory
getAccessToken()returns the cached JWT for all subsequent API calls- When the token nears expiry (within 60s), a background refresh is triggered
Gateway URL resolution order:
- Explicit
gatewayUrloption passed tocreateInMemoryAuthClient()(ornullto disable gateway tokens) - Otherwise
env.adminGatewayUrlfromwindow.__ENV__(set by/env.jsfrom the shell or theadminModule()dev server)
Vite plugin: When using
adminModule()ordefineModuleConfig(),/env.jsis served in dev and listsADMIN_GATEWAY_URLfrom your.envor the default staging gateway. See the Vite Configuration section for override options (gatewayUrl: nulldisables/env.jsentirely).
Error handling:
The UserSelector shows loading, error, and timeout states during the token fetch. If the gateway is unreachable, the developer can:
- Retry the token fetch
- Continue with mock token to fall back to legacy behavior
- Go back to the user selection list
# Add to your .env file to enable gateway token integration
ADMIN_GATEWAY_URL=https://admin-bff-stg.nsx.devModule URL Allowlist (Shell Mode)
When the shell loads modules dynamically from URLs, it validates each URL against VITE_ALLOWED_MODULE_ORIGINS before import() or loadScript().
Format: Comma-separated patterns supporting *.domain wildcard (e.g. *.nsx.dev matches modules.nsx.dev, cdn.nsx.dev, and apex nsx.dev).
Dev mode: localhost and 127.0.0.1 are always allowed regardless of the allowlist, so local dev servers work without configuration.
Production: Set VITE_ALLOWED_MODULE_ORIGINS in your build environment. If unset or empty, all module loads fail with a clear error.
BFF / Okta cookie auth (recommended)
Production auth should go through admin-bff: the BFF sets an HttpOnly access_token cookie and exposes GET /me for identity. The shell does not read the JWT in the browser.
AdminShell
<AdminShell
modules={[manifest]}
bff
// or: bff={{ baseUrl: "https://admin.example.com" }}
/>Use the same origin as the BFF (or a dev proxy) so credentials: 'include' sends the cookie. getAccessToken() returns null for this client — use useFetch / api.fetch, which send credentialed requests when the token is null.
Production Guard
In production deployments, configure bff (admin-bff) for real authentication. If neither authClient nor bff is provided, AdminShell uses in-memory mock authentication.
DO NOT (Common Mistakes)
❌ DO NOT use MemoryRouter
// WRONG - don't create your own router
import { MemoryRouter, Routes, Route } from "react-router-dom";
function App() {
return (
<MemoryRouter> {/* ❌ WRONG */}
<Routes>
<Route path="/" element={<List />} />
</Routes>
</MemoryRouter>
);
}// CORRECT - use Routes directly, shell provides BrowserRouter
import { Routes, Route } from "react-router-dom";
function App() {
return (
<Routes>
<Route path="/" element={<List />} />
</Routes>
);
}❌ DO NOT create BrowserRouter in spa.tsx
// WRONG - shell already provides BrowserRouter
import { BrowserRouter } from "react-router-dom";
export default function App() {
return (
<BrowserRouter> {/* ❌ WRONG */}
<MyRoutes />
</BrowserRouter>
);
}// CORRECT - just export the component
import { App } from "./App";
export default App;❌ DO NOT forget the React plugin
// WRONG - missing React plugin
export default defineModuleConfig({
port: 3003,
// ❌ No plugins!
});// CORRECT - include React plugin
import react from "@vitejs/plugin-react";
export default defineModuleConfig({
port: 3003,
plugins: [react()], // ✅
});❌ DO NOT use external databases
// WRONG - no Supabase, Firebase, or external BaaS
import { createClient } from "@supabase/supabase-js"; // ❌
import { initializeApp } from "firebase/app"; // ❌// CORRECT - use authenticated fetch for internal APIs
import { useFetch } from "@nsxbet/admin-sdk";
const fetch = useFetch();
const data = await fetch("/api/internal-endpoint");❌ DO NOT format dates directly
// WRONG - bypasses platform timezone preference
date.toLocaleDateString(); // ❌
date.toLocaleTimeString(); // ❌
new Intl.DateTimeFormat("en-US").format(date); // ❌
dayjs(date).format("YYYY-MM-DD"); // ❌
import { format } from "date-fns"; format(date, "PP"); // ❌// CORRECT - use <Timestamp /> or useTimestamp()
import { Timestamp, useTimestamp } from "@nsxbet/admin-sdk";
<Timestamp value={date} format="date" /> // ✅
const { formatDate } = useTimestamp();
formatDate(date, "datetime"); // ✅Direct formatting bypasses the platform timezone preference, causing inconsistent timestamp display across modules. The @nsxbet/no-raw-date-format ESLint rule enforces this.
❌ DO NOT import useNavigate from SDK
// WRONG - useNavigate is not exported from SDK
import { useNavigate } from "@nsxbet/admin-sdk"; // ❌// CORRECT - import from react-router-dom
import { useNavigate } from "react-router-dom"; // ✅Troubleshooting
First step: Run npx @nsxbet/admin-cli checklist and verify all items. The checklist covers every setup requirement and often identifies misconfigurations quickly.
"Failed to resolve module specifier 'react'"
Cause: Module is bundling React instead of using shell's version.
Solution: Ensure vite.config.ts uses defineModuleConfig or has external: ["react", "react-dom", "react-router-dom"].
"process is not defined"
Cause: Module code references Node.js globals.
Solution: defineModuleConfig handles this automatically. If using custom config, add:
define: {
"process.env": {},
"process.env.NODE_ENV": JSON.stringify("production"),
}Module not loading in shell
Checklist:
- ✅
spa.tsxexports default component - ✅
admin.module.jsonhas validid,title,routeBase - ✅ Build outputs to
dist/withspa-[hash].js - ✅ Module is registered in shell's catalog
Styles not working
Run npx @nsxbet/admin-cli checklist to verify Tailwind/PostCSS configuration. Key items:
- ✅
index.cssimports@nsxbet/admin-ui/styles.css - ✅
tailwind.config.jsuseswithAdminSdkfrom@nsxbet/admin-sdk/tailwind - ✅
postcss.config.jsexists with tailwindcss plugin
Running Your Module
# Development (standalone with shell UI)
bun run dev
# Open http://localhost:3003
# Build for production
bun run build
# Output: dist/assets/spa-[hash].js
# Preview built module
bun run previewError Boundary with Module Ownership
When a module crashes at runtime, the error boundary displays ownership information to help with incident triage. The DynamicModule component accepts an optional moduleInfo prop that provides module metadata to the error boundary.
<DynamicModule
baseUrl="http://localhost:5001"
moduleInfo={{
id: "@admin/payments",
title: { "en-US": "Payments", "pt-BR": "Pagamentos", "es": "Pagos", "ro": "Plăți" },
owners: { team: "Payments", supportChannel: "#payments-support" },
}}
/>When a module error occurs, the error boundary will:
- Display the module name (localized for the current locale)
- Show the owner team and support channel (if provided)
- Render the support channel as a clickable link if it starts with
http/https - Provide both "Try Again" (re-render) and "Reload Page" (full reload) buttons
- Report the error to telemetry with module attribution (
moduleId,ownerTeam,errorType) - Capture unhandled promise rejections while the module is mounted and report them via telemetry
If owners.team is empty, the ownership section is omitted and the error boundary shows a standard error UI.
Platform API — Timestamp Namespace
The shell exposes window.__ADMIN_PLATFORM_API__.timestamp for timezone preference management:
| Property | Type | Description |
|----------|------|-------------|
| mode | "utc" \| "local" | Current timezone mode |
| setMode(mode) | (TimezoneMode) => void | Change the timezone mode |
| onModeChange(cb) | (callback) => () => void | Subscribe to changes; returns unsubscribe |
Note: Modules should use
useTimestamp()or<Timestamp />rather than accessing the Platform API directly.
Types
import type {
AdminModuleManifest,
ModuleCommand,
PlatformAPI,
User,
Breadcrumb,
ModuleInfo,
ErrorBoundaryProps,
TimezoneMode,
TimestampFormat,
UseTimestampResult,
} from "@nsxbet/admin-sdk";License
UNLICENSED - Internal use only
