mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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:
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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 • 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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+8283
-11
File diff suppressed because it is too large
Load Diff
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user