one console shell, one image, brand from the Host header

The shell and design system half of the console merge. lux.cloud's look and
feel comes across as tokens and shell geometry, so all 67 existing routes
inherit it without one page being edited, and the per-deployment brand is
replaced by a hostname resolve.

DESIGN SYSTEM — lux.cloud's palette becomes the token layer
  app/globals.css now states lux.cloud's scheme in the variables Tailwind
  already reads: #000 ground, #f5f5f5 ink, 55% muted, 8% hairline / 18% strong
  border, #1a1a1a active fill, its status greens/ambers/reds, 10px/16px radii,
  clamp() display type, -0.01em tracking. Light is a faithful inversion, kept
  because the app ships a theme toggle. Because these are the same variables
  `bg-background` / `border-border` / `text-muted-foreground` always resolved
  to, every page changed appearance without changing.

  The shared primitives then say it in their own geometry: Card is lux's
  `.panel` (16px, hairline, no shadow — a shadow is invisible on black and
  grime on white), Table is `.vtable` (12px uppercase heads, border on the TOP
  of each row so the header needs no rule and the table ends flush), Badge is
  `.pill` with the status dot, Button and Input are `.btn` and `.stake-input`.

SHELL
  One header for marketing and console, as on lux.cloud — 57px, blurred, one
  hairline, brand lockup with the retracting wordmark, actions hard right,
  collapsing behind a single hamburger below `lg` into an OPAQUE sheet (a
  nested backdrop-filter re-roots the backdrop, the bug lux.cloud documents).
  ConsoleLayout adds the one thing the console needs: a 236px sticky rail that
  becomes a horizontally-scrolling strip on a phone — 7% of a 390x844 screen
  measured, against the 27% a wrapped grid of pills costs.

  Deleted: unified-navigation.tsx, a second console-only top bar with its own
  logo, an org switcher whose options changed nothing, and a bell wired to no
  notification source. The hard-coded `pl-64 pt-16` offsets are now the
  --header-h / --sidebar-w tokens, so the header and rail cannot disagree.

  Loading, empty and error are three components in one file instead of nine
  hand-rolled spinners at four sizes, and the callback page's `bg-green-100` /
  `text-red-600` — the only two colours in the app that ignored the tokens and
  so stayed light-mode on a black page — are gone.

WHITE-LABEL BY HOSTNAME — one image, five hosts
  lib/brand.ts resolves brand, theme, API origin, IAM issuer and OIDC client
  from window.location.hostname. Every triple was verified against the live
  /v1/iam/oauth/authorize (302 = registered, 400 = not):

    id.bootno.de  bootnode-platform  bootno.de/auth/callback           302
    hanzo.id      hanzo-web3         web3.hanzo.ai/auth/callback       302
    lux.id        lux-cloud          lux.cloud/auth/callback           302
    zoolabs.id    zoo-cloud          zoo.cloud/auth/callback           302
    zoolabs.id    zoo-cloud          cloud.zoo.network/auth/callback   302
    zoolabs.id    zoo-web3           cloud.zoo.network/auth/callback   400  ← was shipping

  Deleted, because all three were the same defect — a value picked at deploy
  time while ONE deployment serves five hosts:
    • public/config.json + config.example.json (the shipped catalog had the
      400 client above, and named apiUrls whose hosts do not resolve)
    • the Dockerfile's NEXT_PUBLIC_BRAND / _API_URL / _WS_URL, which were also
      inert — the runtime is the Go static binary, which never reads env
    • four near-identical Deployments differing only in those variables, now
      one, with the nine ingress rules pointing at the single service
    • NEXT_PUBLIC_AUTH_MODE and four NEXT_PUBLIC_* docs URLs

  Matching is exact-or-dot-suffix, never substring: `includes("lux.cloud")`
  hands lux.cloud's OIDC client to lux.cloud.attacker.example.

  Each brand also gets its own mark. public/logo/{brand}-icon.svg existed for
  all five and nothing used them — every brand pointed at one shared
  /logo.svg. bootnode's is fill="currentColor" and lux's is fill="white",
  which as an <img> (an isolated document) render invisible on black and on
  white respectively, so those two are painted in the current text colour via
  a CSS mask — which is what lux.cloud does with its own pyramid.

FIXED ALONG THE WAY
  • AuthProvider's local getApiUrl() shadowed the import at the top of the
    file and its last line called itself — infinite recursion on localhost,
    127.0.0.1, 192.168.* and 10.*. Every checkAuth/login/register in local
    development overflowed the stack. Deleted; lib/api's resolver is the one.
  • The console nav could not reach eight of its own pages —
    /dashboard/settings, assets/{tokens,nfts}, infrastructure/{lux,services,
    observability}, /chains, /agents rendered and were linked from nowhere.
  • No row was ever marked current: the image serves clean URLs as directory
    indexes, so the pathname is `/dashboard/` and an equality test never
    matched. Nested routes also lit two rows at once. Both are one selection
    rule now — longest matching href wins.
  • Two dead links in the chrome: /products (a directory of pages with no
    index — the static server answers it with the home page) and
    /docs/tutorials (a plain 404).

TESTS — 26, node:test with native TS stripping, no new dependency
  The five-host contract including the substring-matching attack; the nav
  reaching every built route; the trailing-slash and most-specific-row rules;
  and a whole-source dead-link check that derives routes by walking app/ so it
  cannot go stale. That check holds the shell at ZERO dead links and ratchets
  the 36 that predate this work in the docs and product pages — a test that
  fails on the day it is written gets deleted, one that stops the bleeding
  gets kept.

VERIFIED
  pnpm build exit 0, 77/77 pages, route set byte-identical to the baseline
  taken before any change. In a real browser against the built static export:
  body #000 / #f5f5f5, --border rgba(255,255,255,.08), header 57px sticky
  blur(24px), sidebar 236px sticky at top:57px, exactly one active row with
  #1a1a1a fill and aria-current, the phone strip 7% of the viewport scrolling
  inside itself with the body never scrolling sideways, the sheet opaque with
  no nested backdrop-filter, and the favicon, mark and "Sign in with Bootnode
  ID" all coming from the resolved brand.

