refactor(dashboard): one chrome, from @luxfi/ui

The dashboard had its own header, its own user menu (components/layout/user-menu.tsx)
and its own branding table (lib/branding.ts) that mapped a hostname to a brand a
second time — a copy of what @luxfi/ui/white-label already answers, and one that
drifted (it had no bootno.de row and pointed zoo at the wrong issuer).

Both are deleted. The nav is now `AppNav` from @luxfi/ui: brand, org switcher,
links, settings and the user menu, with the links collapsing into the same gui
Sheet the switchers use instead of the old `hidden md:flex` that simply vanished
on a phone with nothing to replace it.

Identity comes from IdentityProvider (IAM: get-account / get-organizations /
get-organization-projects). Credentials stay with @hanzo/iam — one PKCE flow and
one token store per surface; the chrome borrows only signIn and signOut.

`next build` passes: 60 routes.
This commit is contained in:
2026-07-27 14:42:56 -07:00
parent fd8eb49dc7
commit a648eaec80
26 changed files with 8559 additions and 429 deletions
+38 -48
View File
@@ -1,64 +1,54 @@
'use client'
import { handleCallback } from '@hanzo/iam/browser'
import { useWhiteLabel } from '@luxfi/ui'
import { useRouter } from 'next/navigation'
import { Suspense, useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { setTokens, setUserEmail } from '@/lib/auth'
import { getBranding } from '@/lib/branding'
import { markSession, setTokens, setUserEmail } from '@/lib/auth'
import { api } from '@/lib/api'
function CallbackInner() {
const router = useRouter()
const searchParams = useSearchParams()
const wl = useWhiteLabel()
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState('Completing sign in…')
useEffect(() => {
// Check for error in query params (OAuth error redirects)
const oauthError = searchParams.get('error')
const oauthErrorDesc = searchParams.get('error_description')
if (oauthError) {
setError(oauthErrorDesc ?? oauthError)
return
}
let live = true
void (async () => {
try {
// ONE exchange: state check + PKCE verifier + code→token, all inside
// the SDK. The IAM access token IS the session from here on, stored
// under the `hanzo_iam_*` keys the shared chrome reads.
const { token, redirect } = await handleCallback()
if (!live) return
// The middleware cannot see sessionStorage; raise its flag the moment
// the IAM login succeeds, so the gate follows the SESSION and not the
// downstream MPC exchange below.
markSession(true)
// Implicit flow: access_token may be in the hash fragment or query string
// (Hanzo IAM returns it in the query string, standard OAuth2 uses hash)
const hash = window.location.hash.substring(1)
const hashParams = new URLSearchParams(hash)
const accessToken = hashParams.get('access_token') ?? searchParams.get('access_token')
const state = hashParams.get('state') ?? searchParams.get('state')
if (!accessToken) {
setError('No access token received from identity provider')
return
}
// Validate state to prevent CSRF
const savedState = sessionStorage.getItem('oidc_state')
if (savedState && state !== savedState) {
setError('Invalid state parameter — possible CSRF attack')
return
}
sessionStorage.removeItem('oidc_state')
const branding = getBranding(window.location.hostname)
setStatus('Authenticating with MPC API…')
// Exchange the OIDC access token for a local MPC API JWT
api
.oidcExchange(accessToken, branding.iamUrl)
.then((mpcAuth) => {
setTokens(mpcAuth.access_token, mpcAuth.refresh_token)
if (mpcAuth.email) {
setUserEmail(mpcAuth.email)
// The MPC API keeps its OWN JWT, minted from the IAM token. That
// exchange is a DOWNSTREAM call: if the API is unreachable the user is
// still signed in and the chrome still works — MPC data is what fails,
// and it says so on the page that needs it. Failing the whole login for
// it used to strand a perfectly good session on this screen.
setStatus('Authenticating with the MPC API…')
try {
const mpc = await api.oidcExchange(token.accessToken, wl.issuer)
setTokens(mpc.access_token, mpc.refresh_token)
if (mpc.email) setUserEmail(mpc.email)
} catch {
// Signed in; MPC API unavailable.
}
router.replace('/dashboard')
})
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Authentication failed')
})
}, [searchParams, router])
if (live) router.replace(redirect || '/dashboard')
} catch (err: unknown) {
if (live) setError(err instanceof Error ? err.message : 'Authentication failed')
}
})()
return () => {
live = false
}
}, [router, wl.issuer])
if (error) {
return (
+2 -2
View File
@@ -39,8 +39,8 @@ export default function BridgeContractsPage() {
</p>
</div>
<div className="mb-6 rounded-md border border-amber-500/30 bg-amber-500/5 p-4">
<p className="text-sm text-amber-500">
<div className="mb-6 rounded-md border border-[var(--border)] bg-[var(--muted)] p-4">
<p className="text-sm text-[var(--color-status-warn)]">
The MPC bridge-signer wallet needs BRIDGE_ROLE granted on each token contract to mint bridged tokens.
Call <code className="rounded bg-muted px-1">grantBridge(address)</code> on each ERC20B contract with the MPC wallet address.
</p>
+1 -1
View File
@@ -193,7 +193,7 @@ export default function DashboardPage() {
<div className="space-y-3">
<div className="flex items-center justify-between rounded-md bg-muted/50 px-4 py-3">
<div className="flex items-center gap-3">
<div className={`h-2 w-2 rounded-full ${cluster.ready ? 'bg-emerald-400' : 'bg-yellow-400'}`} />
<div className={`h-2 w-2 rounded-full ${cluster.ready ? 'bg-emerald-400' : 'bg-[var(--color-status-warn)]'}`} />
<span className="text-sm font-medium font-mono">{cluster.node_id}</span>
</div>
<div className="flex items-center gap-4">
+1 -1
View File
@@ -24,7 +24,7 @@ const sections = [
{
title: 'API Reference',
description: 'Complete REST API documentation — authentication, wallets, transactions, bridge, and webhooks.',
color: 'border-amber-500/30',
color: 'border-[var(--border)]',
},
{
title: 'Security Model',
+20 -40
View File
@@ -1,48 +1,28 @@
/*
* The MPC dashboard rides the canonical Lux token set.
*
* @luxfi/ui/tokens.css supplies every --background / --foreground / --card /
* --muted / --border / --input / --ring / --primary / --secondary / --accent /
* --destructive value, in both modes, off one achromatic ladder.
*
* What it replaced: a hand-rolled shadcn block whose neutrals were not neutral —
* every rung carried hue 240 at up to 10% saturation (--background 240 10% 3.9%,
* --muted 240 3.7% 15.9%, --border 240 3.7% 15.9%, --ring 240 4.9% 83.9%). Next
* to explore and the bootnode console, which are both true achromatic, this read
* as a different product. The tokens are hex now, not bare HSL triples, so
* tailwind.config.ts consumes var(--x) directly instead of hsl(var(--x)).
*/
@import '@luxfi/ui/tokens.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
/* Radius is the one non-colour value this app owns. */
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
}
}
@layer base {
@@ -60,7 +40,7 @@
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted)) transparent;
scrollbar-color: var(--muted) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
@@ -73,12 +53,12 @@
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted));
background-color: var(--muted);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground));
background-color: var(--muted-foreground);
}
.scrollbar-none {
+8 -4
View File
@@ -2,15 +2,19 @@ import type { Metadata } from 'next'
import { headers } from 'next/headers'
import { GeistSans } from 'geist/font/sans'
import { GeistMono } from 'geist/font/mono'
import { getBranding } from '@/lib/branding'
import { resolveWhiteLabel } from '@luxfi/ui/white-label'
import { Providers } from './providers'
import './globals.css'
// Title from the Host header — the same resolution the client uses, so the tab
// and the chrome can never disagree about which brand this is.
export async function generateMetadata(): Promise<Metadata> {
const h = await headers()
const host = h.get('host') ?? 'mpc.lux.network'
const b = getBranding(host)
return { title: b.brand, description: b.description }
const wl = resolveWhiteLabel(h.get('host'))
return {
title: `${wl.name} MPC`,
description: `Multi-Party Computation wallet platform by ${wl.name}`,
}
}
export default function RootLayout({
+20 -32
View File
@@ -1,62 +1,50 @@
'use client'
import { useCallback, useState, useEffect } from 'react'
import { getBranding, type Branding } from '@/lib/branding'
const CALLBACK_PATH = '/auth/callback'
function buildLoginUrl(branding: Branding): string {
const redirectUri = `${window.location.origin}${CALLBACK_PATH}`
const state = crypto.randomUUID()
sessionStorage.setItem('oidc_state', state)
const params = new URLSearchParams({
response_type: 'token',
client_id: branding.iamClientId,
redirect_uri: redirectUri,
scope: 'openid profile email',
state,
})
return `${branding.iamUrl}/oauth/authorize?${params.toString()}`
}
import { startLogin } from '@hanzo/iam/browser'
import { useWhiteLabel } from '@luxfi/ui'
import { useCallback } from 'react'
// One button, one flow. The authorize URL, the PKCE challenge, the state nonce
// and the token exchange are all @hanzo/iam's — this page never reconstructs an
// IdP URL. (It used to: an implicit-grant `response_type=token` request built by
// hand against `${iamUrl}/oauth/authorize`, which is not even the issuer's
// authorize path — the real one is `/v1/iam/oauth/authorize`.)
//
// Brand, issuer and client id come from the host via `useWhiteLabel()`.
export default function LoginPage() {
const [branding, setBranding] = useState<Branding>(getBranding(''))
useEffect(() => { setBranding(getBranding(window.location.hostname)) }, [])
const handleLogin = useCallback(() => {
window.location.href = buildLoginUrl(branding)
}, [branding])
const wl = useWhiteLabel()
const signIn = useCallback(() => {
void startLogin({ redirect: '/dashboard' })
}, [])
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="rounded-lg border border-border bg-card p-8">
<h1 className="mb-2 text-center text-xl font-semibold tracking-tight">
{branding.brand}
{wl.name} MPC
</h1>
<p className="mb-8 text-center text-sm text-muted-foreground">
{branding.description}
Multi-Party Computation wallet platform by {wl.name}
</p>
<button
type="button"
onClick={handleLogin}
onClick={signIn}
className="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Continue with {branding.iamLabel}
Continue with {wl.iamDomain}
</button>
<p className="mt-4 text-center text-xs text-muted-foreground">
Powered by{' '}
<a
href={branding.iamUrl}
href={wl.issuer}
target="_blank"
rel="noreferrer"
className="underline underline-offset-4 hover:text-foreground/80"
>
{branding.iamLabel}
{wl.iamDomain}
</a>
</p>
</div>
+10 -31
View File
@@ -1,26 +1,8 @@
'use client'
import { useEffect, useState } from 'react'
import { startLogin } from '@hanzo/iam/browser'
import { useIdentity, useWhiteLabel } from '@luxfi/ui'
import Link from 'next/link'
import { getBranding, type Branding } from '@/lib/branding'
const CALLBACK_PATH = '/auth/callback'
function buildLoginUrl(branding: Branding): string {
const redirectUri = `${window.location.origin}${CALLBACK_PATH}`
const state = crypto.randomUUID()
sessionStorage.setItem('oidc_state', state)
const params = new URLSearchParams({
response_type: 'token',
client_id: branding.iamClientId,
redirect_uri: redirectUri,
scope: 'openid profile email',
state,
})
return `${branding.iamUrl}/oauth/authorize?${params.toString()}`
}
const features = [
{
@@ -46,20 +28,17 @@ const chains = [
]
export default function LandingPage() {
const [branding, setBranding] = useState<Branding>(getBranding(''))
const [hasSession, setHasSession] = useState(false)
useEffect(() => {
setBranding(getBranding(window.location.hostname))
setHasSession(document.cookie.includes('lux_mpc_session'))
}, [])
const wl = useWhiteLabel()
// "Am I signed in?" is IAM's answer, read through the shared identity — not a
// cookie sniff that only ever reflected the MPC API's own exchange.
const { status } = useIdentity()
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4 py-16">
<div className="w-full max-w-3xl text-center">
{/* Hero */}
<h1 className="bg-gradient-to-r from-violet-500 to-blue-500 bg-clip-text text-5xl font-bold tracking-tight text-transparent sm:text-6xl">
{branding.brand}
{wl.name} MPC
</h1>
<p className="mt-3 text-lg text-muted-foreground">
Threshold Signing Service &bull; 3-of-5 Consensus
@@ -67,7 +46,7 @@ export default function LandingPage() {
{/* CTA */}
<div className="mt-8 flex items-center justify-center gap-4">
{hasSession ? (
{status === 'ready' ? (
<Link
href="/dashboard"
className="rounded-lg bg-primary px-6 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
@@ -77,10 +56,10 @@ export default function LandingPage() {
) : (
<button
type="button"
onClick={() => { window.location.href = buildLoginUrl(branding) }}
onClick={() => void startLogin({ redirect: '/dashboard' })}
className="rounded-lg bg-primary px-6 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Sign in with {branding.iamLabel}
Sign in with {wl.iamDomain}
</button>
)}
<Link
+46 -16
View File
@@ -1,24 +1,54 @@
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState, type ReactNode } from 'react'
import { configureIam, logout, startLogin } from '@hanzo/iam/browser'
import { AppProvider, IdentityProvider } from '@luxfi/ui'
import { resolveWhiteLabel } from '@luxfi/ui/white-label'
import { useEffect, useState, type ReactNode } from 'react'
import { clearTokens } from '@/lib/auth'
// The root providers: brand + identity, both resolved from the HOST.
//
// `AppProvider` (@luxfi/ui) carries the white-label — brand, gui theme row,
// OIDC issuer and IAM client id all come from the Host header, so one image
// serves mpc.lux.network, mpc.zoo.network and mpc.pars.network with no
// per-deployment branding table. It also owns the QueryClient, so there is one
// query cache per surface instead of one per provider file.
//
// `IdentityProvider` reads the signed-in account and its org membership from
// IAM. Credentials stay with @hanzo/iam — one PKCE flow, one token store; the
// chrome borrows only the two verbs.
//
// NEXT_PUBLIC_HOST is the LOCAL-DEV seam and nothing else: `localhost` has no
// brand to resolve, so a developer names the host the surface stands in for.
// It is unset in every deployment, where the real Host header answers.
const DEV_HOST = process.env.NEXT_PUBLIC_HOST
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 1,
},
},
})
)
const [host, setHost] = useState<string | undefined>(undefined)
useEffect(() => {
const real = window.location.host
const local = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(real)
const resolved = local && DEV_HOST ? DEV_HOST : real
setHost(resolved)
const wl = resolveWhiteLabel(resolved)
configureIam({ issuer: wl.issuer, clientId: wl.clientId })
}, [])
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
<AppProvider host={host}>
<IdentityProvider
auth={{
signIn: () => void startLogin({ redirect: '/dashboard' }),
signOut: () => {
clearTokens()
void logout()
},
}}
>
{children}
</IdentityProvider>
</AppProvider>
)
}
+2 -2
View File
@@ -83,8 +83,8 @@ export default function ApiKeysPage() {
{/* Created key banner */}
{newKey && (
<div className="mb-6 rounded-lg border border-amber-500/20 bg-amber-500/5 p-4">
<p className="mb-2 text-sm font-medium text-amber-400">
<div className="mb-6 rounded-lg border border-[var(--border)] bg-[var(--muted)] p-4">
<p className="mb-2 text-sm font-medium text-[var(--color-status-warn)]">
API key created. Copy it now -- it will not be shown again.
</p>
<div className="flex items-center gap-2">
+1 -1
View File
@@ -6,7 +6,7 @@ import { api } from '@/lib/api'
import type { User } from '@/lib/types'
const roleBadgeColors: Record<string, string> = {
owner: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
owner: 'bg-[var(--muted)] text-[var(--color-status-warn)] border-[var(--border)]',
admin: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
signer: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
viewer: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
+3 -3
View File
@@ -120,9 +120,9 @@ export default function ResharePage() {
</section>
{/* Warning */}
<div className="rounded-lg border border-yellow-600/30 bg-yellow-600/10 p-4 text-sm">
<p className="font-medium text-yellow-500">Key Rotation Warning</p>
<p className="mt-1 text-yellow-500/80">
<div className="rounded-lg border border-[var(--border)] bg-[var(--muted)] p-4 text-sm">
<p className="font-medium text-[var(--color-status-warn)]">Key Rotation Warning</p>
<p className="mt-1 text-[var(--color-status-warn)]/80">
Resharing invalidates all existing key shares. All participants must be
online and reachable during the process. Ensure backups exist before
proceeding.
@@ -1,3 +1,6 @@
// Chain badges are third-party identity, and lux/brand DESIGN.md §1.2 reserves
// hue for exactly that ("state, market direction, and chain badges"). Bitcoin is
// orange because Bitcoin is orange — this is not Lux chrome and stays as-is.
const chainMeta: Record<string, { label: string; color: string }> = {
ethereum: { label: 'Ethereum', color: 'text-blue-400' },
bitcoin: { label: 'Bitcoin', color: 'text-amber-400' },
+30 -30
View File
@@ -1,31 +1,31 @@
const statusColors: Record<string, string> = {
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
running: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
signed: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
approved: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
enabled: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
settled: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
verified: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
finalized: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
co_signed: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
pending: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
pending_approval: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
pending_sign: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
matched: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
signing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
processing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
settling: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
hsm_signing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
confirming: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
broadcast: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
recorded: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
expired: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
failed: 'bg-red-500/10 text-red-400 border-red-500/20',
rejected: 'bg-red-500/10 text-red-400 border-red-500/20',
cancelled: 'bg-red-500/10 text-red-400 border-red-500/20',
disabled: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
paused: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
inactive: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
// Status is a TONE, not a hue. Lux permits exactly two hues — success and error
// — and carries everything else on the achromatic ladder, so `pending` no longer
// paints itself amber. Tones resolve through @luxfi/ui/tokens.css, which means a
// white-label host retints them without a rebuild.
const TONE_CLASS = {
good: 'bg-[var(--muted)] text-[var(--color-status-good)] border-[var(--border)]',
bad: 'bg-[var(--muted)] text-[var(--color-status-bad)] border-[var(--border)]',
// Waiting on someone. The loudest neutral — was amber-400 on amber-500/10.
pending: 'bg-[var(--muted)] text-[var(--color-status-warn)] border-[var(--border)]',
// Work in flight. Full-contrast neutral — was blue-400 on blue-500/10.
active: 'bg-[var(--secondary)] text-[var(--foreground)] border-[var(--border)]',
// Nothing is happening.
inert: 'bg-[var(--muted)] text-[var(--muted-foreground)] border-[var(--border)]',
} as const
type Tone = keyof typeof TONE_CLASS
const STATUS_TONE: Record<string, Tone> = {
active: 'good', running: 'good', signed: 'good', approved: 'good',
enabled: 'good', settled: 'good', verified: 'good', finalized: 'good',
co_signed: 'good',
pending: 'pending', pending_approval: 'pending', pending_sign: 'pending',
matched: 'pending',
signing: 'active', processing: 'active', settling: 'active',
hsm_signing: 'active', confirming: 'active', broadcast: 'active',
recorded: 'active',
failed: 'bad', rejected: 'bad', cancelled: 'bad',
expired: 'inert', disabled: 'inert', paused: 'inert', inactive: 'inert',
}
interface StatusBadgeProps {
@@ -34,10 +34,10 @@ interface StatusBadgeProps {
}
export function StatusBadge({ status, className = '' }: StatusBadgeProps) {
const colorClasses = statusColors[status.toLowerCase()] ?? 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'
const tone = STATUS_TONE[status.toLowerCase()] ?? 'inert'
return (
<span
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium capitalize ${colorClasses} ${className}`}
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium capitalize ${TONE_CLASS[tone]} ${className}`}
>
{status.replace(/_/g, ' ')}
</span>
+26 -46
View File
@@ -1,56 +1,36 @@
'use client'
import Link from 'next/link'
import { AppNav } from '@luxfi/ui'
import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react'
import { getBranding } from '@/lib/branding'
import { UserMenu } from './user-menu'
const navLinks = [
{ href: '/dashboard' as const, label: 'Dashboard' },
{ href: '/vaults' as const, label: 'Vaults' },
{ href: '/transactions' as const, label: 'Transactions' },
{ href: '/intents' as const, label: 'Intents' },
{ href: '/settlements' as const, label: 'Settlements' },
{ href: '/policies' as const, label: 'Policies' },
{ href: '/bridge' as const, label: 'Bridge' },
{ href: '/team' as const, label: 'Team' },
{ href: '/settings' as const, label: 'Settings' },
]
// The nav is the ONE nav — brand, org switcher, links, settings, user menu —
// from @luxfi/ui. It used to be a bespoke header with a bespoke user menu and
// `hidden md:flex` links that simply vanished on a phone with nothing to
// replace them; the shared chrome collapses them into the same gui Sheet the
// switchers use, so mobile is a real presentation rather than an omission.
const LINKS = [
{ href: '/dashboard', label: 'Dashboard' },
{ href: '/vaults', label: 'Vaults' },
{ href: '/transactions', label: 'Transactions' },
{ href: '/intents', label: 'Intents' },
{ href: '/settlements', label: 'Settlements' },
{ href: '/policies', label: 'Policies' },
{ href: '/bridge', label: 'Bridge' },
{ href: '/team', label: 'Team' },
] as const
export function Nav() {
const pathname = usePathname()
const [logoText, setLogoText] = useState('MPC')
useEffect(() => { setLogoText(getBranding(window.location.hostname).logoText) }, [])
return (
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
<div className="flex items-center gap-8">
<Link href="/dashboard" className="flex items-center gap-2">
<span className="text-lg font-semibold tracking-tight">{logoText}</span>
</Link>
<nav className="hidden items-center gap-1 md:flex">
{navLinks.map((link) => {
const isActive = pathname.startsWith(link.href)
return (
<Link
key={link.href}
href={link.href}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
{link.label}
</Link>
)
})}
</nav>
</div>
<UserMenu />
</div>
</header>
<AppNav
home="/dashboard"
links={LINKS.map((l) => ({ ...l, active: pathname.startsWith(l.href) }))}
settings={[
{ id: 'api-keys', label: 'API keys', href: '/settings/api-keys' },
{ id: 'webhooks', label: 'Webhooks', href: '/settings/webhooks' },
{ id: 'devices', label: 'Devices', href: '/settings/devices' },
]}
userItems={[{ id: 'audit', label: 'Audit log', href: '/audit' }]}
/>
)
}
-80
View File
@@ -1,80 +0,0 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import Link from 'next/link'
import { clearTokens, getUserEmail } from '@/lib/auth'
export function UserMenu() {
const [open, setOpen] = useState(false)
const [email, setEmail] = useState<string | null>(null)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
setEmail(getUserEmail())
}, [])
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleLogout = () => {
clearTokens()
window.location.href = '/login'
}
const initial = email ? email.charAt(0).toUpperCase() : 'U'
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
{initial}
</div>
<span className="hidden sm:inline">{email ?? 'Account'}</span>
<svg
className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute right-0 mt-1 w-48 rounded-md border border-border bg-card p-1 shadow-lg">
<Link
href="/settings"
onClick={() => setOpen(false)}
className="block rounded-sm px-3 py-2 text-sm text-card-foreground hover:bg-accent"
>
Profile
</Link>
<Link
href="/settings"
onClick={() => setOpen(false)}
className="block rounded-sm px-3 py-2 text-sm text-card-foreground hover:bg-accent"
>
Settings
</Link>
<hr className="my-1 border-border" />
<button
onClick={handleLogout}
className="block w-full rounded-sm px-3 py-2 text-left text-sm text-destructive hover:bg-accent"
>
Logout
</button>
</div>
)}
</div>
)
}
@@ -13,7 +13,7 @@ export interface PaymentHistoryProps {
const statusColors: Record<string, string> = {
confirmed: 'bg-green-600/20 text-green-500',
pending: 'bg-yellow-600/20 text-yellow-500',
pending: 'bg-[var(--muted)] text-[var(--color-status-warn)]',
failed: 'bg-red-600/20 text-red-400',
}
@@ -18,7 +18,7 @@ export interface SubscriptionTableProps {
const statusColors: Record<string, string> = {
active: 'bg-green-600/20 text-green-500',
paused: 'bg-yellow-600/20 text-yellow-500',
paused: 'bg-[var(--muted)] text-[var(--color-status-warn)]',
cancelled: 'bg-zinc-600/20 text-zinc-400',
}
@@ -24,7 +24,7 @@ export interface TxDetailProps {
}
const statusColors: Record<string, string> = {
pending: 'bg-yellow-600/20 text-yellow-500',
pending: 'bg-[var(--muted)] text-[var(--color-status-warn)]',
confirmed: 'bg-green-600/20 text-green-500',
failed: 'bg-red-600/20 text-red-400',
awaiting_approval: 'bg-blue-600/20 text-blue-400',
@@ -129,7 +129,7 @@ export function TxDetail({ transaction: tx }: TxDetailProps) {
className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-xs"
>
<span className="truncate font-mono">{a.address}</span>
<span className={a.approved ? 'text-green-500' : 'text-yellow-500'}>
<span className={a.approved ? 'text-green-500' : 'text-[var(--color-status-warn)]'}>
{a.approved ? 'Approved' : 'Pending'}
</span>
</div>
@@ -17,7 +17,7 @@ export interface TxTableProps {
}
const statusColors: Record<string, string> = {
pending: 'bg-yellow-600/20 text-yellow-500',
pending: 'bg-[var(--muted)] text-[var(--color-status-warn)]',
confirmed: 'bg-green-600/20 text-green-500',
failed: 'bg-red-600/20 text-red-400',
awaiting_approval: 'bg-blue-600/20 text-blue-400',
+31 -5
View File
@@ -1,9 +1,23 @@
// JWT token management via localStorage
// The MPC API's own JWT, and the server-visible session flag.
//
// TWO tokens, two audiences, said plainly:
// * the IAM access token — the SESSION. Owned by @hanzo/iam (PKCE, one
// refresh timer) under its own `hanzo_iam_*` keys, and read by the shared
// @luxfi/ui chrome. Nothing in this file touches it.
// * the MPC API JWT below — minted FROM the IAM token by `POST /auth/oidc`,
// and only good against the MPC API.
//
// `markSession` is the one bit Next middleware can see: sessionStorage is
// invisible to the server, so the login writes a flag cookie the matcher reads.
// It is set by the IAM login succeeding — NOT by the MPC exchange, which is a
// downstream call that may legitimately fail while the user is signed in.
const ACCESS_TOKEN_KEY = 'lux_mpc_access_token'
const REFRESH_TOKEN_KEY = 'lux_mpc_refresh_token'
const USER_EMAIL_KEY = 'lux_mpc_user_email'
export const SESSION_COOKIE = 'lux_mpc_session'
export function getToken(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(ACCESS_TOKEN_KEY)
@@ -18,8 +32,21 @@ export function setTokens(accessToken: string, refreshToken: string): void {
if (typeof window === 'undefined') return
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
// Set session cookie so Next.js middleware can enforce auth on server side
document.cookie = 'lux_mpc_session=1; path=/; SameSite=Strict; Secure'
}
/**
* Raise or clear the flag the middleware gates on.
*
* `Secure` is added only on https: an unconditional `Secure` meant the cookie
* was silently dropped on http://localhost, so every local sign-in bounced
* straight back to /login.
*/
export function markSession(on: boolean): void {
if (typeof window === 'undefined') return
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
document.cookie = on
? `${SESSION_COOKIE}=1; path=/; SameSite=Lax${secure}`
: `${SESSION_COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax${secure}`
}
export function setUserEmail(email: string): void {
@@ -37,8 +64,7 @@ export function clearTokens(): void {
localStorage.removeItem(ACCESS_TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(USER_EMAIL_KEY)
// Clear session cookie so middleware redirects to /login immediately
document.cookie = 'lux_mpc_session=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Strict'
markSession(false)
}
export function isAuthenticated(): boolean {
-51
View File
@@ -1,51 +0,0 @@
export interface Branding {
brand: string
logoText: string
description: string
iamUrl: string
iamClientId: string
iamLabel: string
}
export const brandingConfig: Record<string, Branding> = {
'mpc.lux.network': {
brand: 'Lux MPC',
logoText: 'Lux MPC',
description: 'Multi-Party Computation Wallet Platform by Lux Network',
iamUrl: 'https://lux.id',
iamClientId: 'lux-mpc',
iamLabel: 'Lux ID',
},
'mpc.pars.network': {
brand: 'Pars MPC',
logoText: 'Pars MPC',
description: 'Multi-Party Computation Wallet Platform by Pars Network',
iamUrl: 'https://pars.id',
iamClientId: 'pars-mpc',
iamLabel: 'Pars ID',
},
'mpc.zoo.network': {
brand: 'Zoo MPC',
logoText: 'Zoo MPC',
description: 'Multi-Party Computation Wallet Platform by Zoo Network',
iamUrl: 'https://id.zoo.network',
iamClientId: 'zoo-mpc',
iamLabel: 'Zoo ID',
},
'cloud.lux.network': {
brand: 'Lux Cloud',
logoText: 'Lux Cloud',
description: 'Cloud Wallet Management by Lux Network',
iamUrl: 'https://lux.id',
iamClientId: 'lux-mpc',
iamLabel: 'Lux ID',
},
}
const defaultBranding: Branding = brandingConfig['mpc.lux.network']
/** Pure function — works in both server and client contexts. */
export function getBranding(hostname: string): Branding {
const host = hostname.split(':')[0]
return brandingConfig[host] ?? defaultBranding
}
+8 -3
View File
@@ -1,3 +1,5 @@
const { withLuxUi } = require('@luxfi/ui/next')
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
@@ -14,11 +16,14 @@ const nextConfig = {
if (process.env.NODE_ENV === 'production') return []
return [
{
source: '/api/v1/:path*',
destination: 'http://localhost:8081/api/v1/:path*',
source: '/v1/:path*',
destination: 'http://localhost:8081/v1/:path*',
},
]
},
}
module.exports = nextConfig
// ONE incantation for the gui engine (transpile list, react-native-web alias,
// platform defines) — see @luxfi/ui/next. Hand-rolling it per surface is how
// two apps end up transpiling different halves of the same engine.
module.exports = withLuxUi(nextConfig)
+4
View File
@@ -9,11 +9,15 @@
"lint": "next lint"
},
"dependencies": {
"@hanzo/gui": "^7.3.0",
"@hanzo/iam": "^0.13.8",
"@luxfi/ui": "7.4.2",
"@tanstack/react-query": "^5.96.1",
"geist": "^1.7.0",
"next": "^15.5.14",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-native-web": "^0.21.0",
"zustand": "^5.0.12"
},
"devDependencies": {
+8283 -11
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -14,34 +14,34 @@ const config: Config = {
mono: ['var(--font-geist-mono)'],
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
background: 'var(--background)',
foreground: 'var(--foreground)',
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
DEFAULT: 'var(--muted)',
foreground: 'var(--muted-foreground)',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
DEFAULT: 'var(--card)',
foreground: 'var(--card-foreground)',
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
border: 'var(--border)',
input: 'var(--input)',
ring: 'var(--ring)',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
DEFAULT: 'var(--primary)',
foreground: 'var(--primary-foreground)',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
DEFAULT: 'var(--secondary)',
foreground: 'var(--secondary-foreground)',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
DEFAULT: 'var(--destructive)',
foreground: 'var(--destructive-foreground)',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
DEFAULT: 'var(--accent)',
foreground: 'var(--accent-foreground)',
},
},
borderRadius: {