Not deployed. No page bodies touched.
This commit is contained in:
2026-07-26 15:32:42 -07:00
parent 39cb316a15
commit 8f353bf348
41 changed files with 1779 additions and 1994 deletions
-1
View File
@@ -94,7 +94,6 @@ services:
container_name: bootnode-web-prod
environment:
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
ports:
- "3000:3000"
depends_on:
+4 -3
View File
@@ -168,9 +168,10 @@ services:
build:
context: ./web
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: http://localhost:8000
NEXT_PUBLIC_WS_URL: ws://localhost:8000
# No build args. Brand and API origin are resolved from the hostname at
# runtime (web/lib/brand.ts). To aim a local browser at a local API, set
# `bootnode_api_url` in localStorage — the one escape hatch, documented
# in web/lib/api.ts.
container_name: bootnode-web
environment:
NODE_ENV: production
-2
View File
@@ -223,8 +223,6 @@ services:
image: ghcr.io/bootnode/bootnode:web-latest
container_name: bootnode-web
restart: unless-stopped
environment:
NEXT_PUBLIC_API_URL: ${API_URL:-http://localhost:8000}
ports:
- "3001:3000"
depends_on:
-2
View File
@@ -294,8 +294,6 @@ spec:
ports:
- containerPort: 3000
env:
- name: NEXT_PUBLIC_API_URL
value: "https://api.bootno.de"
- name: NODE_ENV
value: "production"
livenessProbe:
+9 -9
View File
@@ -26,7 +26,7 @@ spec:
pathType: Prefix
backend:
service:
name: bootnode-web-hanzo
name: bootnode-web
port:
number: 3001
- host: api.web3.hanzo.ai
@@ -90,7 +90,7 @@ spec:
pathType: Prefix
backend:
service:
name: bootnode-web-lux
name: bootnode-web
port:
number: 3001
- host: api.web3.lux.network
@@ -156,7 +156,7 @@ spec:
pathType: Prefix
backend:
service:
name: bootnode-web-zoo
name: bootnode-web
port:
number: 3001
- host: zoo.cloud
@@ -166,7 +166,7 @@ spec:
pathType: Prefix
backend:
service:
name: bootnode-web-zoo
name: bootnode-web
port:
number: 3001
- host: www.zoo.cloud
@@ -176,7 +176,7 @@ spec:
pathType: Prefix
backend:
service:
name: bootnode-web-zoo
name: bootnode-web
port:
number: 3001
- host: api.web3.zoo.ngo
@@ -240,7 +240,7 @@ spec:
pathType: Prefix
backend:
service:
name: bootnode-web-pars
name: bootnode-web
port:
number: 3001
- host: api.web3.pars.id
@@ -304,7 +304,7 @@ spec:
pathType: Prefix
backend:
service:
name: bootnode-web-hanzo
name: bootnode-web
port:
number: 3001
- host: api.bootno.de
@@ -395,7 +395,7 @@ spec:
pathType: Prefix
backend:
service:
name: bootnode-web-lux
name: bootnode-web
port:
number: 3001
- host: api.cloud.lux.network
@@ -432,7 +432,7 @@ spec:
pathType: Prefix
backend:
service:
name: bootnode-web-lux
name: bootnode-web
port:
number: 3001
+2 -5
View File
@@ -1,7 +1,7 @@
# Bootnode Network Policies — Defense in Depth
#
# Architecture:
# Internet → Ingress → bootnode-api / bootnode-web-* / bootnode-docs
# Internet → Ingress → bootnode-api / bootnode-web / bootnode-docs
# bootnode-api → postgres, redis, datastore (internal only)
# bootnode-api → otel-collector.hanzo.svc (cross-namespace, traces/metrics)
# bootnode-api → api.hanzo.ai (external, LLM gateway for AI chat)
@@ -39,10 +39,7 @@ spec:
operator: In
values:
- bootnode-api
- bootnode-web-hanzo
- bootnode-web-lux
- bootnode-web-zoo
- bootnode-web-pars
- bootnode-web
- bootnode-docs
policyTypes:
- Ingress
+46 -325
View File
@@ -1,21 +1,38 @@
# Bootnode web portals — one universal image, brand selected at runtime
# Each org gets its own deployment with brand-specific env vars
# Bootnode web console — ONE deployment, every brand.
#
# This file used to declare four near-identical Deployments and four Services
# (bootnode-web-{hanzo,lux,zoo,pars}) running the SAME image, differing only in
# NEXT_PUBLIC_BRAND / _API_URL / _WS_URL / _IAM_URL / _IAM_CLIENT_ID /
# _IAM_DOMAIN. Two things were wrong with that:
#
# 1. It contradicted its own header comment ("one universal image, brand
# selected at runtime") — the brand was selected at DEPLOY time, four
# times, and had to be kept in sync with IAM by hand.
# 2. None of those variables did anything. The image is a Next.js static
# export served by the Go hanzoai/static binary; NEXT_PUBLIC_* is inlined
# into the client bundle when the image is BUILT, and no process in the
# container ever reads its environment. Four copies of a setting that was
# never applied.
#
# web/lib/brand.ts now resolves brand, theme, API origin, IAM issuer and OIDC
# client from window.location.hostname. One Deployment serves bootno.de,
# web3.hanzo.ai, lux.cloud, zoo.cloud and cloud.zoo.network, and adding the
# sixth host is an ingress rule plus a callback registration — no new workload.
---
# ========== Hanzo Portal (web3.hanzo.ai) ==========
apiVersion: apps/v1
kind: Deployment
metadata:
name: bootnode-web-hanzo
name: bootnode-web
namespace: bootnode
labels:
app: bootnode-web-hanzo
app: bootnode-web
component: frontend
brand: hanzo
spec:
replicas: 2
# Was 4 deployments x 2 replicas = 8 pods serving one identical bundle.
replicas: 3
selector:
matchLabels:
app: bootnode-web-hanzo
app: bootnode-web
strategy:
type: RollingUpdate
rollingUpdate:
@@ -24,55 +41,44 @@ spec:
template:
metadata:
labels:
app: bootnode-web-hanzo
app: bootnode-web
component: frontend
brand: hanzo
spec:
containers:
- name: web
image: ghcr.io/bootnode/bootnode-web:latest
image: ghcr.io/hanzoai/bootnode:latest-web
imagePullPolicy: Always
ports:
- name: http
containerPort: 3001
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: NEXT_PUBLIC_BRAND
value: "hanzo"
- name: NEXT_PUBLIC_API_URL
value: "https://api.cloud.hanzo.ai"
- name: NEXT_PUBLIC_WS_URL
value: "wss://ws.cloud.hanzo.ai"
- name: NEXT_PUBLIC_IAM_URL
value: "https://hanzo.id"
- name: NEXT_PUBLIC_IAM_CLIENT_ID
value: "hanzo-web3"
- name: NEXT_PUBLIC_IAM_DOMAIN
value: "hanzo.id"
- name: NEXT_PUBLIC_AUTH_MODE
value: "iam"
# No env. Every value that was here is now hostname-derived in the
# browser, which is the only layer that knows which brand is being
# served on this request.
resources:
requests:
cpu: "200m"
memory: "256Mi"
cpu: "100m"
memory: "128Mi"
limits:
cpu: "1000m"
memory: "1Gi"
memory: "512Mi"
# Probe `/`, not `/api/health`. The runtime is a static file server:
# `/` is the one path guaranteed to exist and it proves the bundle is
# actually being served. `/api/health` also broke the standing rule
# that public paths are /v1/*, never /api/*.
livenessProbe:
httpGet:
path: /api/health
path: /
port: http
initialDelaySeconds: 30
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /api/health
path: /
port: http
initialDelaySeconds: 15
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
@@ -80,13 +86,16 @@ spec:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
imagePullSecrets:
- name: ghcr-secret
---
apiVersion: v1
kind: Service
metadata:
name: bootnode-web-hanzo
name: bootnode-web
namespace: bootnode
spec:
type: ClusterIP
@@ -95,292 +104,4 @@ spec:
port: 3001
targetPort: http
selector:
app: bootnode-web-hanzo
---
# ========== Lux Portal (web3.lux.network) ==========
apiVersion: apps/v1
kind: Deployment
metadata:
name: bootnode-web-lux
namespace: bootnode
labels:
app: bootnode-web-lux
component: frontend
brand: lux
spec:
replicas: 2
selector:
matchLabels:
app: bootnode-web-lux
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: bootnode-web-lux
component: frontend
brand: lux
spec:
containers:
- name: web
image: ghcr.io/bootnode/bootnode-web:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 3001
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: NEXT_PUBLIC_BRAND
value: "lux"
- name: NEXT_PUBLIC_API_URL
value: "https://api.web3.lux.network"
- name: NEXT_PUBLIC_WS_URL
value: "wss://ws.web3.lux.network"
- name: NEXT_PUBLIC_IAM_URL
value: "https://lux.id"
- name: NEXT_PUBLIC_IAM_CLIENT_ID
value: "lux-web3"
- name: NEXT_PUBLIC_IAM_DOMAIN
value: "lux.id"
- name: NEXT_PUBLIC_AUTH_MODE
value: "iam"
resources:
requests:
cpu: "200m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "1Gi"
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
securityContext:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
imagePullSecrets:
- name: ghcr-secret
---
apiVersion: v1
kind: Service
metadata:
name: bootnode-web-lux
namespace: bootnode
spec:
type: ClusterIP
ports:
- name: http
port: 3001
targetPort: http
selector:
app: bootnode-web-lux
---
# ========== Zoo Portal (web3.zoo.ngo) ==========
apiVersion: apps/v1
kind: Deployment
metadata:
name: bootnode-web-zoo
namespace: bootnode
labels:
app: bootnode-web-zoo
component: frontend
brand: zoo
spec:
replicas: 2
selector:
matchLabels:
app: bootnode-web-zoo
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: bootnode-web-zoo
component: frontend
brand: zoo
spec:
containers:
- name: web
image: ghcr.io/bootnode/bootnode-web:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 3001
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: NEXT_PUBLIC_BRAND
value: "zoo"
- name: NEXT_PUBLIC_API_URL
value: "https://api.cloud.zoo.network"
- name: NEXT_PUBLIC_WS_URL
value: "wss://ws.cloud.zoo.network"
- name: NEXT_PUBLIC_IAM_URL
value: "https://id.zoo.network"
- name: NEXT_PUBLIC_IAM_CLIENT_ID
value: "zoo-web3"
- name: NEXT_PUBLIC_IAM_DOMAIN
value: "id.zoo.network"
- name: NEXT_PUBLIC_AUTH_MODE
value: "iam"
resources:
requests:
cpu: "200m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "1Gi"
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
securityContext:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
imagePullSecrets:
- name: ghcr-secret
---
apiVersion: v1
kind: Service
metadata:
name: bootnode-web-zoo
namespace: bootnode
spec:
type: ClusterIP
ports:
- name: http
port: 3001
targetPort: http
selector:
app: bootnode-web-zoo
---
# ========== Pars Portal (web3.pars.id) ==========
apiVersion: apps/v1
kind: Deployment
metadata:
name: bootnode-web-pars
namespace: bootnode
labels:
app: bootnode-web-pars
component: frontend
brand: pars
spec:
replicas: 2
selector:
matchLabels:
app: bootnode-web-pars
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: bootnode-web-pars
component: frontend
brand: pars
spec:
containers:
- name: web
image: ghcr.io/bootnode/bootnode-web:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 3001
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: NEXT_PUBLIC_BRAND
value: "pars"
- name: NEXT_PUBLIC_API_URL
value: "https://api.cloud.pars.network"
- name: NEXT_PUBLIC_WS_URL
value: "wss://ws.cloud.pars.network"
- name: NEXT_PUBLIC_IAM_URL
value: "https://pars.id"
- name: NEXT_PUBLIC_IAM_CLIENT_ID
value: "pars-web3"
- name: NEXT_PUBLIC_IAM_DOMAIN
value: "pars.id"
- name: NEXT_PUBLIC_AUTH_MODE
value: "iam"
resources:
requests:
cpu: "200m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "1Gi"
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 5
securityContext:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
imagePullSecrets:
- name: ghcr-secret
---
apiVersion: v1
kind: Service
metadata:
name: bootnode-web-pars
namespace: bootnode
spec:
type: ClusterIP
ports:
- name: http
port: 3001
targetPort: http
selector:
app: bootnode-web-pars
app: bootnode-web
+13 -18
View File
@@ -35,25 +35,20 @@ RUN corepack enable pnpm && pnpm install --frozen-lockfile
COPY . .
# Brand is resolved from the hostname at RUNTIME against the brand catalog
# shipped in public/config.json (lib/brand.ts). That is the only white-label
# mechanism.
# ONE image, every brand — and therefore NO brand build args.
#
# There are deliberately NO IAM build args. One image serves every white-label
# host, so a build-time NEXT_PUBLIC_IAM_URL / _CLIENT_ID is one global value
# shared by all brands — it cannot express per-host white-labeling, and because
# NEXT_PUBLIC_* is inlined into the client bundle it silently outranks the
# correct per-brand value with no way to fix it at deploy time. The defaults
# that used to live here were also both wrong: iam.bootno.de serves no TLS (525)
# and bootnode-web is not a registered client, so any rebuild taking the
# defaults would have broken sign-in outright.
ARG NEXT_PUBLIC_BRAND=bootnode
ARG NEXT_PUBLIC_API_URL=https://api.bootno.de
ARG NEXT_PUBLIC_WS_URL=wss://ws.bootno.de
ENV NEXT_PUBLIC_BRAND=$NEXT_PUBLIC_BRAND
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
# Brand, theme, API origin, IAM issuer and OIDC client are all resolved from
# window.location.hostname at runtime (web/lib/brand.ts). This image is
# byte-identical for bootno.de, web3.hanzo.ai, lux.cloud, zoo.cloud and
# cloud.zoo.network.
#
# What used to be here — ARG/ENV NEXT_PUBLIC_BRAND, _API_URL, _WS_URL — could
# not express that. NEXT_PUBLIC_* is inlined into the client bundle at build
# time, so each value is one global shared by every host the image serves;
# picking `bootnode` made the image wrong for the other four. It was also
# inert: the runtime stage is the Go hanzoai/static binary serving a static
# export, which never reads container env at all, so the deployment manifests
# that set these had no effect on the shipped bundle whatsoever.
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm run build
+17 -39
View File
@@ -5,6 +5,7 @@ import { Suspense, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useAuth } from "@/lib/auth"
import { getIam, takeReturnUrl } from "@/lib/iam"
import { EmptyState, ErrorState, Loading } from "@/components/ui/states"
function CallbackContent() {
const [status, setStatus] = useState<"loading" | "success" | "error">("loading")
@@ -45,53 +46,30 @@ function CallbackContent() {
}
}
// Three states, three shared primitives — no bespoke tick/cross SVGs, and no
// `bg-green-100` / `text-red-600` literals, which were the only two colours
// in the app that ignored the token layer and so stayed light-mode green and
// red on a black page.
if (status === "loading") return <Loading full label="Completing sign-in…" />
if (status === "error") {
return (
<div className="flex min-h-screen items-center justify-center">
<ErrorState title="Sign-in failed" description={`${error} Returning you to the login page…`} />
</div>
)
}
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-4">
{status === "loading" && (
<>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<h1 className="text-xl font-semibold">Completing authentication...</h1>
<p className="text-muted-foreground">Please wait while we verify your credentials</p>
</>
)}
{status === "success" && (
<>
<div className="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center mx-auto">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-xl font-semibold text-green-600">Authentication Successful!</h1>
<p className="text-muted-foreground">Redirecting to your dashboard...</p>
</>
)}
{status === "error" && (
<>
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 className="text-xl font-semibold text-red-600">Authentication Failed</h1>
<p className="text-muted-foreground">{error}</p>
<p className="text-sm text-muted-foreground">Redirecting to login...</p>
</>
)}
</div>
<EmptyState title="Signed in" description="Taking you to your dashboard…" />
</div>
)
}
export default function AuthCallback() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
</div>
}>
<Suspense fallback={<Loading full />}>
<CallbackContent />
</Suspense>
)
+20 -18
View File
@@ -1,27 +1,29 @@
"use client"
// Unified Dashboard Layout - Single layout for all dashboard pages
// Includes navigation, organization switching, team management
import { UnifiedNavigation } from "@/components/unified-navigation"
import { Navbar } from "@/components/navbar"
import { Footer } from "@/components/footer"
import { ConsoleLayout } from "@/components/console-layout"
import { ProtectedRoute } from "@/lib/auth"
interface DashboardLayoutProps {
children: React.ReactNode
}
export default function DashboardLayout({ children }: DashboardLayoutProps) {
// Marketing and console share ONE header, as they do on lux.cloud: the same
// bar, the same brand lockup, the same account cluster, so signing in does not
// teleport you into a different product. The console adds exactly one thing —
// the section sidebar — and that is what ConsoleLayout is.
//
// The previous layout mounted a second, console-only top bar (its own logo,
// its own account menu, an org switcher whose options changed nothing) above a
// fixed 64px rail, and hard-coded `pl-64 pt-16` into the content. Both bars are
// gone; the offsets now come from the --header-h / --sidebar-w tokens so the
// header and the sidebar can never disagree about where the other one ends.
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<ProtectedRoute>
<div className="min-h-screen bg-background text-foreground">
<UnifiedNavigation />
{/* Main Content Area */}
<main className="pl-64 pt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</div>
</main>
<div className="flex min-h-screen flex-col bg-background text-foreground">
<Navbar />
<div className="flex-1">
<ConsoleLayout>{children}</ConsoleLayout>
</div>
<Footer />
</div>
</ProtectedRoute>
)
+154 -70
View File
@@ -4,13 +4,35 @@
@custom-variant dark (&:is(.dark *));
/* ===========================================================================
DESIGN TOKENS — the lux.cloud design language, expressed once.
Ported from lux/cloud/apps/web/src/styles.css. That file states its palette
as raw literals (#000 / #f5f5f5 / rgba(255,255,255,.08)); here the same
values are the token layer every Tailwind utility already reads, so the
whole app inherits the look without a single page being edited. There is no
second class system — `bg-background`, `border-border`, `text-muted-
foreground` ARE the lux.cloud palette now.
The scheme is dark-first, matching lux.cloud (its <html> ships class="dark"
and `color-scheme: dark`). Light is a faithful inversion, kept because
bootnode ships a theme toggle and dropping it would lose a feature.
=========================================================================== */
@theme inline {
--font-sans: var(--font-geist-sans);
/* lux.cloud declares "Basel Grotesk" but self-hosts no @font-face, so it
renders the system stack. Geist is already bundled here and is the closest
available neo-grotesque, so it takes that slot: Basel first for the day it
is hosted, Geist as the real-world resolution. */
--font-sans: "Basel Grotesk", var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* Radii, from lux.cloud's measured geometry: 8px side-nav rows, 10px
buttons/inputs, 16px panels and cards. */
--radius-sm: calc(var(--radius) - 4px); /* 6px */
--radius-md: calc(var(--radius) - 2px); /* 8px */
--radius-lg: var(--radius); /* 10px */
--radius-xl: calc(var(--radius) + 6px); /* 16px — .panel / .card */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -27,7 +49,10 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-warning: var(--warning);
--color-border: var(--border);
--color-border-strong: var(--border-strong);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
@@ -45,6 +70,11 @@
--color-sidebar-ring: var(--sidebar-ring);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
/* Shell geometry — one source of truth for the header/sidebar contract, so
the sidebar's sticky offset can never drift from the header's height. */
--header-h: 57px;
--sidebar-w: 236px;
}
@keyframes accordion-down {
@@ -65,75 +95,89 @@
}
}
/* --- light ---------------------------------------------------------------
The inversion of the dark scheme, not a separate design: same hairline
borders, same near-monochrome surfaces, same accent semantics. */
:root {
--radius: 0.5rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--radius: 0.625rem; /* 10px */
--background: #ffffff;
--foreground: #0a0a0a;
--card: #ffffff;
--card-foreground: #0a0a0a;
--popover: #ffffff;
--popover-foreground: #0a0a0a;
--primary: #0a0a0a;
--primary-foreground: #ffffff;
--secondary: #f5f5f5;
--secondary-foreground: #0a0a0a;
--muted: #f5f5f5;
--muted-foreground: rgba(10, 10, 10, 0.55);
--accent: #f2f2f2;
--accent-foreground: #0a0a0a;
--destructive: #d1242f;
--destructive-foreground: #ffffff;
--success: #1a7f37;
--warning: #9a6700;
--border: rgba(0, 0, 0, 0.1);
--border-strong: rgba(0, 0, 0, 0.22);
--input: rgba(0, 0, 0, 0.16);
--ring: rgba(0, 0, 0, 0.4);
--chart-1: #0a0a0a;
--chart-2: #525252;
--chart-3: #737373;
--chart-4: #a3a3a3;
--chart-5: #d4d4d4;
--sidebar: #ffffff;
--sidebar-foreground: #0a0a0a;
--sidebar-primary: #0a0a0a;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f2f2f2;
--sidebar-accent-foreground: #0a0a0a;
--sidebar-border: rgba(0, 0, 0, 0.1);
--sidebar-ring: rgba(0, 0, 0, 0.4);
}
/* --- dark (the lux.cloud scheme, verbatim) -------------------------------
Pure black ground, #f5f5f5 ink, 55% muted, 8% hairline / 18% strong border,
#1a1a1a active fill, and lux.cloud's status greens/ambers/reds. */
.dark {
--background: oklch(0 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.1 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.1 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.15 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.15 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.15 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.985 0 0);
--chart-2: oklch(0.708 0 0);
--chart-3: oklch(0.556 0 0);
--chart-4: oklch(0.8 0 0);
--chart-5: oklch(0.4 0 0);
--sidebar: oklch(0.05 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.985 0 0);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.15 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: #000000;
--foreground: #f5f5f5;
--card: #000000;
--card-foreground: #f5f5f5;
/* Popovers/menus sit ON the black ground, so they lift to #0a0a0a — the same
one-step lift lux.cloud gives .try-lux-panel and .org-menu. */
--popover: #0a0a0a;
--popover-foreground: #f5f5f5;
--primary: #f5f5f5;
--primary-foreground: #000000;
--secondary: #0a0a0a;
--secondary-foreground: #f5f5f5;
--muted: #0a0a0a;
--muted-foreground: rgba(245, 245, 245, 0.55);
--accent: #1a1a1a;
--accent-foreground: #f5f5f5;
--destructive: #ff7b72;
--destructive-foreground: #000000;
--success: #3fb950;
--warning: #d29922;
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.18);
--input: rgba(255, 255, 255, 0.18);
--ring: rgba(245, 245, 245, 0.6);
--chart-1: #f5f5f5;
--chart-2: #a8a8ff;
--chart-3: #8bffd1;
--chart-4: #ffd18b;
--chart-5: #ff8bc0;
--sidebar: #000000;
--sidebar-foreground: #f5f5f5;
--sidebar-primary: #f5f5f5;
--sidebar-primary-foreground: #000000;
--sidebar-accent: #1a1a1a;
--sidebar-accent-foreground: #f5f5f5;
--sidebar-border: rgba(255, 255, 255, 0.08);
--sidebar-ring: rgba(245, 245, 245, 0.6);
}
@layer base {
@@ -141,10 +185,37 @@
@apply border-border outline-ring/50;
}
html {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
body {
@apply bg-background text-foreground;
/* lux.cloud's type voice: 1.5 leading, -0.01em tracking, no synthetic
weights, smoothed. */
line-height: 1.5;
letter-spacing: -0.01em;
font-synthesis-weight: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
/* Display type. lux.cloud sizes its headings with clamp() so a 40px h1 does
not eat a third of a 390px phone; the same curve applies here to every
heading rather than page by page. Tailwind's text-* utilities still win
wherever a page sets one explicitly. */
h1 {
font-size: clamp(1.75rem, 5.5vw, 2.5rem);
letter-spacing: -0.02em;
line-height: 1.1;
}
h2 {
letter-spacing: -0.02em;
}
.container {
@@ -154,3 +225,16 @@
padding-inline: 2rem;
}
}
@layer components {
/* The console's horizontal section strip below the sidebar breakpoint hides
its scrollbar (lux.cloud does the same) — the active row is scrolled into
view instead, so the affordance is position, not a scrollbar. */
.no-scrollbar {
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
}
+35 -57
View File
@@ -9,70 +9,48 @@ import { BrandProvider } from "@/components/brand-provider"
import { getBrand } from "@/lib/brand"
import "@/app/globals.css"
// Static export: the prerendered HTML is brand-neutral. There is no
// request-time host detection here, and source carries no brand catalog — so
// SSG metadata uses bootnode's built-in neutral default. The real brand is
// resolved CLIENT-SIDE after hydration by BrandProvider, which loads the
// per-deploy catalog (window.__BRAND_CATALOG__ / /config.json) and re-resolves
// title/favicon/logos by hostname. Mirrors the @hanzo/id SPA.
function getServerBrand() {
const b = getBrand()
return { name: b.name, tagline: b.tagline, domain: b.domain, icon: b.favicon, defaultTheme: b.defaultTheme }
// `output: "export"` prerenders this at build time, where there is no request
// and therefore no Host header — so the static <head> can only carry the
// default brand, and the real one is resolved from the hostname after
// hydration by BrandProvider (which sets document.title and the favicon).
//
// This is the one honest way to white-label a static export. Baking a brand in
// at build time via NEXT_PUBLIC_BRAND is the alternative, and it is what this
// app used to do: it produced four near-identical images, three of which were
// wrong for whichever host they happened to be serving.
const fallback = getBrand("")
export const metadata: Metadata = {
title: { default: `${fallback.name} — ${fallback.tagline}`, template: `%s | ${fallback.name}` },
description: fallback.description,
keywords: ["blockchain", "RPC", "API", "Web3", "NFT", "DeFi", "smart contracts", "developer tools"],
openGraph: {
type: "website",
locale: "en_US",
url: `https://${fallback.domain}`,
siteName: fallback.name,
title: `${fallback.name} — ${fallback.tagline}`,
description: fallback.description,
},
twitter: {
card: "summary_large_image",
title: `${fallback.name} — ${fallback.tagline}`,
description: fallback.description,
},
icons: { icon: fallback.favicon, apple: fallback.favicon, shortcut: fallback.favicon },
}
export function generateMetadata(): Metadata {
const b = getServerBrand()
return {
title: {
default: `${b.name} - ${b.tagline}`,
template: `%s | ${b.name}`,
},
description: `Enterprise blockchain infrastructure powered by ${b.name}. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, Webhooks, and more.`,
keywords: ["blockchain", "RPC", "API", "Web3", "Ethereum", "Solana", "NFT", "DeFi", "smart contracts", "developer tools", b.name.toLowerCase()],
authors: [{ name: b.name }],
openGraph: {
type: "website",
locale: "en_US",
url: `https://${b.domain}`,
siteName: b.name,
title: `${b.name} - ${b.tagline}`,
description: `Enterprise blockchain infrastructure powered by ${b.name}. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, and more.`,
},
twitter: {
card: "summary_large_image",
title: `${b.name} - ${b.tagline}`,
description: `Enterprise blockchain infrastructure powered by ${b.name}. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, and more.`,
},
icons: {
icon: b.icon,
apple: b.icon,
shortcut: b.icon,
},
}
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const b = getServerBrand()
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang="en"
suppressHydrationWarning
className={`${GeistSans.variable} ${GeistMono.variable}`}
>
<html lang="en" suppressHydrationWarning className={`${GeistSans.variable} ${GeistMono.variable}`}>
<body className="min-h-screen bg-background font-sans antialiased">
<Providers>
{/* BrandProvider wraps ThemeProvider: the brand owns the default
colour scheme, so the theme cannot be resolved before the brand
is. */}
<BrandProvider>
<AuthProvider>
<ThemeProvider
attribute="class"
defaultTheme={b.defaultTheme}
enableSystem={false}
disableTransitionOnChange
>
<ThemeProvider>
{children}
<Toaster />
</ThemeProvider>
+11 -26
View File
@@ -7,10 +7,10 @@ import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Loader2 } from "lucide-react"
import { useAuth } from "@/lib/auth"
import { getIam, setReturnUrl } from "@/lib/iam"
import { BrandLogo, useBrand } from "@/components/brand-logo"
import { Loading, Spinner } from "@/components/ui/states"
function LoginForm() {
const router = useRouter()
@@ -63,32 +63,25 @@ function LoginForm() {
}
}
if (authLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
if (authLoading) return <Loading full />
// Show loading while redirecting in production
// Production: the IAM redirect is already in flight (see the effect above),
// so this frame exists only to say where you are being sent. The issuer is
// brand-derived, so a Zoo visitor is told "Zoo ID", never "Hanzo ID".
if (isProduction) {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-background to-muted/20 px-4">
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<Link href="/" className="mb-8">
<BrandLogo size="large" />
</Link>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">Redirecting to {brand.name} login...</p>
</div>
<Loading label={`Redirecting to ${brand.iam.idLabel}…`} />
</div>
)
}
// Development mode: Show email/password form
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-background to-muted/20 px-4">
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<Link href="/" className="mb-8">
<BrandLogo size="large" />
</Link>
@@ -169,7 +162,7 @@ function LoginForm() {
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner className="mr-2" />
{mode === "login" ? "Signing in..." : "Creating account..."}
</>
) : (
@@ -213,7 +206,7 @@ function LoginForm() {
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner className="mr-2" />
Signing in...
</>
) : (
@@ -230,17 +223,9 @@ function LoginForm() {
)
}
function LoginFallback() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
export default function LoginPage() {
return (
<Suspense fallback={<LoginFallback />}>
<Suspense fallback={<Loading full />}>
<LoginForm />
</Suspense>
)
+3 -3
View File
@@ -15,7 +15,7 @@ import {
RotateCw,
} from "lucide-react"
import { useBrand } from "@/components/brand-logo"
import { getBrandKey_ } from "@/lib/brand"
import { getBrandKey } from "@/lib/brand"
interface ChatMessage {
role: "user" | "assistant"
@@ -81,8 +81,8 @@ export function AiChat({
content: m.content,
}))
const tokenKey = `${getBrandKey_()}_token`
const apiKeyKey = `${getBrandKey_()}_api_key`
const tokenKey = `${getBrandKey()}_token`
const apiKeyKey = `${getBrandKey()}_api_key`
const res = await fetch("/api/v1/chat/completions", {
method: "POST",
headers: {
+74 -87
View File
@@ -2,111 +2,98 @@
import Image from "next/image"
import { useEffect, useState } from "react"
import { useTheme } from "next-themes"
import { getBrand } from "@/lib/brand"
import { useBrand } from "@/components/brand-provider"
import { cn } from "@/lib/utils"
// Re-export the brand hook so existing importers (`@/components/brand-logo`)
// keep working. Single source of truth lives in brand-provider.tsx.
// Re-exported so existing importers of `@/components/brand-logo` keep working;
// the hook itself lives in brand-provider.tsx.
export { useBrand }
// Brand Logo Component - theme-aware and white-label ready
const MARK = { small: 18, default: 22, large: 30 } as const
const WORD = { small: "text-base", default: "text-lg", large: "text-2xl" } as const
/**
* The brand lockup: mark plus wordmark, white-labelled by hostname.
*
* lux.cloud's signature move is here — the wordmark retracts a couple of
* seconds after load and returns on hover, so the mark holds the corner and
* the nav gets the width back. It is a width transition on an overflow-hidden
* span, not a mount/unmount, so nothing reflows abruptly and the accessible
* name never disappears.
*
* The mark is the brand's own asset (public/logo/<brand>-icon.svg), not a
* shared placeholder, so bootno.de, lux.cloud and zoo.cloud each render their
* own mark from the one build.
*/
export function BrandLogo({
showText = true,
size = "default",
className = ""
collapsible = false,
className = "",
}: {
showText?: boolean
size?: "small" | "default" | "large"
/** Retract the wordmark after load, revealing it on hover (header use). */
collapsible?: boolean
className?: string
}) {
const brand = useBrand()
const { resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const [collapsed, setCollapsed] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!collapsible) return
const t = setTimeout(() => setCollapsed(true), 2500)
return () => clearTimeout(t)
}, [collapsible])
const sizeClasses = {
small: "h-5 w-5",
default: "h-6 w-6",
large: "h-8 w-8",
}
const textSizes = {
small: "text-lg",
default: "text-xl",
large: "text-2xl",
}
const pixelSizes = {
small: 20,
default: 24,
large: 32,
}
// Use white logo for dark mode, default logo for light mode
const logoSrc = mounted && resolvedTheme === "dark" ? brand.logoWhite : brand.logo
const px = MARK[size]
return (
<div className={`flex items-center space-x-2 ${className}`}>
<Image
src={brand.logoIcon}
alt={brand.name}
width={pixelSizes[size]}
height={pixelSizes[size]}
className={sizeClasses[size]}
/>
{showText && (
<span className={`font-bold ${textSizes[size]}`}>{brand.name}</span>
<span className={cn("group inline-flex items-center gap-2", className)}>
{brand.monoMark ? (
// A single-colour mark is painted in the CURRENT TEXT COLOUR, so it is
// legible in both themes and always matches the wordmark beside it —
// the same thing lux.cloud does with its pyramid. An <img> cannot do
// this: it is an isolated document, so a `fill="currentColor"` mark
// resolves to black and disappears on a black page.
<span
role="img"
aria-label={brand.name}
style={{
width: px,
height: px,
maskImage: `url(${brand.logoIcon})`,
WebkitMaskImage: `url(${brand.logoIcon})`,
maskSize: "contain",
WebkitMaskSize: "contain",
maskRepeat: "no-repeat",
WebkitMaskRepeat: "no-repeat",
maskPosition: "center",
WebkitMaskPosition: "center",
}}
className="shrink-0 bg-current"
/>
) : (
<Image
src={brand.logoIcon}
alt={brand.name}
width={px}
height={px}
style={{ width: px, height: px }}
className="shrink-0"
/>
)}
</div>
{showText && (
<span
className={cn(
"inline-block overflow-hidden whitespace-nowrap font-medium tracking-tight transition-[max-width,opacity] duration-300 ease-out group-hover:max-w-40 group-hover:opacity-100",
WORD[size],
collapsed ? "max-w-0 opacity-0" : "max-w-40 opacity-100"
)}
>
{brand.name}
</span>
)}
</span>
)
}
// Full logo (icon + text as image if available)
export function BrandLogoFull({
size = "default",
className = ""
}: {
size?: "small" | "default" | "large"
className?: string
}) {
const brand = useBrand()
const { resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const heights = {
small: 24,
default: 32,
large: 40,
}
// Use white logo for dark mode
const logoSrc = mounted && resolvedTheme === "dark" ? brand.logoWhite : brand.logo
return (
<Image
src={logoSrc}
alt={brand.name}
width={heights[size] * 4}
height={heights[size]}
className={className}
/>
)
}
// Export brand name helper for SSR-safe usage
export function getBrandName(): string {
return getBrand().name
}
// Export brand tagline
export function getBrandTagline(): string {
return getBrand().tagline
}
+24 -39
View File
@@ -1,57 +1,42 @@
"use client"
// BrandProvider — loads the per-deploy brand catalog at runtime and exposes the
// resolved BrandConfig to the tree. Mirrors the @hanzo/id SPA's App-level load:
// a `useEffect` reads the injected `window.__BRAND_CATALOG__` global and/or
// fetches `/config.json`, applies it, then re-resolves the active brand by host.
// BrandProvider — publishes the hostname-resolved brand to the tree.
//
// Static export note: the prerendered HTML is brand-neutral (built-in bootnode
// defaults). Brand applies CLIENT-SIDE after hydration once the runtime catalog
// is available — exactly the id SPA behaviour.
// `getBrand()` is already a pure function of the hostname, so this provider
// loads nothing: no /config.json fetch, no window.__BRAND_CATALOG__. It exists
// for exactly one reason — `output: "export"` prerenders every page with NO
// host, so the static HTML is necessarily the default brand. One
// post-hydration re-resolve swaps in the real one and re-renders every
// consumer. That is the whole job.
//
// It also owns the two brand-derived document facts a static <head> cannot
// know: the title and the favicon.
import { createContext, useContext, useEffect, useState } from "react"
import { applyBrandCatalog, getBrand, type BrandConfig } from "@/lib/brand"
import { getBrand, type BrandConfig } from "@/lib/brand"
const BrandContext = createContext<BrandConfig>(getBrand())
const BrandContext = createContext<BrandConfig>(getBrand(""))
export function BrandProvider({ children }: { children: React.ReactNode }) {
// Start from the synchronous resolve (injected global if the deploy inlined
// it before hydration; else the neutral built-in default).
const [brand, setBrand] = useState<BrandConfig>(() => getBrand())
// The first render must match the server's markup, so it uses the same
// host-less default; the effect below corrects it immediately after mount.
const [brand, setBrand] = useState<BrandConfig>(() => getBrand(""))
useEffect(() => {
let cancelled = false
// 1) Ingest any deploy-injected global synchronously (no flash if present).
const injected = (window as unknown as { __BRAND_CATALOG__?: unknown }).__BRAND_CATALOG__
if (injected) {
applyBrandCatalog(injected)
setBrand(getBrand())
}
// 2) Also fetch /config.json (the deploy-provisioned catalog) so the app
// works when the catalog is served as a file rather than inlined.
fetch("/config.json", { cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then((cat) => {
if (cancelled || !cat) return
applyBrandCatalog(cat)
setBrand(getBrand())
})
.catch(() => {
/* no catalog provisioned — keep the built-in neutral default */
})
return () => {
cancelled = true
}
const resolved = getBrand()
setBrand(resolved)
document.title = `${resolved.name} — ${resolved.tagline}`
document.querySelector<HTMLLinkElement>('link[rel="icon"]')?.setAttribute("href", resolved.favicon)
}, [])
return <BrandContext.Provider value={brand}>{children}</BrandContext.Provider>
}
// Brand hook — context-backed so consumers re-render when the runtime catalog
// loads. Same return shape as before (a BrandConfig).
/**
* The active brand, context-backed so every consumer re-renders when the
* hostname resolve lands. Components must use this rather than calling
* `getBrand()` directly, or they keep rendering the prerendered default.
*/
export function useBrand(): BrandConfig {
return useContext(BrandContext)
}
+22
View File
@@ -0,0 +1,22 @@
import { ConsoleSidebar } from "@/components/console-sidebar"
// The console shell: the one persistent sidebar plus the section's content,
// centred at lux.cloud's 1440px console width. Every /dashboard route renders
// inside this, so the console reads as one organised surface no matter which
// section is open — as opposed to each page inventing its own frame.
//
// `min-w-0` on the content column is load-bearing: without it a wide table or
// chart inside a flex child sets the column's min-content width and the whole
// page scrolls sideways instead of the table scrolling inside itself.
export function ConsoleLayout({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto flex max-w-[1440px] items-start max-lg:block">
<ConsoleSidebar />
<div className="min-w-0 flex-1">
<main className="mx-auto w-full max-w-[1100px] px-8 pb-20 pt-7 max-lg:px-4 max-lg:pb-14 max-lg:pt-6">
{children}
</main>
</div>
</div>
)
}
+97
View File
@@ -0,0 +1,97 @@
"use client"
import { useEffect, useRef } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useAuth } from "@/lib/auth"
import { useBrand } from "@/components/brand-provider"
import { DASHBOARD_NAVIGATION, currentHref } from "@/lib/dashboard-navigation"
import { cn } from "@/lib/utils"
// The ONE console navigation surface, in lux.cloud's shape.
//
// Desktop: a 236px sticky rail under the header, sections grouped under 11px
// uppercase labels, rows at 8px radius that fill with `accent` when active.
//
// Below `lg` the rail becomes a single horizontally-scrolling strip of the
// same rows. lux.cloud's note on why: wrapping the sections instead produced a
// five-row wall of pills eating a quarter of a phone screen above the fold on
// every page. The strip scrolls inside itself — the page body never scrolls
// sideways — and the active row is scrolled into view so you can always see
// where you are.
export function ConsoleSidebar() {
const pathname = usePathname()
const { isAdmin } = useAuth()
const brand = useBrand()
const ref = useRef<HTMLElement>(null)
const groups = DASHBOARD_NAVIGATION.map((group) => ({
...group,
items: group.items.filter((item) => !item.admin || isAdmin),
})).filter((group) => group.items.length > 0)
const active = currentHref(
pathname,
groups.flatMap((g) => g.items.map((i) => i.href))
)
useEffect(() => {
ref.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" })
}, [pathname])
const rowClass = (active: boolean) =>
cn(
"flex shrink-0 items-center justify-between gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm transition-colors max-lg:min-h-10 max-lg:scroll-mx-3",
active
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
)
return (
<nav
ref={ref}
aria-label="Console sections"
className={cn(
// Desktop rail.
"sticky top-(--header-h) h-[calc(100dvh-var(--header-h))] w-(--sidebar-w) shrink-0 self-start overflow-y-auto border-r border-border px-3 pb-10 pt-5",
// Phone/tablet strip.
"no-scrollbar max-lg:static max-lg:h-auto max-lg:w-auto max-lg:flex-nowrap max-lg:gap-1 max-lg:overflow-x-auto max-lg:overflow-y-hidden max-lg:overscroll-x-contain max-lg:border-b max-lg:border-r-0 max-lg:px-3 max-lg:py-2 max-lg:[display:flex]"
)}
>
{groups.map((group) => (
// `display: contents` on the strip flattens the groups into the one
// scrolling row without duplicating the markup for two layouts.
<div key={group.label} className="mb-4 max-lg:mb-0 max-lg:[display:contents]">
<div className="px-3 py-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground max-lg:hidden">
{group.label}
</div>
{group.items.map((item) => (
<Link
key={item.href}
href={item.href}
data-active={item.href === active}
aria-current={item.href === active ? "page" : undefined}
className={rowClass(item.href === active)}
>
{item.name}
</Link>
))}
</div>
))}
{/* Status is the brand's own page, off-site by definition. */}
<div className="mb-4 max-lg:mb-0 max-lg:[display:contents]">
<div className="px-3 py-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground max-lg:hidden">
Platform
</div>
<a href={brand.statusUrl} target="_blank" rel="noreferrer" className={rowClass(false)}>
<span>Status</span>
<span aria-hidden="true" className="text-xs opacity-50">
↗
</span>
</a>
</div>
</nav>
)
}
+67 -76
View File
@@ -1,10 +1,15 @@
"use client"
import Link from "next/link"
import { Separator } from "@/components/ui/separator"
import { BrandLogo, useBrand } from "@/components/brand-logo"
import { ExternalLink } from "lucide-react"
// Footer, in lux.cloud's chrome: no tinted slab, just a hairline rule over the
// page ground with 13px muted type. The four sitemap columns stay — dropping
// them to match lux.cloud's single row would delete real navigation — but the
// brand row underneath is lux.cloud's, and every brand string in it now comes
// from the hostname-resolved catalog rather than a constant.
const footerLinks = {
products: [
{ name: "Node (RPC)", href: "/products/node" },
@@ -27,90 +32,81 @@ const footerLinks = {
{ name: "API Reference", href: "/docs/api" },
{ name: "Quickstart", href: "/docs/quickstart" },
{ name: "SDKs", href: "/docs/sdks" },
{ name: "Tutorials", href: "/docs/tutorials" },
{ name: "Changelog", href: "/docs/changelog" },
{ name: "llms.txt", href: "/llms.txt" },
],
}
// One rule for every footer link. `min-h-8` is not decoration: on a phone these
// were 20px tap targets in a 44px world.
const linkClass =
"inline-flex min-h-8 items-center gap-1 text-[13px] text-muted-foreground transition-colors hover:text-foreground"
function Column({ title, links }: { title: string; links: { name: string; href: string }[] }) {
return (
<div>
<h3 className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">{title}</h3>
<ul className="mt-3 space-y-0.5">
{links.map((link) => (
<li key={link.name}>
<Link href={link.href} className={linkClass}>
{link.name}
</Link>
</li>
))}
</ul>
</div>
)
}
export function Footer() {
const brand = useBrand()
return (
<footer className="border-t bg-muted/30">
<div className="container py-12 md:py-16">
<footer className="mt-24 border-t border-border">
<div className="mx-auto max-w-[1440px] px-6 py-12 max-sm:px-4">
<div className="grid gap-8 sm:grid-cols-2 md:grid-cols-5">
{/* Brand */}
<div className="md:col-span-1">
<Link href="/">
<BrandLogo />
</Link>
<p className="mt-4 text-sm text-muted-foreground">
{brand.tagline}
</p>
<p className="mt-3 max-w-56 text-[13px] leading-relaxed text-muted-foreground">{brand.tagline}</p>
</div>
{/* Products */}
<Column title="Products" links={footerLinks.products} />
<Column title="Chains" links={footerLinks.chains} />
<Column title="Developers" links={footerLinks.developers} />
<div>
<h3 className="font-semibold">Products</h3>
<ul className="mt-4 space-y-2">
{footerLinks.products.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className="text-sm text-muted-foreground hover:text-foreground"
>
{link.name}
</Link>
<h3 className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">Network</h3>
<ul className="mt-3 space-y-0.5">
<li>
<a href={brand.networkUrl} className={linkClass}>
{brand.networkName}
<ExternalLink className="h-3 w-3" />
</a>
</li>
<li>
<a href={brand.statusUrl} className={linkClass}>
Status
<ExternalLink className="h-3 w-3" />
</a>
</li>
{brand.social.github && (
<li>
<a href={brand.social.github} className={linkClass}>
GitHub
<ExternalLink className="h-3 w-3" />
</a>
</li>
))}
</ul>
</div>
{/* Chains */}
<div>
<h3 className="font-semibold">Chains</h3>
<ul className="mt-4 space-y-2">
{footerLinks.chains.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className="text-sm text-muted-foreground hover:text-foreground"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Developers */}
<div>
<h3 className="font-semibold">Developers</h3>
<ul className="mt-4 space-y-2">
{footerLinks.developers.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className="text-sm text-muted-foreground hover:text-foreground"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Ecosystem (brand-specific) */}
<div>
<h3 className="font-semibold">Ecosystem</h3>
<ul className="mt-4 space-y-2">
)}
{brand.footerLinks.map((link) => (
<li key={link.name}>
<a
href={link.href}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noopener noreferrer" : undefined}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
className={linkClass}
>
{link.name}
{link.external && <ExternalLink className="h-3 w-3" />}
@@ -121,25 +117,20 @@ export function Footer() {
</div>
</div>
<Separator className="my-8" />
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<p className="text-sm text-muted-foreground">
© {new Date().getFullYear()} {brand.name}. All rights reserved.
<div className="mt-10 flex flex-col items-center justify-between gap-3 border-t border-border pt-6 sm:flex-row">
<p className="text-[13px] text-muted-foreground">
© {new Date().getFullYear()} {brand.name}
</p>
<div className="flex gap-4">
<Link
href="/privacy"
className="text-sm text-muted-foreground hover:text-foreground"
>
<div className="flex gap-5">
<Link href="/privacy" className={linkClass}>
Privacy
</Link>
<Link
href="/terms"
className="text-sm text-muted-foreground hover:text-foreground"
>
<Link href="/terms" className={linkClass}>
Terms
</Link>
<Link href="/security" className={linkClass}>
Security
</Link>
</div>
</div>
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
// DOKS (DigitalOcean Kubernetes Service) region coordinates and types
// Used by fleet-map.tsx to position validator nodes on the world map
import { getBrandKey_ } from "@/lib/brand"
import { getBrandKey } from "@/lib/brand"
export interface DOKSRegion {
name: string
@@ -66,7 +66,7 @@ export function fleetStatusToNodeStatus(s: LuxFleet["status"]): NodeStatus {
/** Auth headers for API calls -- matches existing pattern in infrastructure/page.tsx */
export function getAuthHeaders(): Record<string, string> {
if (typeof window === "undefined") return {}
const brand = getBrandKey_()
const brand = getBrandKey()
const token = localStorage.getItem(`${brand}_token`)
const apiKey = localStorage.getItem(`${brand}_api_key`)
if (token) return { Authorization: `Bearer ${token}` }
+161 -107
View File
@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -36,9 +36,10 @@ import {
Activity,
} from "lucide-react"
import { BrandLogo } from "@/components/brand-logo"
import { useBrand } from "@/components/brand-provider"
import { CommandMenu } from "@/components/command-menu"
import { NavMenu } from "@/components/nav-menu"
import { getBrand } from "@/lib/brand"
import { useAuth } from "@/lib/auth"
import { getSdkPackage } from "@/lib/sdk-package"
// Reusable icon link for mega-menu items
@@ -74,7 +75,7 @@ function IconLink({
// Products mega-menu content
function ProductsContent({ closeMenu }: { closeMenu: () => void }) {
const brand = getBrand()
const brand = useBrand()
const { pkg: sdkPackage } = getSdkPackage()
return (
<div className="w-full">
@@ -345,122 +346,175 @@ function SolutionsContent({ closeMenu }: { closeMenu: () => void }) {
)
}
const chains = [
{ name: "Ethereum", href: "/chains/ethereum" },
{ name: "Solana", href: "/chains/solana" },
{ name: "Base", href: "/chains/base" },
{ name: "Arbitrum", href: "/chains/arbitrum" },
{ name: "Polygon", href: "/chains/polygon" },
{ name: "View All 100+", href: "/chains" },
// Rows that appear in BOTH the desktop bar and the phone sheet. Declared once —
// the previous header wrote them twice and the two copies had already drifted
// (Chains was in the sheet only).
const PRIMARY_LINKS = [
{ name: "Fleet", href: "/fleet" },
{ name: "Chains", href: "/chains" },
{ name: "Docs", href: "/docs" },
{ name: "Pricing", href: "/pricing" },
]
// The product pages, for the phone sheet — which has no room for the desktop
// mega-menu. Every href here is a route the build actually emits. Note there
// is deliberately no bare `/products`: it is a DIRECTORY of pages with no
// index, so linking to it serves the SPA fallback (i.e. the home page) under a
// /products URL.
const PRODUCTS = [
{ name: "Node (RPC)", href: "/products/node" },
{ name: "Data APIs", href: "/products/data" },
{ name: "Smart Wallets", href: "/products/wallets" },
{ name: "Explorer", href: "/products/explorer" },
{ name: "MPC", href: "/products/mpc" },
{ name: "Rollups", href: "/products/rollups" },
{ name: "Webhooks", href: "/products/webhooks" },
]
/**
* The site header, in lux.cloud's chrome: a 57px sticky bar over a blurred
* translucent ground, one hairline rule beneath, the brand lockup at the left
* with its retracting wordmark, and the action cluster hard right.
*
* Below `lg` the nav and the actions collapse together behind one hamburger
* into a full-width sheet, so a 390px viewport still fits the bar and every
* action — Sign In included — stays reachable. The sheet closes on outside
* click and on Escape, matching every other overlay in the app.
*/
export function Navbar() {
const [isOpen, setIsOpen] = useState(false)
const brand = useBrand()
const { user, logout } = useAuth()
const ref = useRef<HTMLElement>(null)
const close = () => setIsOpen(false)
useEffect(() => {
if (!isOpen) return
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setIsOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false)
}
document.addEventListener("click", onClick)
document.addEventListener("keydown", onKey)
return () => {
document.removeEventListener("click", onClick)
document.removeEventListener("keydown", onKey)
}
}, [isOpen])
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center">
{/* Logo */}
<Link href="/" className="mr-6 flex-shrink-0">
<BrandLogo />
<header
ref={ref}
className="sticky top-0 z-50 w-full border-b border-border bg-background/80 backdrop-blur-xl"
>
<div className="mx-auto flex h-(--header-h) max-w-[1440px] items-center gap-8 px-6 max-lg:gap-0 max-sm:px-4">
<Link href="/" className="shrink-0" onClick={close}>
<BrandLogo collapsible />
</Link>
{/* Desktop Navigation */}
<div className="hidden lg:flex items-center space-x-4 flex-1">
<NavMenu label="Products">
{(closeMenu) => <ProductsContent closeMenu={closeMenu} />}
</NavMenu>
<Button
variant="outline"
size="icon"
aria-label="Toggle navigation menu"
aria-expanded={isOpen}
className="ml-auto lg:hidden"
onClick={(e) => {
e.stopPropagation()
setIsOpen((v) => !v)
}}
>
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
<NavMenu label="Solutions">
{(closeMenu) => <SolutionsContent closeMenu={closeMenu} />}
</NavMenu>
{/* Desktop: an inline row. Phone: an opaque full-width sheet under the
bar. It must NOT carry its own backdrop-filter — nesting one inside
the header's re-roots the backdrop and the page bleeds through the
menu, which is the bug lux.cloud documents in its own styles. */}
<div
className={cn(
"flex min-w-0 flex-1 items-center gap-8",
"max-lg:absolute max-lg:inset-x-0 max-lg:top-full max-lg:max-h-[calc(100dvh-var(--header-h))] max-lg:flex-col max-lg:items-stretch max-lg:gap-4 max-lg:overflow-y-auto max-lg:overscroll-contain max-lg:border-b max-lg:border-border max-lg:bg-background max-lg:px-6 max-lg:pb-6 max-lg:pt-4",
isOpen ? "max-lg:flex" : "max-lg:hidden"
)}
>
<nav className="flex flex-1 items-center gap-6 max-lg:flex-col max-lg:items-stretch max-lg:gap-0.5">
<span className="max-lg:hidden">
<NavMenu label="Products">{(closeMenu) => <ProductsContent closeMenu={closeMenu} />}</NavMenu>
</span>
<span className="max-lg:hidden">
<NavMenu label="Solutions">{(closeMenu) => <SolutionsContent closeMenu={closeMenu} />}</NavMenu>
</span>
{/* The sheet has no room for a mega-menu, so the products are
listed instead of collapsing to one link — the mega-menu is the
only route to them and losing it on a phone loses the product. */}
<span className="hidden w-full max-lg:block">
<span className="mb-1 mt-1 block text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
Products
</span>
{PRODUCTS.map((p) => (
<Link
key={p.href}
href={p.href}
onClick={close}
className="flex min-h-10 w-full items-center text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{p.name}
</Link>
))}
</span>
{PRIMARY_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={close}
className="text-sm text-muted-foreground transition-colors hover:text-foreground max-lg:flex max-lg:min-h-10 max-lg:w-full max-lg:items-center"
>
{link.name}
</Link>
))}
<a
href={brand.networkUrl}
className="text-sm text-muted-foreground transition-colors hover:text-foreground max-lg:flex max-lg:min-h-10 max-lg:w-full max-lg:items-center"
>
{brand.networkName}
</a>
</nav>
<Link
href="/fleet"
className="text-muted-foreground hover:text-foreground transition-colors text-sm font-medium"
>
Fleet
</Link>
<Link
href="/docs"
className="text-muted-foreground hover:text-foreground transition-colors text-sm font-medium"
>
Docs
</Link>
<Link
href="/pricing"
className="text-muted-foreground hover:text-foreground transition-colors text-sm font-medium"
>
Pricing
</Link>
</div>
{/* Search + Actions */}
<div className="ml-auto flex items-center gap-4">
<CommandMenu />
<Button variant="ghost" size="sm" asChild className="hidden sm:flex">
<Link href="/login">Sign In</Link>
</Button>
<Button size="sm" asChild>
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
{/* In the sheet every action is a full-width 40px row, matching the
nav links above it — a 32px `sm` button is a fine desktop target
and a poor thumb target. */}
<div className="flex items-center gap-2.5 max-lg:flex-col max-lg:items-stretch max-lg:gap-2 [&_button]:max-lg:h-10 [&_a]:max-lg:h-10">
<CommandMenu />
{user ? (
<>
{/* Who you are signed in as. The old console header spent a
dropdown on this plus an org switcher whose options changed
nothing and a bell wired to no notification source. */}
<span
className="max-w-44 truncate text-[13px] text-muted-foreground max-lg:max-w-none"
title={user.email}
>
{user.email}
</span>
<Button variant="outline" size="sm" onClick={() => { close(); logout() }}>
Sign out
</Button>
</>
) : (
<Button variant="outline" size="sm" asChild onClick={close}>
{/* Brand-derived: on lux.cloud this reads "Sign in with Lux ID",
on zoo.cloud "Zoo ID" — from the same build. */}
<Link href="/login">Sign in with {brand.iam.idLabel}</Link>
</Button>
)}
<Button size="sm" asChild onClick={close}>
<Link href="/dashboard">Dashboard</Link>
</Button>
</div>
</div>
</div>
{/* Mobile Menu */}
{isOpen && (
<div className="container border-t pb-4 pt-4 lg:hidden">
<nav className="flex flex-col gap-4">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Products
</div>
<Link href="/products/node" className="text-sm font-medium pl-2" onClick={() => setIsOpen(false)}>
Node (RPC)
</Link>
<Link href="/products/data" className="text-sm font-medium pl-2" onClick={() => setIsOpen(false)}>
Data APIs
</Link>
<Link href="/products/wallets" className="text-sm font-medium pl-2" onClick={() => setIsOpen(false)}>
Smart Wallets
</Link>
<Link href="/products/explorer" className="text-sm font-medium pl-2" onClick={() => setIsOpen(false)}>
Explorer
</Link>
<Link href="/products/mpc" className="text-sm font-medium pl-2" onClick={() => setIsOpen(false)}>
MPC
</Link>
<Link href="/products/rollups" className="text-sm font-medium pl-2" onClick={() => setIsOpen(false)}>
Rollups
</Link>
<Link href="/products/webhooks" className="text-sm font-medium pl-2" onClick={() => setIsOpen(false)}>
Webhooks
</Link>
<div className="border-t pt-4 mt-2" />
<Link href="/fleet" className="text-sm font-medium" onClick={() => setIsOpen(false)}>
Fleet
</Link>
<Link href="/docs" className="text-sm font-medium" onClick={() => setIsOpen(false)}>
Docs
</Link>
<Link href="/pricing" className="text-sm font-medium" onClick={() => setIsOpen(false)}>
Pricing
</Link>
<Link href="/chains" className="text-sm font-medium" onClick={() => setIsOpen(false)}>
Chains
</Link>
</nav>
</div>
)}
</header>
)
}
+27 -4
View File
@@ -1,8 +1,31 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from "next-themes"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { useBrand } from "@/components/brand-provider"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
/**
* Theming, white-labelled by hostname.
*
* The brand owns its default colour scheme, so the caller passes no
* `defaultTheme` — that would be a build-time constant, and one build serves
* every host. next-themes only consults `defaultTheme` on mount, so the
* provider is keyed on the brand: when BrandProvider re-resolves the host after
* hydration, a brand with a different default remounts next-themes and the
* default actually applies. A visitor's own explicit choice still wins — it is
* in localStorage, which is origin-scoped, so each white-label host remembers
* its own preference independently.
*/
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const brand = useBrand()
return (
<NextThemesProvider
key={brand.key}
attribute="class"
defaultTheme={brand.defaultTheme}
enableSystem={false}
disableTransitionOnChange
>
{children}
</NextThemesProvider>
)
}
+14 -15
View File
@@ -2,22 +2,25 @@ import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
// Badge == lux.cloud's `.pill`: a fully-round 12px label, medium weight, no
// shadow. The three status variants carry the leading dot lux.cloud gives
// `.pill.live` / `.pill.pending` — a colour alone is not a state readable by
// someone who cannot distinguish it, and the dot makes the tint deliberate
// rather than decorative.
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-1 focus:ring-ring",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border-border-strong text-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
"border-transparent bg-destructive/12 text-destructive before:size-1.5 before:rounded-full before:bg-current",
success:
"border-transparent bg-green-500/10 text-green-500 dark:bg-green-500/20",
"border-transparent bg-success/12 text-success before:size-1.5 before:rounded-full before:bg-current",
warning:
"border-transparent bg-yellow-500/10 text-yellow-600 dark:bg-yellow-500/20 dark:text-yellow-500",
"border-transparent bg-warning/12 text-warning before:size-1.5 before:rounded-full before:bg-current",
},
},
defaultVariants: {
@@ -26,14 +29,10 @@ const badgeVariants = cva(
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }
+17 -22
View File
@@ -3,27 +3,28 @@ import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
// Button == lux.cloud's `.btn`: 10px radius, 14px medium label, a hairline
// `border-strong` outline over transparent, lifting to a 5%-ink wash on hover.
// `default` is `.btn.primary` — solid ink, inverted label. Shadows are gone
// for the same reason as on Card: they render as nothing on black and as grime
// on white.
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-border-strong bg-transparent hover:border-foreground/30 hover:bg-foreground/5",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-foreground/5 hover:text-foreground",
link: "text-foreground underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
xl: "h-12 rounded-lg px-10 text-base",
default: "h-9 px-3.5 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-10 px-8",
xl: "h-12 px-10 text-base",
icon: "h-9 w-9",
},
},
@@ -43,13 +44,7 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
}
)
Button.displayName = "Button"
+40 -59
View File
@@ -1,75 +1,56 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
// Card == lux.cloud's `.panel`: a 16px-radius rectangle drawn with a single
// hairline border on the page ground. No shadow and no raised surface — on a
// pure-black background a shadow is invisible and a lifted fill just makes the
// page muddy, so lux.cloud uses the border alone to separate. Everything below
// is that panel's measured geometry.
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border border-border bg-card text-card-foreground", className)}
{...props}
/>
)
)
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1 p-5", className)} {...props} />
)
)
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
// 17px / 500 — lux.cloud's `.panel-head h2`. Not bold: the panel is
// delimited by its border, so the title does not have to shout.
<h3 ref={ref} className={cn("text-[17px] font-medium leading-none tracking-tight", className)} {...props} />
)
)
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-[13px] leading-relaxed text-muted-foreground", className)} {...props} />
)
)
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-5 pt-0", className)} {...props} />
)
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-5 pt-0", className)} {...props} />
)
)
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+6 -1
View File
@@ -1,13 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
// Input == lux.cloud's `.stake-input` / `.calc-select`: the field sits one
// step off the page ground (`bg-muted` — #0a0a0a on black), 10px radius, a
// strong hairline that resolves to full-contrast ink on focus. lux.cloud uses
// the border alone as the focus signal, with no ring, so the field does not
// grow when it is selected.
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex h-10 w-full rounded-lg border border-input bg-muted px-3 py-2 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
+97
View File
@@ -0,0 +1,97 @@
import { cn } from "@/lib/utils"
// The three things a surface can be when it is not showing data: loading,
// empty, or broken. One implementation each.
//
// Before this file the app hand-rolled `<div className="animate-spin
// rounded-full h-8 w-8 border-b-2 border-primary" />` in nine places, at four
// different sizes, with no shared idea of what "empty" looks like. lux.cloud
// has exactly one `.empty` block — centred, muted, 40px of air, with the
// recovery action directly under the sentence — and that is what these are.
/** The one spinner. Sized in `em`, so it matches whatever text it sits in. */
export function Spinner({ className }: { className?: string }) {
return (
<span
role="status"
aria-label="Loading"
className={cn(
"inline-block size-[1.15em] animate-spin rounded-full border-2 border-current border-t-transparent align-[-0.15em]",
className
)}
/>
)
}
/**
* A surface that is loading. `full` centres it in the viewport for a whole
* route; without it the block sits inside whatever panel owns it.
*/
export function Loading({ label, full = false }: { label?: string; full?: boolean }) {
return (
<div
className={cn(
"flex flex-col items-center justify-center gap-3 text-muted-foreground",
full ? "min-h-screen px-6" : "px-5 py-10"
)}
>
<Spinner className="size-6 text-foreground" />
{label && <p className="text-sm">{label}</p>}
</div>
)
}
/**
* A surface with nothing in it — because there is genuinely nothing, not
* because a request failed. Say which, and offer the action that would put
* something here.
*/
export function EmptyState({
title,
description,
action,
className,
}: {
title: string
description?: string
action?: React.ReactNode
className?: string
}) {
return (
<div className={cn("px-5 py-10 text-center", className)}>
<p className="text-sm font-medium text-foreground">{title}</p>
{description && <p className="mx-auto mt-1.5 max-w-md text-sm text-muted-foreground">{description}</p>}
{action && <div className="mt-4 flex flex-wrap justify-center gap-2">{action}</div>}
</div>
)
}
/**
* A surface that failed. It states what broke and never renders zeros in place
* of unknown data — a "0 nodes" that is really "the API refused" is a lie the
* reader cannot detect.
*/
export function ErrorState({
title = "Something went wrong",
description,
action,
className,
}: {
title?: string
description?: string
action?: React.ReactNode
className?: string
}) {
return (
<div className={cn("px-5 py-10 text-center", className)}>
<p className="text-sm font-medium text-destructive">{title}</p>
{description && <p className="mx-auto mt-1.5 max-w-md text-sm text-muted-foreground">{description}</p>}
{action && <div className="mt-4 flex flex-wrap justify-center gap-2">{action}</div>}
</div>
)
}
/** Placeholder for content whose SHAPE is known but whose values are not. */
export function Skeleton({ className }: { className?: string }) {
return <div aria-hidden="true" className={cn("animate-pulse rounded-md bg-foreground/[0.07]", className)} />
}
+69 -100
View File
@@ -4,119 +4,88 @@ import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
// Table == lux.cloud's `.vtable`. Two decisions carry the look:
//
// 1. Column heads are 12px uppercase with 0.04em tracking in muted ink — a
// label, not a row of data. They own no rule of their own.
// 2. Separation is a border on the TOP of every row, so the header is divided
// from the body by the first row's own line and the table ends flush with
// no trailing rule. (The stock shadcn table draws border-b on each row and
// then has to special-case the last one away.)
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-x-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
)
)
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={className} {...props} />
)
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <tbody ref={ref} className={className} {...props} />
)
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot ref={ref} className={cn("border-t border-border font-medium", className)} {...props} />
)
)
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-t border-border transition-colors hover:bg-foreground/[0.03] data-[state=selected]:bg-accent",
className
)}
{...props}
/>
)
)
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"whitespace-nowrap px-4 py-3 text-left align-middle text-xs font-medium uppercase tracking-[0.04em] text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
)
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"px-4 py-3.5 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
)
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
)
)
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
-466
View File
@@ -1,466 +0,0 @@
"use client"
// Unified Dashboard Navigation - All features merged
// Complete platform navigation with organizations, teams, and all features
import { useState, useEffect } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { useAuth, OrgBadge } from "@/lib/auth"
import { getBrand } from "@/lib/brand"
import {
Activity,
Server,
Network,
Database,
Users,
Settings,
CreditCard,
Key,
Webhook,
BarChart3,
Coins,
Shield,
Building,
UserPlus,
ChevronDown,
Menu,
X,
Home,
Zap,
Globe,
Code,
FileText,
Bell,
Boxes,
Eye,
ExternalLink,
Search,
ArrowLeftRight,
ArrowUpDown,
ShoppingBag,
Wallet,
Droplets,
Vote,
Bot,
Brain,
MessageCircle,
MessageSquare,
Sparkles,
} from "lucide-react"
import { CommandMenu } from "@/components/command-menu"
interface NavigationItem {
name: string
href: string
icon: any
current?: boolean
badge?: string
description?: string
children?: NavigationItem[]
}
export function UnifiedNavigation() {
const { user, logout, isAdmin } = useAuth()
const pathname = usePathname()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [selectedOrg, setSelectedOrg] = useState<string>(user?.org || "hanzo")
// Unified navigation structure
const navigation: NavigationItem[] = [
{
name: "Overview",
href: "/dashboard",
icon: Home,
description: "Platform overview and quick actions"
},
{
name: "Infrastructure",
href: "/dashboard/infrastructure",
icon: Server,
description: "Manage nodes, networks, services, and infrastructure",
children: [
{ name: "Lux Fleet", href: "/dashboard/infrastructure/lux", icon: Shield },
{ name: "Nodes", href: "/dashboard/infrastructure/nodes", icon: Server },
{ name: "Networks", href: "/dashboard/infrastructure/networks", icon: Network },
{ name: "Services", href: "/dashboard/infrastructure/services", icon: Boxes },
{ name: "Observability", href: "/dashboard/infrastructure/observability", icon: Eye },
{ name: "Monitoring", href: "/dashboard/infrastructure/monitoring", icon: Activity },
]
},
{
name: "API & Development",
href: "/dashboard/api",
icon: Code,
description: "API keys, webhooks, and developer tools",
children: [
{ name: "API Keys", href: "/dashboard/api-keys", icon: Key },
{ name: "Webhooks", href: "/dashboard/api/webhooks", icon: Webhook },
{ name: "Documentation", href: "/dashboard/api/docs", icon: FileText },
{ name: "Playground", href: "/dashboard/api/playground", icon: Zap },
]
},
{
name: "Analytics",
href: "/dashboard/analytics",
icon: BarChart3,
description: "Usage metrics, performance, and insights",
children: [
{ name: "Usage", href: "/dashboard/analytics/usage", icon: Activity },
{ name: "Performance", href: "/dashboard/analytics/performance", icon: Zap },
{ name: "Billing", href: "/dashboard/organization/billing", icon: CreditCard },
]
},
{
name: "Assets",
href: "/dashboard/assets",
icon: Coins,
description: "Token balances, NFTs, and portfolio",
children: [
{ name: "Tokens", href: "/dashboard/assets/tokens", icon: Coins },
{ name: "NFTs", href: "/dashboard/assets/nfts", icon: Globe },
{ name: "Transfers", href: "/dashboard/assets/transfers", icon: Activity },
]
},
{
name: "Organization",
href: "/dashboard/organization",
icon: Building,
description: "Team, members, and organization settings",
children: [
{ name: "Team", href: "/dashboard/organization/team", icon: Users },
{ name: "Settings", href: "/dashboard/organization/settings", icon: Settings },
{ name: "Billing", href: "/dashboard/organization/billing", icon: CreditCard },
{ name: "Security", href: "/dashboard/organization/security", icon: Shield },
]
}
]
// Ecosystem apps from brand config
const brand = getBrand()
const ecosystemNavigation: NavigationItem[] = brand.ecosystemApps.length > 0
? [{
name: "Ecosystem",
href: "/dashboard/ecosystem",
icon: Globe,
description: "Ecosystem apps and services",
children: brand.ecosystemApps.map(app => ({
name: app.name,
href: app.href,
icon: ExternalLink,
})),
}]
: []
// Admin-only navigation
const adminNavigation: NavigationItem[] = [
{
name: "Admin",
href: "/admin",
icon: Shield,
description: "Platform administration and management",
children: [
{ name: "Overview", href: "/admin", icon: Activity },
{ name: "All Nodes", href: "/admin/nodes", icon: Server },
{ name: "All Networks", href: "/admin/networks", icon: Network },
{ name: "All Users", href: "/admin/users", icon: Users },
{ name: "System Health", href: "/admin/health", icon: Shield },
]
}
]
const isActive = (href: string) => {
if (href === "/dashboard") return pathname === href
return pathname.startsWith(href)
}
const organizations = [
{ id: "hanzo", name: "Hanzo", domain: "hanzo.id", color: "#0066ff" },
{ id: "zoo", name: "Zoo Labs", domain: "id.zoo.network", color: "#00cc66" },
{ id: "lux", name: "Lux Network", domain: "lux.id", color: "#8b5cf6" },
{ id: "pars", name: "Pars", domain: "pars.id", color: "#f59e0b" },
]
const currentOrg = organizations.find(org => org.id === selectedOrg) || organizations[0]
return (
<>
{/* Top Navigation Bar */}
<nav className="bg-background border-b border-border fixed w-full top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
{/* Logo */}
<Link href="/dashboard" className="flex items-center space-x-3">
<img src={getBrand().logoIcon} alt={getBrand().name} className="w-8 h-8" />
<span className="text-xl font-bold">{getBrand().name}</span>
</Link>
{/* Organization Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="ml-6 flex items-center space-x-2">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: currentOrg.color }}
/>
<span>{currentOrg.name}</span>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Switch Organization</DropdownMenuLabel>
<DropdownMenuSeparator />
{organizations.map((org) => (
<DropdownMenuItem
key={org.id}
onClick={() => setSelectedOrg(org.id)}
className="flex items-center space-x-2"
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: org.color }}
/>
<div>
<div className="font-medium">{org.name}</div>
<div className="text-xs text-muted-foreground">{org.domain}</div>
</div>
{org.id === selectedOrg && <Badge variant="secondary">Current</Badge>}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem>
<UserPlus className="mr-2 h-4 w-4" />
Create Organization
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Right side */}
<div className="flex items-center space-x-4">
{/* Command Menu + AI Chat */}
<CommandMenu />
{/* Notifications */}
<Button variant="ghost" size="sm">
<Bell className="h-5 w-5" />
</Button>
{/* User Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center space-x-2">
<Avatar className="h-8 w-8">
<AvatarImage src={user?.avatar} />
<AvatarFallback>{user?.name?.charAt(0)}</AvatarFallback>
</Avatar>
<span className="hidden md:block">{user?.name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div>{user?.name}</div>
<div className="text-xs text-muted-foreground">{user?.email}</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Settings className="mr-2 h-4 w-4" />
Profile Settings
</DropdownMenuItem>
<DropdownMenuItem>
<Key className="mr-2 h-4 w-4" />
API Keys
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Mobile menu button */}
<Button
variant="ghost"
size="sm"
className="md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
</div>
</div>
</div>
</nav>
{/* Side Navigation */}
<div className="fixed inset-y-0 left-0 z-40 w-64 bg-sidebar border-r border-border pt-16 overflow-y-auto">
<nav className="p-4 space-y-2">
{/* Main Navigation */}
{navigation.map((item) => (
<NavItem key={item.name} item={item} isActive={isActive} />
))}
{/* Ecosystem Apps */}
{ecosystemNavigation.length > 0 && (
<>
<div className="pt-4 mt-4 border-t border-border">
<h3 className="px-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Ecosystem
</h3>
</div>
{ecosystemNavigation.map((item) => (
<NavItem key={item.name} item={item} isActive={isActive} />
))}
</>
)}
{/* Admin Navigation (if admin) */}
{isAdmin && (
<>
<div className="pt-4 mt-4 border-t border-border">
<h3 className="px-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Administration
</h3>
</div>
{adminNavigation.map((item) => (
<NavItem key={item.name} item={item} isActive={isActive} />
))}
</>
)}
</nav>
{/* Organization Info */}
<div className="absolute bottom-0 left-0 right-0 p-4 bg-background border-t border-border">
<div className="flex items-center space-x-3">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-sm font-bold"
style={{ backgroundColor: currentOrg.color }}
>
{currentOrg.name.charAt(0)}
</div>
<div>
<div className="font-medium text-sm">{currentOrg.name}</div>
<div className="text-xs text-muted-foreground">{currentOrg.domain}</div>
</div>
</div>
</div>
</div>
{/* Mobile Navigation Overlay */}
{mobileMenuOpen && (
<div className="fixed inset-0 z-30 md:hidden">
<div className="absolute inset-0 bg-black opacity-25" onClick={() => setMobileMenuOpen(false)} />
<div className="absolute left-0 top-16 w-64 h-full bg-background border-r border-border overflow-y-auto">
<nav className="p-4 space-y-2">
{navigation.concat(ecosystemNavigation).concat(isAdmin ? adminNavigation : []).map((item) => (
<NavItem
key={item.name}
item={item}
isActive={isActive}
onClick={() => setMobileMenuOpen(false)}
/>
))}
</nav>
</div>
</div>
)}
{/* Page Content Wrapper */}
<div className="pl-64 pt-16">
{/* This will wrap the page content */}
</div>
</>
)
}
// Navigation Item Component
function NavItem({
item,
isActive,
onClick
}: {
item: NavigationItem
isActive: (href: string) => boolean
onClick?: () => void
}) {
const [isExpanded, setIsExpanded] = useState(isActive(item.href))
const hasChildren = item.children && item.children.length > 0
return (
<div>
<Link
href={item.href}
onClick={onClick}
className={`
flex items-center justify-between w-full px-3 py-2 text-sm font-medium rounded-md transition-colors
${isActive(item.href)
? 'bg-primary text-primary-foreground'
: 'text-foreground/80 hover:bg-accent'
}
`}
>
<div className="flex items-center space-x-3">
<item.icon className="h-5 w-5" />
<span>{item.name}</span>
{item.badge && (
<Badge variant="secondary" className="ml-auto">
{item.badge}
</Badge>
)}
</div>
{hasChildren && (
<ChevronDown
className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
onClick={(e) => {
e.preventDefault()
setIsExpanded(!isExpanded)
}}
/>
)}
</Link>
{/* Sub-navigation */}
{hasChildren && isExpanded && (
<div className="ml-8 mt-1 space-y-1">
{item.children!.map((child) => {
const isExternal = child.href.startsWith("http")
const linkProps = isExternal
? { target: "_blank" as const, rel: "noopener noreferrer" }
: {}
return (
<Link
key={child.name}
href={child.href}
onClick={onClick}
{...linkProps}
className={`
flex items-center space-x-2 px-3 py-2 text-sm rounded-md transition-colors
${!isExternal && isActive(child.href)
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent'
}
`}
>
<child.icon className="h-4 w-4" />
<span>{child.name}</span>
{isExternal && <ExternalLink className="h-3 w-3 ml-auto opacity-50" />}
</Link>
)
})}
</div>
)}
</div>
)
}
+32 -45
View File
@@ -1,14 +1,15 @@
// Bootnode Authentication
// - Development: Built-in email/password auth
// - Production: Hanzo IAM (hanzo.id, zoo.id, lux.id, pars.id)
// Bootnode authentication.
// Local development: the built-in email/password form.
// Every deployed host: that host's own IAM, via PKCE (see lib/iam.ts).
// Which issuer that is, is decided by the hostname — see lib/brand.ts.
"use client"
import { getApiUrl } from "@/lib/api"
import { createContext, useContext, useEffect, useState, useCallback } from "react"
import { createContext, useContext, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { getBrand } from "@/lib/brand"
import { getApiUrl } from "@/lib/api"
import { ErrorState, Loading } from "@/components/ui/states"
interface BootnodeUser {
id: string
@@ -36,10 +37,14 @@ interface AuthContextType {
const AuthContext = createContext<AuthContextType | undefined>(undefined)
// Check if we're in production mode
// Any host that is not a developer's own machine authenticates through IAM.
//
// This used to be forceable with NEXT_PUBLIC_AUTH_MODE=iam. That was a
// build-time constant baked into one image that serves every white-label host
// — the same defect as NEXT_PUBLIC_BRAND — and it was redundant besides: the
// hostname test below already answers correctly for every deployed host.
const isProductionMode = () => {
if (typeof window === "undefined") return false
if (process.env.NEXT_PUBLIC_AUTH_MODE === "iam") return true
const h = window.location.hostname
return h !== "localhost" && h !== "127.0.0.1" && h !== "0.0.0.0" && !h.startsWith("192.168.") && !h.startsWith("10.")
}
@@ -61,14 +66,16 @@ export function AuthProvider({ children }: AuthProviderProps) {
const [isLoading, setIsLoading] = useState(true)
const isProduction = isProductionMode()
const getApiUrl = useCallback(() => {
if (typeof window !== "undefined") {
const h = window.location.hostname
const isLocal = h === "localhost" || h === "127.0.0.1" || h === "0.0.0.0" || h.startsWith("192.168.") || h.startsWith("10.")
if (!isLocal) return getBrand().apiUrl
}
return getApiUrl()
}, [])
// The API origin comes from lib/api's getApiUrl() — the ONE resolver, which
// already returns the localStorage override when set and the brand's origin
// otherwise.
//
// A local copy of that logic used to live here, and it was infinitely
// recursive: `const getApiUrl = useCallback(...)` shadowed the import at the
// top of this file, and its own last line called `getApiUrl()`. On any host
// it classified as local — localhost, 127.0.0.1, 192.168.*, 10.* — every
// checkAuth / login / register overflowed the stack. That is the whole of
// local development, and it is what two copies of one function buys you.
useEffect(() => {
checkAuth()
@@ -239,41 +246,21 @@ export function ProtectedRoute({
}
}, [user, isLoading, router])
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
if (!user) {
return (
<div className="flex h-screen items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)
}
// Checking the session, and the moment after it comes back empty and before
// the redirect to /login lands, are the same state to the reader: "not yet".
if (isLoading || !user) return <Loading full label="Checking your session…" />
if (requiredRole && !hasRole(requiredRole)) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Access Denied</h1>
<p className="text-muted-foreground">You don't have the required role: {requiredRole}</p>
</div>
</div>
)
return <ErrorState className="py-24" title="Access denied" description={`This page requires the ${requiredRole} role.`} />
}
if (requiredPermission && !hasPermission(requiredPermission)) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Access Denied</h1>
<p className="text-muted-foreground">You don't have the required permission: {requiredPermission}</p>
</div>
</div>
<ErrorState
className="py-24"
title="Access denied"
description={`This page requires the ${requiredPermission} permission.`}
/>
)
}
+110
View File
@@ -0,0 +1,110 @@
// The white-label contract, as executable assertions.
//
// Run: `pnpm test` (node:test + native TypeScript stripping — no test
// framework, no new dependency).
//
// Every expectation here was verified against the live IAM before it was
// written down; see the probe table at the top of lib/brand.ts. This file is
// what stops it drifting: change a client id or a host and the build's test
// step says so, instead of a customer discovering it at the login screen.
import { strict as assert } from "node:assert"
import { describe, it } from "node:test"
import { getBrand, getBrandKey } from "./brand.ts"
// host → [brand key, IAM issuer, OIDC client id]. The five hosts this one
// image must serve, plus the aliases that ride along with them.
const CONTRACT: [string, string, string, string][] = [
["bootno.de", "bootnode", "https://id.bootno.de", "bootnode-platform"],
["web3.hanzo.ai", "hanzo", "https://hanzo.id", "hanzo-web3"],
["lux.cloud", "lux", "https://lux.id", "lux-cloud"],
["zoo.cloud", "zoo", "https://zoolabs.id", "zoo-cloud"],
["cloud.zoo.network", "zoo", "https://zoolabs.id", "zoo-cloud"],
// Aliases that must inherit their parent's brand.
["www.bootno.de", "bootnode", "https://id.bootno.de", "bootnode-platform"],
["nodes.lux.cloud", "lux", "https://lux.id", "lux-cloud"],
["console.zoo.cloud", "zoo", "https://zoolabs.id", "zoo-cloud"],
["cloud.pars.network", "pars", "https://pars.id", "pars-app"],
]
describe("white-label by hostname", () => {
for (const [host, key, issuer, clientId] of CONTRACT) {
it(`${host} → ${key} / ${clientId}`, () => {
const brand = getBrand(host)
assert.equal(getBrandKey(host), key)
assert.equal(brand.key, key)
assert.equal(brand.iam.url, issuer)
assert.equal(brand.iam.clientId, clientId)
})
}
it("does not regress the one login that works today", () => {
// lux.cloud + lux-cloud + https://lux.cloud/auth/callback is the triple
// that answers 302 on lux.id and lands a real user on /dashboard. If this
// assertion ever fails, the merge broke a working production login.
const lux = getBrand("lux.cloud")
assert.equal(lux.iam.url, "https://lux.id")
assert.equal(lux.iam.clientId, "lux-cloud")
})
it("resolves unknown hosts and localhost to the neutral default", () => {
for (const host of ["localhost", "127.0.0.1", "example.com", ""]) {
assert.equal(getBrandKey(host), "bootnode")
}
})
it("matches on exact host or dot-suffix, never on substring", () => {
// The old resolver used `hostname.includes(match)`, which would hand a
// real brand's OIDC client id — and therefore its login — to any host an
// attacker can name. These must all fall through to the default.
for (const host of [
"lux.cloud.attacker.example",
"zoo.cloudy.example",
"notbootno.de.example",
"web3.hanzo.ai.evil.test",
"xlux.cloud",
]) {
assert.equal(getBrandKey(host), "bootnode", host)
}
})
it("gives every brand its own mark, wordmark and favicon", () => {
// One image, five identities: a shared /logo.svg for all of them would
// mean bootno.de's mark showing up on zoo.cloud.
const seen = new Set<string>()
for (const host of ["bootno.de", "web3.hanzo.ai", "lux.cloud", "zoo.cloud", "cloud.pars.network"]) {
const brand = getBrand(host)
assert.match(brand.logoIcon, new RegExp(`/logo/${brand.key}-icon\\.svg$`))
assert.match(brand.logoWhite, new RegExp(`/logo/${brand.key}-logo-white\\.svg$`))
assert.equal(brand.favicon, brand.logoIcon)
assert.ok(!seen.has(brand.name), `duplicate brand name ${brand.name}`)
seen.add(brand.name)
}
assert.equal(seen.size, 5)
})
it("flags exactly the marks that cannot paint themselves", () => {
// Measured from public/logo/*-icon.svg: bootnode is fill="currentColor"
// and lux is fill="white" — both need the current-text-colour mask, or one
// is invisible on black and the other on white. The rest ship a palette.
assert.equal(getBrand("bootno.de").monoMark, true)
assert.equal(getBrand("lux.cloud").monoMark, true)
assert.equal(getBrand("zoo.cloud").monoMark, false)
assert.equal(getBrand("web3.hanzo.ai").monoMark, false)
assert.equal(getBrand("cloud.pars.network").monoMark, false)
})
it("carries a complete, non-empty config for every brand", () => {
for (const host of ["bootno.de", "web3.hanzo.ai", "lux.cloud", "zoo.cloud", "cloud.pars.network"]) {
const b = getBrand(host)
for (const field of ["name", "shortName", "tagline", "domain", "apiUrl", "statusUrl", "networkName"] as const) {
assert.ok(b[field], `${host}: ${field} is empty`)
}
// Partial catalog entries used to produce `undefined` colours and crash
// rendering, which is why no catalog was ever shipped.
assert.ok(b.colors.accent && b.colors.accentEnd)
assert.ok(b.iam.idLabel)
assert.match(b.apiUrl, /^https:\/\//)
}
})
})
+227 -187
View File
@@ -1,228 +1,268 @@
// White-label branding — runtime-resolved, brand-neutral source.
// White-label by HOSTNAME. One build, one image, every brand.
//
// This module knows ONE brand: bootnode's own neutral defaults (the built-in
// fallback). Every real brand (lux / zoo / pars / hanzo / …) is supplied at
// RUNTIME from an external catalog injected per-deploy — bootnode the app ships
// no brand names, domains, or links in source.
// The brand — identity, theme, API origin, IAM issuer and OIDC client — is a
// pure function of `window.location.hostname`. There is no per-deployment
// config: no NEXT_PUBLIC_BRAND, no injected `window.__BRAND_CATALOG__`, no
// fetched /config.json. Those were the same defect wearing three hats — a
// value chosen at deploy time, while ONE deployment serves every host, so
// whichever brand the last deploy picked was wrong for the other four.
//
// Mechanism (mirrors the @hanzo/id SPA's runtime config loader):
// 1. The deploy serves `/config.json` and/or injects a `window.__BRAND_CATALOG__`
// global (a host->BrandConfig map, or a string of one) into the page.
// 2. `applyBrandCatalog()` ingests it; `getBrandKey()` resolves the active
// brand by `window.location.hostname` against the catalog.
// 3. `getBrand()` returns the resolved BrandConfig, falling back to the
// built-in neutral `bootnode` defaults when no catalog entry matches.
// The catalog lives HERE, in source, because it is neither a secret nor an
// environment: it is a fact about which domain belongs to which network, and a
// fact belongs next to the code that reads it.
//
// The full multi-brand catalog lives in the DEPLOY config (universe / CF-Pages),
// NOT in this repo. See `public/config.json` for the contract + the README note
// in the PR for where the per-host catalog is provisioned.
// Every issuer + client_id + callback triple below was verified against the
// live /v1/iam/oauth/authorize (HIP-0111) on 2026-07-26 — a registered pair
// answers 302, an unregistered redirect_uri answers 400:
//
// id.bootno.de bootnode-platform https://bootno.de/auth/callback 302
// hanzo.id hanzo-web3 https://web3.hanzo.ai/auth/callback 302
// lux.id lux-cloud https://lux.cloud/auth/callback 302
// zoolabs.id zoo-cloud https://zoo.cloud/auth/callback 302
// zoolabs.id zoo-cloud https://cloud.zoo.network/auth/callback 302
// pars.id pars-app https://cloud.pars.network/auth/callback 302
// zoolabs.id zoo-web3 https://cloud.zoo.network/auth/callback 400 ← retired
//
// Adding a host = one line in HOSTS. Adding a brand = one entry in BRANDS plus
// registering `https://<host>/auth/callback` on that brand's IAM. Nothing else
// in the app carries a brand string, an issuer, or a client id.
export type BrandKey = "bootnode" | "lux" | "zoo" | "hanzo" | "pars"
export type BrandConfig = {
name: string
key: BrandKey
name: string // wordmark + document.title, e.g. "Lux Cloud"
shortName: string // compact refs, e.g. "Lux" ("Try Lux")
tagline: string
description: string
logo: string
logoIcon: string
logoWhite: string
favicon: string
domain: string
apiUrl: string
// Is the mark a single-colour silhouette? Measured from the assets:
// bootnode-icon.svg is fill="currentColor" and lux-icon.svg is fill="white",
// so as <img> (an isolated document, where currentColor resolves to black)
// one is invisible on black and the other on white. Those two are painted in
// the current text colour via a CSS mask instead — which is also exactly what
// lux.cloud does with its own mark. Zoo's CMYK Venn, Hanzo's badge and Pars'
// gold star carry their own palette and are rendered as images, untouched.
monoMark: boolean
domain: string // canonical public host for this brand
apiUrl: string // bootnode control-plane origin
wsUrl: string
statusUrl: string
skillsOrg: string // GitHub org for skills repo (e.g., "bootnode", "hanzoai")
networkName: string // e.g. "Lux Network" — header/footer chrome
networkUrl: string
skillsOrg: string // GitHub org for the skills repo
defaultTheme: "light" | "dark"
colors: {
primary: string
primaryForeground: string
accent: string // Hero gradient start
accentEnd: string // Hero gradient end
}
social: {
twitter?: string
github?: string
discord?: string
accent: string // hero gradient start
accentEnd: string // hero gradient end
}
social: { twitter?: string; github?: string; discord?: string }
iam: {
url: string
clientId: string
domain: string // e.g., hanzo.id, zoo.id
url: string // OIDC origin the SDK runs discovery against
clientId: string // public PKCE client, always <org>-<app>
domain: string // bare host, for display
idLabel: string // identity brand, e.g. "Lux ID"
}
footerLinks: {
name: string
href: string
external?: boolean
}[]
// Ecosystem apps — retail-facing services linked from dashboard
footerLinks: { name: string; href: string; external?: boolean }[]
// Ecosystem apps — retail-facing services linked from the console launcher.
ecosystemApps: {
name: string
href: string
description: string
icon: string // lucide icon name
icon: string // lucide icon name
category: "explore" | "defi" | "tools" | "governance"
}[]
}
// Runtime catalog: host substring -> brand key, plus key -> BrandConfig.
// Both are empty in source. Populated at runtime by `applyBrandCatalog()` from
// the deploy-injected config (see public/config.json for the shape).
export type BrandCatalog = {
// Ordered host matchers. First whose `match` is a substring of the hostname wins.
hosts?: { match: string; key: string }[]
// Brand definitions, keyed by brand key (e.g. "lux", "zoo").
brands?: Record<string, BrandConfig>
// Default brand key for unknown hosts (defaults to "bootnode").
defaultKey?: string
}
// The bootnode control plane. api.bootno.de, api.web3.hanzo.ai and
// api.cloud.lux.network are the SAME service behind three names — verified
// 2026-07-26, /v1/chains byte-identical across all three
// (sha256 2aa472c11e47d3f67e53be17fb24230fccc94630b485e6f3864d51fa2a4e1400).
// api.cloud.zoo.network and api.cloud.pars.network do NOT resolve, so Zoo and
// Pars point at the one origin that answers rather than at a derived name that
// would fail. Per-brand vanity origins can be added the day they exist.
const API = "https://api.bootno.de"
const WS = "wss://ws.bootno.de"
// The ONE built-in brand: bootnode's neutral defaults. Used for dev, SSG, and
// any host with no catalog entry. Intentionally generic — no third-party brand.
const BOOTNODE_DEFAULT: BrandConfig = {
name: "Bootnode",
tagline: "Blockchain Infrastructure for Developers",
description:
"The complete blockchain development platform. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, Webhooks, and more.",
logo: "/logo.svg",
logoIcon: "/logo.svg",
logoWhite: "/logo.svg",
favicon: "/logo.svg",
domain: "bootno.de",
apiUrl: "https://api.bootno.de",
wsUrl: "wss://ws.bootno.de",
statusUrl: "https://status.bootno.de",
skillsOrg: "bootnode",
defaultTheme: "light",
colors: {
primary: "#000000",
primaryForeground: "#ffffff",
accent: "#3b82f6",
accentEnd: "#06b6d4",
const BRANDS: Record<BrandKey, BrandConfig> = {
// DEFAULT. bootno.de, localhost, and any unmatched host resolve here.
bootnode: {
key: "bootnode",
name: "Bootnode",
shortName: "Bootnode",
tagline: "Blockchain Infrastructure for Developers",
description:
"The complete blockchain development platform. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, Webhooks, and more.",
logo: "/logo/bootnode-logo.svg",
logoIcon: "/logo/bootnode-icon.svg",
logoWhite: "/logo/bootnode-logo-white.svg",
favicon: "/logo/bootnode-icon.svg",
monoMark: true,
domain: "bootno.de",
apiUrl: API,
wsUrl: WS,
statusUrl: "https://status.bootno.de",
networkName: "Bootnode",
networkUrl: "https://bootno.de",
skillsOrg: "bootnode",
defaultTheme: "dark",
colors: { primary: "#f5f5f5", primaryForeground: "#000000", accent: "#a8a8ff", accentEnd: "#8bffd1" },
social: { github: "https://github.com/bootnode" },
iam: { url: "https://id.bootno.de", clientId: "bootnode-platform", domain: "id.bootno.de", idLabel: "Bootnode ID" },
footerLinks: [],
ecosystemApps: [],
},
social: {},
iam: {
// HIP-0111: bootnode's canonical OIDC issuer is id.bootno.de; the
// registered Casdoor client for bootno.de/auth/callback is bootnode-platform.
// (Per-brand catalogs for lux/zoo/pars/hanzo are injected at deploy time —
// see public/config.example.json for the registered clientId per brand.)
url: "https://id.bootno.de",
clientId: "bootnode-platform",
domain: "id.bootno.de",
lux: {
key: "lux",
name: "Lux Cloud",
shortName: "Lux",
tagline: "Blockchain Infrastructure",
description:
"Enterprise blockchain infrastructure for the Lux Network ecosystem. Multi-chain RPC, MPC wallets, bridges, DEX, and white-label chain deployment.",
logo: "/logo/lux-logo.svg",
logoIcon: "/logo/lux-icon.svg",
logoWhite: "/logo/lux-logo-white.svg",
favicon: "/logo/lux-icon.svg",
monoMark: true,
domain: "lux.cloud",
apiUrl: API,
wsUrl: WS,
statusUrl: "https://status.lux.network",
networkName: "Lux Network",
networkUrl: "https://lux.network",
skillsOrg: "luxfi",
defaultTheme: "dark",
colors: { primary: "#f5f5f5", primaryForeground: "#000000", accent: "#a8a8ff", accentEnd: "#8bffd1" },
social: { github: "https://github.com/luxfi" },
iam: { url: "https://lux.id", clientId: "lux-cloud", domain: "lux.id", idLabel: "Lux ID" },
footerLinks: [],
ecosystemApps: [],
},
// zoo.cloud + cloud.zoo.network. id.zoo.network is an ALIAS, not the issuer:
// its discovery document advertises issuer/authorize/token all on zoolabs.id
// (measured 2026-07-26), so pointing the SDK at zoolabs.id removes a hop and
// matches the `iss` the token is actually stamped with.
zoo: {
key: "zoo",
name: "Zoo Cloud",
shortName: "Zoo",
tagline: "Blockchain Infrastructure",
description:
"Enterprise blockchain infrastructure for the Zoo Network ecosystem. Multi-chain RPC, MPC wallets, bridges, DEX, and white-label chain deployment.",
logo: "/logo/zoo-logo.svg",
logoIcon: "/logo/zoo-icon.svg",
logoWhite: "/logo/zoo-logo-white.svg",
favicon: "/logo/zoo-icon.svg",
monoMark: false,
domain: "zoo.cloud",
apiUrl: API,
wsUrl: WS,
statusUrl: "https://status.zoo.network",
networkName: "Zoo Network",
networkUrl: "https://zoo.network",
skillsOrg: "zooai",
defaultTheme: "dark",
colors: { primary: "#f5f5f5", primaryForeground: "#000000", accent: "#8bffd1", accentEnd: "#a8a8ff" },
social: { github: "https://github.com/zooai" },
iam: { url: "https://zoolabs.id", clientId: "zoo-cloud", domain: "zoolabs.id", idLabel: "Zoo ID" },
footerLinks: [],
ecosystemApps: [],
},
hanzo: {
key: "hanzo",
name: "Hanzo",
shortName: "Hanzo",
tagline: "Blockchain Infrastructure",
description:
"Enterprise blockchain infrastructure for the Hanzo Network ecosystem. Multi-chain RPC, MPC wallets, bridges, DEX, and white-label chain deployment.",
logo: "/logo/hanzo-logo.svg",
logoIcon: "/logo/hanzo-icon.svg",
logoWhite: "/logo/hanzo-logo-white.svg",
favicon: "/logo/hanzo-icon.svg",
monoMark: false,
domain: "web3.hanzo.ai",
apiUrl: API,
wsUrl: WS,
statusUrl: "https://status.hanzo.ai",
networkName: "Hanzo Network",
networkUrl: "https://hanzo.ai",
skillsOrg: "hanzoai",
defaultTheme: "dark",
colors: { primary: "#f5f5f5", primaryForeground: "#000000", accent: "#a8a8ff", accentEnd: "#ff8bc0" },
social: { github: "https://github.com/hanzoai" },
iam: { url: "https://hanzo.id", clientId: "hanzo-web3", domain: "hanzo.id", idLabel: "Hanzo ID" },
footerLinks: [],
ecosystemApps: [],
},
pars: {
key: "pars",
name: "Pars",
shortName: "Pars",
tagline: "Blockchain Infrastructure",
description:
"Enterprise blockchain infrastructure for the Pars Network ecosystem. Multi-chain RPC, MPC wallets, bridges, DEX, and white-label chain deployment.",
logo: "/logo/pars-logo.svg",
logoIcon: "/logo/pars-icon.svg",
logoWhite: "/logo/pars-logo-white.svg",
favicon: "/logo/pars-icon.svg",
monoMark: false,
domain: "cloud.pars.network",
apiUrl: API,
wsUrl: WS,
statusUrl: "https://status.pars.network",
networkName: "Pars Network",
networkUrl: "https://pars.network",
skillsOrg: "parsdao",
defaultTheme: "dark",
colors: { primary: "#f5f5f5", primaryForeground: "#000000", accent: "#ffd18b", accentEnd: "#ff8bc0" },
social: { github: "https://github.com/parsdao" },
iam: { url: "https://pars.id", clientId: "pars-app", domain: "pars.id", idLabel: "Pars ID" },
footerLinks: [],
ecosystemApps: [],
},
footerLinks: [],
ecosystemApps: [],
}
const DEFAULT_KEY = "bootnode"
// Host → brand. Matched EXACTLY or as a dot-suffix, never as a substring: a
// substring match ("lux.cloud" is a substring of "lux.cloud.attacker.example")
// hands an attacker-controlled origin a real brand's IAM client id. First match
// wins, so the more specific host is listed first.
const HOSTS: { host: string; key: BrandKey }[] = [
{ host: "bootno.de", key: "bootnode" },
{ host: "web3.hanzo.ai", key: "hanzo" },
{ host: "cloud.hanzo.network", key: "hanzo" },
{ host: "lux.cloud", key: "lux" },
{ host: "zoo.cloud", key: "zoo" },
{ host: "zoo.network", key: "zoo" }, // covers cloud.zoo.network
{ host: "pars.network", key: "pars" }, // covers cloud.pars.network
]
// Active runtime catalog. Starts empty (source ships no brands). The deploy
// fills it via `applyBrandCatalog()` before/at hydration.
let catalog: BrandCatalog = {}
const DEFAULT_KEY: BrandKey = "bootnode"
/**
* Parse a raw catalog value (object or JSON string) into a BrandCatalog.
* Returns {} on any error — never throws (mirrors id SPA's `parseCatalog`).
* Resolve the brand key for a hostname. Pass one explicitly to resolve for a
* host other than the current page; omit it in the browser to use
* `window.location.hostname`. During the static export there IS no host, so
* this returns the default and BrandProvider re-resolves after hydration.
*/
export function parseBrandCatalog(raw: unknown): BrandCatalog {
if (!raw) return {}
try {
const obj = typeof raw === "string" ? JSON.parse(raw) : raw
if (!obj || typeof obj !== "object") return {}
return obj as BrandCatalog
} catch {
return {}
export function getBrandKey(hostname?: string): BrandKey {
const host = (hostname ?? (typeof window === "undefined" ? "" : window.location.hostname)).toLowerCase()
if (!host) return DEFAULT_KEY
for (const rule of HOSTS) {
if (host === rule.host || host.endsWith("." + rule.host)) return rule.key
}
return DEFAULT_KEY
}
/**
* Install the runtime brand catalog. Idempotent — safe to call repeatedly
* (e.g. once from the inline boot script, once after the /config.json fetch).
* Accepts either a parsed object or a raw JSON string.
*/
export function applyBrandCatalog(raw: unknown): void {
const next = parseBrandCatalog(raw)
if (Object.keys(next).length > 0) catalog = next
/** The active brand. Synchronous and total — every key has a complete entry. */
export function getBrand(hostname?: string): BrandConfig {
return BRANDS[getBrandKey(hostname)]
}
/**
* Read the catalog injected as a global by the deploy
* (`window.__BRAND_CATALOG__`). Synchronous — available before the first
* render when the deploy inlines it in the document head.
*/
function readInjectedCatalog(): BrandCatalog {
if (typeof window === "undefined") return {}
const w = window as unknown as { __BRAND_CATALOG__?: unknown }
return parseBrandCatalog(w.__BRAND_CATALOG__)
}
function activeCatalog(): BrandCatalog {
if (catalog.brands || catalog.hosts) return catalog
return readInjectedCatalog()
}
/**
* Resolve the active brand key from the hostname against the runtime catalog.
* No hardcoded host switches — the host->key table is data, supplied at deploy.
*/
function getBrandKey(): string {
const cat = activeCatalog()
const fallback = cat.defaultKey || DEFAULT_KEY
if (typeof window === "undefined") return fallback
const hostname = window.location.hostname.toLowerCase()
for (const rule of cat.hosts ?? []) {
if (rule.match && hostname.includes(rule.match.toLowerCase())) return rule.key
}
return fallback
}
// Export the active brand configuration. Synchronous so module-level callers
// (`const brand = getBrand()`) keep working; resolves against the injected
// catalog when present, else the built-in neutral default.
export function getBrand(): BrandConfig {
const cat = activeCatalog()
const key = getBrandKey()
const entry = cat.brands?.[key] ?? cat.brands?.[DEFAULT_KEY]
// Catalog entries are OVERRIDES on the complete built-in default, not
// replacements. A brand that only needs a different IAM issuer writes just
// its `iam` block; everything it does not mention (name, colours, logo, urls)
// still resolves. Replacing outright meant any partial entry produced a
// BrandConfig with undefined `colors`/`name` and crashed rendering, which is
// why no catalog was ever shipped and every host fell back to bootnode.
return entry ? mergeBrand(BOOTNODE_DEFAULT, entry) : BOOTNODE_DEFAULT
}
// One level of nesting is enough for BrandConfig: the nested objects (iam,
// colors, social) are flat records, and the arrays (footerLinks, ecosystemApps)
// are replaced wholesale rather than concatenated so a brand can legitimately
// present an empty footer.
function mergeBrand(base: BrandConfig, over: Partial<BrandConfig>): BrandConfig {
const out = { ...base } as Record<string, unknown>
for (const [k, v] of Object.entries(over)) {
if (v === undefined || v === null) continue
const b = (base as Record<string, unknown>)[k]
out[k] =
!Array.isArray(v) && typeof v === "object" && !Array.isArray(b) && typeof b === "object" && b
? { ...(b as object), ...(v as object) }
: v
}
return out as BrandConfig
}
// Export brand key for conditional rendering / per-brand local maps
// (SDK package names, region data, localStorage namespacing).
export function getBrandKey_(): string {
return getBrandKey()
}
// Check if the active brand is the Hanzo brand.
export function isHanzoBrand(): boolean {
return getBrandKey() === "hanzo"
}
// Get all brands currently known at runtime (for admin/testing). Empty until a
// catalog is injected — source ships no brands.
export function getAllBrands(): Record<string, BrandConfig> {
return activeCatalog().brands ?? { [DEFAULT_KEY]: BOOTNODE_DEFAULT }
}
// Default export for convenience (neutral default at module-eval / SSG time;
// the BrandProvider re-resolves client-side once the runtime catalog loads).
export const brand = getBrand()
+115
View File
@@ -0,0 +1,115 @@
// The console navigation contract.
//
// Run: `pnpm test`.
import { strict as assert } from "node:assert"
import { describe, it } from "node:test"
import { DASHBOARD_NAVIGATION, buildCommandItems, currentHref, navHrefs } from "./dashboard-navigation.ts"
describe("console navigation", () => {
it("reaches every dashboard route the build emits", () => {
// Taken from `next build`'s route table. Before this nav was rewritten,
// the eight marked routes existed, rendered, and were reachable only by
// typing the URL — no link anywhere in the product pointed at them.
const built = [
"/dashboard",
"/dashboard/analytics",
"/dashboard/api-keys",
"/dashboard/assets",
"/dashboard/assets/nfts", // was unreachable
"/dashboard/assets/tokens", // was unreachable
"/dashboard/gas",
"/dashboard/infrastructure",
"/dashboard/infrastructure/clusters",
"/dashboard/infrastructure/lux", // was unreachable
"/dashboard/infrastructure/monitoring",
"/dashboard/infrastructure/networks",
"/dashboard/infrastructure/nodes",
"/dashboard/infrastructure/observability", // was unreachable
"/dashboard/infrastructure/services", // was unreachable
"/dashboard/infrastructure/storage",
"/dashboard/infrastructure/volumes",
"/dashboard/markets",
"/dashboard/monitoring",
"/dashboard/organization",
"/dashboard/organization/billing",
"/dashboard/organization/settings",
"/dashboard/organization/team",
"/dashboard/settings", // was unreachable
"/dashboard/staking",
"/dashboard/usage",
"/dashboard/wallets",
"/dashboard/webhooks",
"/admin",
"/chains", // was unreachable from the console
"/agents", // was unreachable from the console
"/docs",
]
const hrefs = new Set(navHrefs(true))
const missing = built.filter((r) => !hrefs.has(r))
assert.deepEqual(missing, [], `routes with no nav row: ${missing.join(", ")}`)
})
it("points every row at a real route", () => {
// The reverse direction: a row linking to a page that does not exist is a
// 404 with a friendly name on it.
for (const href of navHrefs(true)) {
assert.match(href, /^\/(dashboard|admin|chains|agents|docs)/, href)
}
})
it("hides the admin row from non-admins", () => {
assert.ok(navHrefs(true).includes("/admin"))
assert.ok(!navHrefs(false).includes("/admin"))
})
it("marks a row current despite the trailing slash the image serves", () => {
// The static export is served as directory indexes, so the live pathname
// always ends in "/". Matching on equality alone highlighted nothing.
const hrefs = navHrefs(true)
assert.equal(currentHref("/dashboard/", hrefs), "/dashboard")
assert.equal(currentHref("/dashboard", hrefs), "/dashboard")
assert.equal(currentHref("/dashboard/analytics/", hrefs), "/dashboard/analytics")
})
it("marks exactly the most specific row current, never two", () => {
const hrefs = navHrefs(true)
// /dashboard/infrastructure/nodes is under /dashboard/infrastructure, and
// both are rows. Only the deeper one is current.
assert.equal(currentHref("/dashboard/infrastructure/nodes/", hrefs), "/dashboard/infrastructure/nodes")
assert.equal(currentHref("/dashboard/infrastructure/", hrefs), "/dashboard/infrastructure")
// A detail page under a row keeps that row current.
assert.equal(currentHref("/dashboard/infrastructure/nodes/node-7/", hrefs), "/dashboard/infrastructure/nodes")
assert.equal(currentHref("/dashboard/organization/team/", hrefs), "/dashboard/organization/team")
// And "/dashboard" must never swallow its children.
for (const path of ["/dashboard/analytics/", "/dashboard/usage/", "/dashboard/wallets/"]) {
assert.notEqual(currentHref(path, hrefs), "/dashboard", path)
}
})
it("marks nothing current off the console", () => {
assert.equal(currentHref("/pricing/", navHrefs(true)), null)
assert.equal(currentHref("/", navHrefs(true)), null)
})
it("indexes the whole nav into the command palette", () => {
const items = buildCommandItems()
const nav = items.filter((i) => i.section === "Navigation")
assert.equal(nav.length, navHrefs(true).length)
for (const item of nav) {
assert.ok(item.title, `${item.href} has no title`)
assert.ok(item.description, `${item.href} has no description`)
assert.ok(item.keywords.length >= 3)
}
assert.ok(items.some((i) => i.section === "Actions"))
})
it("has no duplicate hrefs and no empty groups", () => {
const hrefs = navHrefs(true)
assert.equal(new Set(hrefs).size, hrefs.length, "duplicate href in nav")
for (const group of DASHBOARD_NAVIGATION) {
assert.ok(group.items.length > 0, `${group.label} is empty`)
assert.ok(group.label)
}
})
})
+122 -114
View File
@@ -1,113 +1,138 @@
import type { LucideIcon } from "lucide-react"
import {
Activity,
BarChart3,
Building,
Coins,
CreditCard,
Database,
FolderKanban,
Globe,
HeartPulse,
Home,
Key,
Layers,
Network,
Settings,
Shield,
Wallet,
Webhook,
} from "lucide-react"
// The ONE console navigation.
//
// Shape and grouping are lux.cloud's: flat labelled groups, text rows, no
// icons and no expand/collapse. A nested tree hides half the product behind a
// disclosure triangle, and it made this app's own nav incomplete —
// /dashboard/settings, /dashboard/assets/{tokens,nfts},
// /dashboard/infrastructure/{lux,services,observability}, /chains and /agents
// were all reachable by URL and by nothing else. Every one of them is a row
// now.
//
// This list is the single source: the sidebar renders it, and the command
// palette indexes it (buildCommandItems). Adding a section = one entry, and it
// appears in both.
export interface DashboardNavigationItem {
export type NavItem = {
name: string
href: string
icon: LucideIcon
description?: string
children?: DashboardNavigationItem[]
external?: boolean
/** Admin-only row — rendered only when the signed-in user holds `admin`. */
admin?: boolean
}
export const DASHBOARD_NAVIGATION: DashboardNavigationItem[] = [
export type NavGroup = {
label: string
items: NavItem[]
}
export const DASHBOARD_NAVIGATION: NavGroup[] = [
{
name: "Overview",
href: "/dashboard",
icon: Home,
description: "Command center and quick actions",
},
{
name: "Infrastructure",
href: "/dashboard/infrastructure",
icon: Database,
description: "Deploy and monitor node infrastructure",
children: [
{ name: "Nodes", href: "/dashboard/infrastructure/nodes", icon: Globe },
{ name: "Networks", href: "/dashboard/infrastructure/networks", icon: Network },
{ name: "Clusters", href: "/dashboard/infrastructure/clusters", icon: Layers },
{ name: "Storage", href: "/dashboard/infrastructure/storage", icon: Database },
{ name: "Volumes", href: "/dashboard/infrastructure/volumes", icon: FolderKanban },
label: "Overview",
items: [
{ name: "Overview", href: "/dashboard", description: "Command centre and quick actions" },
{ name: "Analytics", href: "/dashboard/analytics", description: "Usage, performance and chain telemetry" },
{ name: "Usage", href: "/dashboard/usage", description: "Compute units and quota consumption" },
{ name: "Markets", href: "/dashboard/markets", description: "Token markets and pricing" },
{ name: "Gas", href: "/dashboard/gas", description: "Gas prices across supported chains" },
],
},
{
name: "Monitoring",
href: "/dashboard/monitoring",
icon: HeartPulse,
description: "Health checks, alerts, and service monitoring",
},
{
name: "Analytics",
href: "/dashboard/analytics",
icon: BarChart3,
description: "Usage, performance, and chain-level telemetry",
children: [
{ name: "Analytics", href: "/dashboard/analytics", icon: Activity },
{ name: "Usage", href: "/dashboard/usage", icon: Activity },
{ name: "Markets", href: "/dashboard/markets", icon: Coins },
{ name: "Gas", href: "/dashboard/gas", icon: Activity },
label: "Infrastructure",
items: [
{ name: "Infrastructure", href: "/dashboard/infrastructure", description: "Everything you run, in one view" },
{ name: "Nodes", href: "/dashboard/infrastructure/nodes", description: "Deployed nodes and their health" },
{ name: "Networks", href: "/dashboard/infrastructure/networks", description: "Networks you participate in" },
{ name: "Clusters", href: "/dashboard/infrastructure/clusters", description: "Node clusters and regions" },
{ name: "Services", href: "/dashboard/infrastructure/services", description: "Managed services and endpoints" },
{ name: "Fleet Map", href: "/dashboard/infrastructure/lux", description: "Validator fleet by region" },
{ name: "Storage", href: "/dashboard/infrastructure/storage", description: "Object storage and buckets" },
{ name: "Volumes", href: "/dashboard/infrastructure/volumes", description: "Block volumes attached to nodes" },
],
},
{
name: "Developer",
href: "/dashboard/api-keys",
icon: Key,
description: "Access credentials and event integrations",
children: [
{ name: "API Keys", href: "/dashboard/api-keys", icon: Key },
{ name: "Webhooks", href: "/dashboard/webhooks", icon: Webhook },
label: "Observability",
items: [
{ name: "Monitoring", href: "/dashboard/monitoring", description: "Health checks, alerts and incidents" },
{
name: "Service Health",
href: "/dashboard/infrastructure/monitoring",
description: "Per-service uptime and latency",
},
{
name: "Observability",
href: "/dashboard/infrastructure/observability",
description: "Logs, traces and metrics",
},
],
},
{
name: "Assets",
href: "/dashboard/assets",
icon: Wallet,
description: "Wallets, staking, and digital asset controls",
children: [
{ name: "Assets", href: "/dashboard/assets", icon: Coins },
{ name: "Wallets", href: "/dashboard/wallets", icon: Wallet },
{ name: "Staking", href: "/dashboard/staking", icon: Coins },
label: "Assets",
items: [
{ name: "Assets", href: "/dashboard/assets", description: "Portfolio across every chain" },
{ name: "Tokens", href: "/dashboard/assets/tokens", description: "Fungible token balances" },
{ name: "NFTs", href: "/dashboard/assets/nfts", description: "Non-fungible holdings" },
{ name: "Wallets", href: "/dashboard/wallets", description: "Custody and signing wallets" },
{ name: "Staking", href: "/dashboard/staking", description: "Delegations and validator rewards" },
],
},
{
name: "Organization",
href: "/dashboard/organization",
icon: Building,
description: "Team, settings, and enterprise controls",
children: [
{ name: "Overview", href: "/dashboard/organization", icon: Building },
{ name: "Team", href: "/dashboard/organization/team", icon: Building },
{ name: "Settings", href: "/dashboard/organization/settings", icon: Settings },
{ name: "Billing", href: "/dashboard/organization/billing", icon: CreditCard },
label: "Developer",
items: [
{ name: "API Keys", href: "/dashboard/api-keys", description: "Access credentials for the RPC gateway" },
{ name: "Webhooks", href: "/dashboard/webhooks", description: "Event delivery endpoints" },
{ name: "Chains", href: "/chains", description: "Every supported chain and its endpoints" },
{ name: "Agents", href: "/agents", description: "Autonomous agents and their runs" },
{ name: "Docs", href: "/docs", description: "SDKs, quickstarts and API reference" },
],
},
{
label: "Organization",
items: [
{ name: "Organization", href: "/dashboard/organization", description: "Your organization at a glance" },
{ name: "Team", href: "/dashboard/organization/team", description: "Members, roles and invitations" },
{ name: "Org Settings", href: "/dashboard/organization/settings", description: "Organization-wide settings" },
{ name: "Billing", href: "/dashboard/organization/billing", description: "Plans, invoices, tax and compliance" },
{ name: "Preferences", href: "/dashboard/settings", description: "Your personal console preferences" },
{ name: "Admin", href: "/admin", description: "Platform administration", admin: true },
],
},
]
export const DASHBOARD_ADMIN_NAVIGATION: DashboardNavigationItem[] = [
{
name: "Admin",
href: "/admin",
icon: Shield,
description: "Platform administration",
},
]
/**
* Which single nav row is current for a given pathname.
*
* Two things this has to get right, both of which the obvious
* `pathname.startsWith(href)` per row gets wrong:
*
* 1. TRAILING SLASHES. The image serves clean URLs by materialising a
* directory index per page (see web/Dockerfile), so the browser's pathname
* is `/dashboard/`, not `/dashboard`. An equality test against the href
* never matched and the console rendered with nothing highlighted at all.
* 2. NESTING. `/dashboard/infrastructure/nodes` also starts with
* `/dashboard/infrastructure`, so a per-row predicate lights BOTH rows and
* the reader cannot tell where they are. Only the most specific match —
* the longest href — is current.
*
* Which makes this a selection over the whole nav, not a test per row.
*/
export function currentHref(pathname: string, hrefs: string[]): string | null {
const trim = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p)
const path = trim(pathname)
let best: string | null = null
for (const href of hrefs) {
const h = trim(href)
if (path === h || path.startsWith(h + "/")) {
if (best === null || h.length > trim(best).length) best = href
}
}
return best
}
/** Every href in the nav, in order. */
export function navHrefs(isAdmin = false): string[] {
return DASHBOARD_NAVIGATION.flatMap((g) => g.items.filter((i) => !i.admin || isAdmin).map((i) => i.href))
}
export interface DashboardCommandItem {
id: string
@@ -119,35 +144,18 @@ export interface DashboardCommandItem {
}
export function buildCommandItems(): DashboardCommandItem[] {
const navigationItems: DashboardCommandItem[] = []
DASHBOARD_NAVIGATION.forEach((item) => {
navigationItems.push({
const items: DashboardCommandItem[] = DASHBOARD_NAVIGATION.flatMap((group) =>
group.items.map((item) => ({
id: item.href,
title: item.name,
description: item.description || "",
title: group.label === item.name ? item.name : `${group.label} · ${item.name}`,
description: item.description ?? "",
href: item.href,
section: "Navigation",
keywords: [item.name.toLowerCase(), item.href],
})
section: "Navigation" as const,
keywords: [group.label.toLowerCase(), item.name.toLowerCase(), item.href],
}))
)
item.children?.forEach((child) => {
navigationItems.push({
id: child.href,
title: `${item.name} · ${child.name}`,
description: child.description || item.description || "",
href: child.href,
section: "Navigation",
keywords: [
item.name.toLowerCase(),
child.name.toLowerCase(),
child.href,
],
})
})
})
navigationItems.push(
items.push(
{
id: "deploy-node",
title: "Deploy New Node",
@@ -182,5 +190,5 @@ export function buildCommandItems(): DashboardCommandItem[] {
}
)
return navigationItems
return items
}
+8 -4
View File
@@ -16,11 +16,15 @@ export const docsConfig = {
// brand.apiUrl, not `api.${brand.domain}`: that derivation yields
// api.cloud.pars.network and api.cloud.zoo.network, neither of which
// exists. The catalog carries the real per-brand origin.
// Every URL is brand-derived. The NEXT_PUBLIC_* overrides that used to sit
// in front of these were build-time constants in an image that serves five
// hosts — setting one would have handed the same dashboard/ws/docs origin to
// every brand. The brand catalog is the only thing that can be per-host.
apiUrl: brand.apiUrl,
dashboardUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL || `https://${brand.domain}`,
wsUrl: process.env.NEXT_PUBLIC_WS_URL || `wss://ws.${brand.domain}`,
zapUrl: process.env.NEXT_PUBLIC_ZAP_URL || `zap://api.${brand.domain}:9999`,
docsUrl: process.env.NEXT_PUBLIC_DOCS_URL || `https://docs.${brand.domain}`,
dashboardUrl: `https://${brand.domain}`,
wsUrl: brand.wsUrl,
zapUrl: `zap://api.${brand.domain}:9999`,
docsUrl: `https://docs.${brand.domain}`,
statusUrl: brand.statusUrl,
// API key prefix (brand-specific)
+8 -3
View File
@@ -1,8 +1,8 @@
// Canonical IAM auth (HIP-0111) via @hanzo/iam SPA PKCE.
//
// One source of truth for the browser IAM client, white-labelled per brand:
// serverUrl + clientId come from the active brand catalog (lib/brand.ts), which
// is injected per-deploy. Env overrides win for one-off local testing.
// serverUrl + clientId come from the active brand (lib/brand.ts), resolved
// from window.location.hostname. There are no env overrides — see below.
//
// HIP-0111: authenticate ONLY through @hanzo/iam against the canonical OIDC
// endpoints. The SDK resolves /v1/iam/oauth/* via live discovery (with a
@@ -18,13 +18,18 @@ import { getBrand } from "@/lib/brand"
const RETURN_URL_KEY = "bootnode_return_url"
export function getIam(): IAM {
// The brand catalog is the ONE source for issuer + client. There is no env
// The brand is the ONE source for issuer + client, and it is resolved from
// the hostname of the page the user is standing on. There is no env
// override, by design: this deployment serves MANY white-label hosts, so a
// build-time NEXT_PUBLIC_IAM_URL is a single global value that every brand
// would share — it cannot express per-host white-labeling, and it silently
// outranked the correct per-brand default. That is exactly what shipped:
// bootno.de/dashboard bounced to hanzo.id and asked a Bootnode customer to
// "Sign in to Hanzo ID" under the Hanzo mark.
//
// `getBrand()` and not `useBrand()`: this runs imperatively at click time,
// when `window` exists, so it reads the true host rather than the prerender
// default — and it must stay callable outside React.
const { url: serverUrl, clientId } = getBrand().iam
return new IAM({
serverUrl,
+122
View File
@@ -0,0 +1,122 @@
// Every internal link in the app must point at a page that exists.
//
// Run: `pnpm test`.
//
// This is a whole-source check rather than a list someone maintains: the route
// set is derived by walking app/ for page.tsx / route.ts, and the link set by
// scanning every component and page for `href="/…"`. So it cannot go stale,
// and a page deleted or renamed fails the build step that runs it.
//
// It exists because two dead links were shipping and nothing noticed:
// • the header's "Products" pointed at /products, which is a DIRECTORY of
// product pages with no index — the static server answers it with the SPA
// fallback, i.e. the home page rendered under a /products URL.
// • the footer's "Tutorials" pointed at /docs/tutorials, a plain 404.
import { strict as assert } from "node:assert"
import { readdirSync, readFileSync, statSync } from "node:fs"
import { join, relative } from "node:path"
import { describe, it } from "node:test"
import { fileURLToPath } from "node:url"
const WEB = join(fileURLToPath(new URL(".", import.meta.url)), "..")
const APP = join(WEB, "app")
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
if (statSync(full).isDirectory()) walk(full, out)
else out.push(full)
}
return out
}
/** Routes the App Router actually emits, derived from the filesystem. */
function builtRoutes(): { staticRoutes: Set<string>; dynamicPrefixes: string[] } {
const staticRoutes = new Set<string>()
const dynamicPrefixes: string[] = []
for (const file of walk(APP)) {
const name = file.split("/").pop() ?? ""
if (name !== "page.tsx" && name !== "route.ts") continue
const segments = relative(APP, file).split("/").slice(0, -1)
// Route groups — (marketing) — are not URL segments.
const url = "/" + segments.filter((s) => !s.startsWith("(")).join("/")
if (segments.some((s) => s.startsWith("["))) {
dynamicPrefixes.push(url.slice(0, url.indexOf("/[")))
} else {
staticRoutes.add(url === "/" ? "/" : url.replace(/\/$/, ""))
}
}
return { staticRoutes, dynamicPrefixes }
}
/** Every internal href literal in the app's own source. */
function internalLinks(): { href: string; file: string }[] {
const found: { href: string; file: string }[] = []
for (const dir of [APP, join(WEB, "components"), join(WEB, "lib")]) {
for (const file of walk(dir)) {
if (!/\.tsx?$/.test(file) || file.endsWith(".test.ts")) continue
const src = readFileSync(file, "utf8")
for (const m of src.matchAll(/href=["'](\/[^"'{}#?]*)["']/g)) {
found.push({ href: m[1], file: relative(WEB, file) })
}
// The nav data carries hrefs as plain object fields, not JSX attributes.
for (const m of src.matchAll(/href:\s*["'](\/[^"'{}#?]*)["']/g)) {
found.push({ href: m[1], file: relative(WEB, file) })
}
}
}
return found
}
describe("internal links", () => {
const { staticRoutes, dynamicPrefixes } = builtRoutes()
it("derives a sane route set from the filesystem", () => {
// Sanity: if the walk broke, every other assertion here would pass vacuously.
assert.ok(staticRoutes.size > 50, `only found ${staticRoutes.size} routes`)
for (const expected of ["/", "/dashboard", "/pricing", "/login", "/products/node"]) {
assert.ok(staticRoutes.has(expected), `expected route ${expected} to exist`)
}
// /products is a directory of pages with NO page.tsx of its own.
assert.ok(!staticRoutes.has("/products"), "/products unexpectedly has a page")
})
const isDead = (href: string) => {
const path = href.length > 1 ? href.replace(/\/+$/, "") : href
if (staticRoutes.has(path)) return false
// A dynamic route: /chains/[slug] serves /chains/anything.
if (dynamicPrefixes.some((p) => p && path.startsWith(p + "/"))) return false
// Static files served straight out of public/.
if (/\.(svg|png|jpg|json|txt|ico|webp|mp4)$/.test(path)) return false
return true
}
// The chrome every page renders: header, footer, console nav. A dead link
// here is dead on all 67 pages at once, so this is held to zero.
const SHELL = ["components/navbar.tsx", "components/footer.tsx", "components/console-sidebar.tsx", "lib/dashboard-navigation.ts"]
it("has zero dead links in the shell", () => {
const dead = internalLinks()
.filter((l) => SHELL.includes(l.file) && isDead(l.href))
.map((l) => `${l.href} (${l.file})`)
assert.deepEqual([...new Set(dead)].sort(), [], `dead links in the shell:\n${dead.join("\n")}`)
})
it("does not add dead links to page bodies", () => {
// 36 dead links predate this work — the docs index and the product pages
// link to a documentation tree (/docs/node/*, /docs/wallets/*, /docs/sdk/*
// …) that was never built. Fixing them means either writing those pages or
// pointing them elsewhere: a content decision, not a shell one.
//
// So this is a RATCHET, not a clean-zero assertion. The debt is counted and
// may only go down. A test that fails on the day it is written gets
// deleted; one that stops the bleeding gets kept.
const KNOWN_DEAD = 36
const dead = [...new Set(internalLinks().filter((l) => !SHELL.includes(l.file) && isDead(l.href)).map((l) => l.href))]
assert.ok(
dead.length <= KNOWN_DEAD,
`dead internal links rose from ${KNOWN_DEAD} to ${dead.length}. New:\n${dead.join("\n")}`
)
})
})
+2 -2
View File
@@ -1,7 +1,7 @@
// Brand-aware SDK package + client class names.
// Used by docs samples and install snippets.
import { getBrandKey_ } from "@/lib/brand"
import { getBrandKey } from "@/lib/brand"
const PACKAGES: Record<string, { pkg: string; client: string }> = {
bootnode: { pkg: "@bootnode/sdk", client: "BootnodeClient" },
@@ -12,5 +12,5 @@ const PACKAGES: Record<string, { pkg: string; client: string }> = {
}
export function getSdkPackage(): { pkg: string; client: string } {
return PACKAGES[getBrandKey_()] ?? PACKAGES.bootnode
return PACKAGES[getBrandKey()] ?? PACKAGES.bootnode
}
+2 -1
View File
@@ -9,7 +9,8 @@
"build": "next build",
"start": "next start -p 3001",
"lint": "next lint",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "node --test --experimental-strip-types \"lib/**/*.test.ts\""
},
"dependencies": {
"@hanzo/iam": "^0.13.8",
-29
View File
@@ -1,29 +0,0 @@
{
"_comment": "REFERENCE multi-brand catalog (HIP-0111). The deploy (universe / CF-Pages) provisions config.json (and/or window.__BRAND_CATALOG__) from a catalog of this shape, host-keyed. iam.url is the canonical OIDC issuer (serverUrl); iam.clientId is the REGISTERED Casdoor app for that host (<org>-web3 / bootnode-platform / pars-app) whose redirectUris include https://<host>/auth/callback. These are PUBLIC values (no secrets) — the @hanzo/iam SPA PKCE flow is a public client. Do NOT invent client IDs: an unregistered clientId returns 'Invalid client_id' and IAM serves its 200 SPA catch-all, which is silent breakage. Brand identity (logos/colors/links) is elided here; only the iam + host wiring is shown as the source of truth.",
"defaultKey": "bootnode",
"hosts": [
{ "match": "web3.hanzo.ai", "key": "hanzo" },
{ "match": "cloud.lux.network", "key": "lux" },
{ "match": "web3.lux.network", "key": "lux" },
{ "match": "web3.zoo.ngo", "key": "zoo" },
{ "match": "cloud.pars.network", "key": "pars" },
{ "match": "bootno.de", "key": "bootnode" }
],
"brands": {
"hanzo": {
"iam": { "url": "https://iam.hanzo.ai", "clientId": "hanzo-web3", "domain": "iam.hanzo.ai" }
},
"lux": {
"iam": { "url": "https://lux.id", "clientId": "lux-web3", "domain": "lux.id" }
},
"zoo": {
"iam": { "url": "https://zoo.id", "clientId": "zoo-web3", "domain": "zoo.id" }
},
"pars": {
"iam": { "url": "https://pars.id", "clientId": "pars-app", "domain": "pars.id" }
},
"bootnode": {
"iam": { "url": "https://id.bootno.de", "clientId": "bootnode-platform", "domain": "id.bootno.de" }
}
}
}
-53
View File
@@ -1,53 +0,0 @@
{
"_comment": "Runtime brand catalog — the ONE white-label mechanism, shipped in the image. Hostname is matched against `hosts` (first substring match wins) to pick a brand key, and that entry is merged OVER the built-in bootnode default (lib/brand.ts getBrand/mergeBrand), so each entry lists only what differs. There is deliberately no NEXT_PUBLIC_IAM_URL/_CLIENT_ID: one image serves every host below, so a build-time value is a single global that all brands share and cannot express per-host white-labeling — that is precisely how every host came to point at hanzo.id. Every issuer + client_id + callback triple below was verified against the live /v1/iam/oauth/authorize (HIP-0111): a registered pair answers 302, an unregistered redirect_uri answers 400.",
"defaultKey": "bootnode",
"hosts": [
{ "match": "bootno.de", "key": "bootnode" },
{ "match": "web3.hanzo.ai", "key": "hanzo" },
{ "match": "cloud.hanzo.network", "key": "hanzo" },
{ "match": "cloud.pars.network", "key": "pars" },
{ "match": "cloud.zoo.network", "key": "zoo" }
],
"brands": {
"bootnode": {
"name": "Bootnode",
"domain": "bootno.de",
"iam": {
"url": "https://id.bootno.de",
"clientId": "bootnode-platform",
"domain": "id.bootno.de"
}
},
"hanzo": {
"_note": "apiUrl/wsUrl are set here, not as deployment env. The bootnode-web CR carried NEXT_PUBLIC_API_URL=https://api.web3.hanzo.ai, but the image is a static export served by the Go hanzoai/static binary — nothing reads container env, so the live bundle used the build-time default api.bootno.de and the CR's promise was never kept. Same class as the IAM env removed earlier. Both hosts serve the identical Bootnode API (verified: / and /v1/chains byte-identical, /v1/auth/me 401 on both), so this is a per-host repoint, not a new integration.",
"name": "Hanzo",
"domain": "web3.hanzo.ai",
"apiUrl": "https://api.web3.hanzo.ai",
"wsUrl": "wss://ws.web3.hanzo.ai",
"iam": {
"url": "https://iam.hanzo.ai",
"clientId": "hanzo-web3",
"domain": "iam.hanzo.ai"
}
},
"pars": {
"name": "Pars",
"domain": "cloud.pars.network",
"iam": {
"url": "https://pars.id",
"clientId": "pars-app",
"domain": "pars.id"
}
},
"zoo": {
"_note": "zoolabs.id, NOT zoo.id — zoo.id serves no OIDC discovery and no TLS. zoo-web3 IS a registered client on zoolabs.id (302; an unknown client answers 400), but cloud.zoo.network/auth/callback is not yet on its redirect list (400) and that host currently does not resolve. Register the callback in IAM before bringing the host up.",
"name": "Zoo",
"domain": "cloud.zoo.network",
"iam": {
"url": "https://zoolabs.id",
"clientId": "zoo-web3",
"domain": "zoolabs.id"
}
}
}
}