Defer heavy getServerSideProps on home, operator, addresses, and wallet to cut TTFB; centralize locale-safe formatters with client-only relative times; add compact ops UX, bridge/operator relay pagination, and Playwright route/scroll smoke in deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
881 lines
42 KiB
TypeScript
881 lines
42 KiB
TypeScript
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { Card } from '@/libs/frontend-ui-primitives'
|
|
import PageIntro from '@/components/common/PageIntro'
|
|
import SectionTabs, { TabPanel, type SectionTab } from '@/components/common/SectionTabs'
|
|
import DisclosureSection from '@/components/common/DisclosureSection'
|
|
import EntityBadge from '@/components/common/EntityBadge'
|
|
import {
|
|
accessApi,
|
|
type AccessAPIKeyRecord,
|
|
type AccessAuditEntry,
|
|
type AccessProduct,
|
|
type AccessSubscription,
|
|
type AccessUsageSummary,
|
|
type AccessUser,
|
|
type WalletAccessSession,
|
|
} from '@/services/api/access'
|
|
import { formatDate, formatInteger, formatTimestamp } from '@/utils/format'
|
|
|
|
const ACCESS_SCOPE_OPTIONS = ['rpc:read', 'rpc:write', 'rpc:admin'] as const
|
|
const OPERATOR_IDENTITIES = [
|
|
{
|
|
slug: 'thirdweb-rpc',
|
|
label: 'ThirdWeb',
|
|
vmid: 2103,
|
|
address: '0xB2dEA0e264ddfFf91057A3415112e57A1a5Eac14',
|
|
},
|
|
{
|
|
slug: 'alltra-rpc',
|
|
label: 'Alltra/HYBX',
|
|
vmid: 2102,
|
|
address: '0xaf6e3444AEaf7855cf41b557C94A96dc7fcF49C1',
|
|
},
|
|
{
|
|
slug: 'core-rpc',
|
|
label: 'DBIS',
|
|
vmid: 2101,
|
|
address: '0x4A666F96fC8764181194447A7dFdb7d471b301C8',
|
|
},
|
|
] as const
|
|
|
|
function Field({
|
|
label,
|
|
value,
|
|
onChange,
|
|
type = 'text',
|
|
}: {
|
|
label: string
|
|
value: string
|
|
onChange: (value: string) => void
|
|
type?: string
|
|
}) {
|
|
return (
|
|
<label className="block">
|
|
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</span>
|
|
<input
|
|
type={type}
|
|
value={value}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200 dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
|
/>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
export default function AccessManagementPage() {
|
|
const [products, setProducts] = useState<AccessProduct[]>([])
|
|
const [user, setUser] = useState<AccessUser | null>(null)
|
|
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
|
|
const [connectingWallet, setConnectingWallet] = useState(false)
|
|
const [apiKeys, setApiKeys] = useState<AccessAPIKeyRecord[]>([])
|
|
const [subscriptions, setSubscriptions] = useState<AccessSubscription[]>([])
|
|
const [usage, setUsage] = useState<AccessUsageSummary[]>([])
|
|
const [auditEntries, setAuditEntries] = useState<AccessAuditEntry[]>([])
|
|
const [adminSubscriptions, setAdminSubscriptions] = useState<AccessSubscription[]>([])
|
|
const [adminAuditEntries, setAdminAuditEntries] = useState<AccessAuditEntry[]>([])
|
|
const [auditLimit, setAuditLimit] = useState('20')
|
|
const [adminAuditLimit, setAdminAuditLimit] = useState('50')
|
|
const [adminSubscriptionStatus, setAdminSubscriptionStatus] = useState('pending')
|
|
const [adminAuditProduct, setAdminAuditProduct] = useState('')
|
|
const [adminActionNotes, setAdminActionNotes] = useState<Record<string, string>>({})
|
|
const [email, setEmail] = useState('')
|
|
const [username, setUsername] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [apiKeyName, setAPIKeyName] = useState('Core RPC key')
|
|
const [apiKeyTier, setAPIKeyTier] = useState('pro')
|
|
const [apiKeyProduct, setAPIKeyProduct] = useState('thirdweb-rpc')
|
|
const [apiKeyExpiresDays, setAPIKeyExpiresDays] = useState('30')
|
|
const [apiKeyMonthlyQuota, setAPIKeyMonthlyQuota] = useState('')
|
|
const [apiKeyScopes, setAPIKeyScopes] = useState<string[]>(['rpc:read', 'rpc:write'])
|
|
const [createdKey, setCreatedKey] = useState('')
|
|
const [message, setMessage] = useState('')
|
|
const [error, setError] = useState('')
|
|
const [accessTab, setAccessTab] = useState<'products' | 'account' | 'admin'>('products')
|
|
|
|
const accessTabs = useMemo((): SectionTab<'products' | 'account' | 'admin'>[] => {
|
|
const tabs: SectionTab<'products' | 'account' | 'admin'>[] = [
|
|
{ id: 'products', label: 'Products', count: products.length },
|
|
{ id: 'account', label: 'Account & keys' },
|
|
]
|
|
if (user?.is_admin) {
|
|
tabs.push({ id: 'admin', label: 'Admin' })
|
|
}
|
|
return tabs
|
|
}, [products.length, user?.is_admin])
|
|
|
|
const clearSessionState = useCallback(() => {
|
|
setUser(null)
|
|
setApiKeys([])
|
|
setSubscriptions([])
|
|
setUsage([])
|
|
setAuditEntries([])
|
|
setAdminSubscriptions([])
|
|
setAdminAuditEntries([])
|
|
}, [])
|
|
|
|
const syncWalletSession = useCallback(() => {
|
|
setWalletSession(accessApi.getStoredWalletSession())
|
|
}, [])
|
|
|
|
const loadAdminData = useCallback(async (
|
|
isAdmin: boolean,
|
|
nextSubscriptionStatus = adminSubscriptionStatus,
|
|
nextAuditProduct = adminAuditProduct,
|
|
nextAuditLimit = Number(adminAuditLimit),
|
|
) => {
|
|
if (!isAdmin) {
|
|
setAdminSubscriptions([])
|
|
setAdminAuditEntries([])
|
|
return
|
|
}
|
|
const [adminResponse, adminAuditResponse] = await Promise.all([
|
|
accessApi.listAdminSubscriptions(nextSubscriptionStatus).catch(() => ({ subscriptions: [] })),
|
|
accessApi.listAdminAudit(nextAuditLimit, nextAuditProduct).catch(() => ({ entries: [] })),
|
|
])
|
|
setAdminSubscriptions(adminResponse.subscriptions || [])
|
|
setAdminAuditEntries(adminAuditResponse.entries || [])
|
|
}, [adminAuditLimit, adminAuditProduct, adminSubscriptionStatus])
|
|
|
|
const loadSignedInData = useCallback(async () => {
|
|
const [me, keys, usageResponse, auditResponse] = await Promise.all([
|
|
accessApi.getMe(),
|
|
accessApi.listAPIKeys(),
|
|
accessApi.getUsage().catch(() => ({ usage: [] })),
|
|
accessApi.listAudit(Number(auditLimit)).catch(() => ({ entries: [] })),
|
|
])
|
|
setUser(me.user)
|
|
setSubscriptions(me.subscriptions || [])
|
|
setApiKeys(keys.api_keys || [])
|
|
setUsage(usageResponse.usage || [])
|
|
setAuditEntries(auditResponse.entries || [])
|
|
await loadAdminData(Boolean(me.user?.is_admin))
|
|
}, [auditLimit, loadAdminData])
|
|
|
|
const loadAccessData = useCallback(async () => {
|
|
const productResponse = await accessApi.listProducts()
|
|
setProducts(productResponse.products || [])
|
|
syncWalletSession()
|
|
|
|
const token = accessApi.getStoredAccessToken()
|
|
if (!token) {
|
|
clearSessionState()
|
|
return
|
|
}
|
|
|
|
try {
|
|
await loadSignedInData()
|
|
} catch {
|
|
accessApi.clearSession()
|
|
clearSessionState()
|
|
}
|
|
}, [clearSessionState, loadSignedInData, syncWalletSession])
|
|
|
|
useEffect(() => {
|
|
void loadAccessData()
|
|
}, [loadAccessData])
|
|
|
|
useEffect(() => {
|
|
syncWalletSession()
|
|
window.addEventListener('storage', syncWalletSession)
|
|
window.addEventListener('explorer-access-session-changed', syncWalletSession)
|
|
return () => {
|
|
window.removeEventListener('storage', syncWalletSession)
|
|
window.removeEventListener('explorer-access-session-changed', syncWalletSession)
|
|
}
|
|
}, [syncWalletSession])
|
|
|
|
useEffect(() => {
|
|
if (!user) return
|
|
void accessApi
|
|
.listAudit(Number(auditLimit))
|
|
.then((response) => setAuditEntries(response.entries || []))
|
|
.catch(() => {})
|
|
}, [auditLimit, user])
|
|
|
|
useEffect(() => {
|
|
if (!user?.is_admin) return
|
|
void loadAdminData(true)
|
|
}, [adminSubscriptionStatus, adminAuditLimit, adminAuditProduct, loadAdminData, user?.is_admin])
|
|
|
|
useEffect(() => {
|
|
if (apiKeyProduct === 'core-rpc') {
|
|
setAPIKeyScopes((current) =>
|
|
current.includes('rpc:admin') ? current : [...current, 'rpc:admin'],
|
|
)
|
|
} else {
|
|
setAPIKeyScopes((current) => current.filter((scope) => scope !== 'rpc:admin'))
|
|
}
|
|
}, [apiKeyProduct])
|
|
|
|
const handleRegister = async (event: FormEvent) => {
|
|
event.preventDefault()
|
|
setError('')
|
|
setMessage('')
|
|
try {
|
|
const response = await accessApi.register(email, username, password)
|
|
setUser(response.user)
|
|
setMessage('Account created. You can now issue API keys for managed RPC access.')
|
|
await loadSignedInData()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Registration failed')
|
|
}
|
|
}
|
|
|
|
const handleLogin = async (event: FormEvent) => {
|
|
event.preventDefault()
|
|
setError('')
|
|
setMessage('')
|
|
try {
|
|
const response = await accessApi.login(email, password)
|
|
setUser(response.user)
|
|
await loadSignedInData()
|
|
setMessage('Signed in successfully.')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Login failed')
|
|
}
|
|
}
|
|
|
|
const handleCreateAPIKey = async (event: FormEvent) => {
|
|
event.preventDefault()
|
|
setError('')
|
|
setMessage('')
|
|
setCreatedKey('')
|
|
try {
|
|
const response = await accessApi.createAPIKey({
|
|
name: apiKeyName,
|
|
tier: apiKeyTier,
|
|
productSlug: apiKeyProduct,
|
|
expiresDays: apiKeyExpiresDays === 'never' ? 0 : Number(apiKeyExpiresDays || 0),
|
|
monthlyQuota: apiKeyMonthlyQuota.trim() ? Number(apiKeyMonthlyQuota) : undefined,
|
|
scopes: apiKeyScopes,
|
|
})
|
|
setCreatedKey(response.api_key)
|
|
setMessage('API key created. This is the only time the plaintext key will be shown.')
|
|
await loadSignedInData()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create API key')
|
|
}
|
|
}
|
|
|
|
const handleRotate = async (key: AccessAPIKeyRecord) => {
|
|
setError('')
|
|
setMessage('')
|
|
setCreatedKey('')
|
|
try {
|
|
const response = await accessApi.createAPIKey({
|
|
name: key.name.replace(/\s+\[[^\]]+\]$/, ''),
|
|
tier: key.tier,
|
|
productSlug: key.productSlug,
|
|
monthlyQuota: key.monthlyQuota,
|
|
scopes: key.scopes,
|
|
})
|
|
await accessApi.revokeAPIKey(key.id)
|
|
setCreatedKey(response.api_key)
|
|
setMessage('API key rotated. The old key has been revoked and the new plaintext key is shown below once.')
|
|
await loadSignedInData()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to rotate API key')
|
|
}
|
|
}
|
|
|
|
const handleRevoke = async (id: string) => {
|
|
setError('')
|
|
setMessage('')
|
|
try {
|
|
await accessApi.revokeAPIKey(id)
|
|
setMessage('API key revoked.')
|
|
await loadSignedInData()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to revoke API key')
|
|
}
|
|
}
|
|
|
|
const handleSignOut = () => {
|
|
accessApi.clearSession()
|
|
clearSessionState()
|
|
setCreatedKey('')
|
|
setMessage('Signed out.')
|
|
}
|
|
|
|
const handleWalletConnect = async () => {
|
|
setError('')
|
|
setMessage('')
|
|
try {
|
|
setConnectingWallet(true)
|
|
const session = await accessApi.connectWalletSession()
|
|
setWalletSession(session)
|
|
await loadSignedInData()
|
|
setMessage('Wallet connected. Account sign-in is active and authenticated explorer access is now available.')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Wallet connection failed')
|
|
} finally {
|
|
setConnectingWallet(false)
|
|
}
|
|
}
|
|
|
|
const handleWalletDisconnect = () => {
|
|
accessApi.clearWalletSession()
|
|
syncWalletSession()
|
|
clearSessionState()
|
|
setCreatedKey('')
|
|
setMessage('Wallet session disconnected.')
|
|
}
|
|
|
|
const handleRequestSubscription = async (productSlug: string, tier: string) => {
|
|
setError('')
|
|
setMessage('')
|
|
try {
|
|
await accessApi.requestSubscription(productSlug, tier)
|
|
await loadSignedInData()
|
|
setMessage('Access request saved.')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to request access')
|
|
}
|
|
}
|
|
|
|
const handleAdminSubscriptionAction = async (subscriptionId: string, status: string) => {
|
|
setError('')
|
|
setMessage('')
|
|
try {
|
|
await accessApi.updateAdminSubscription(subscriptionId, status, adminActionNotes[subscriptionId] || '')
|
|
await loadSignedInData()
|
|
setAdminActionNotes((current) => ({ ...current, [subscriptionId]: '' }))
|
|
setMessage(`Subscription ${status === 'active' ? 'approved' : status}.`)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to update subscription')
|
|
}
|
|
}
|
|
|
|
const getSubscriptionForProduct = (productSlug: string) =>
|
|
subscriptions.find((subscription) => subscription.productSlug === productSlug)
|
|
|
|
const handleScopeToggle = (scope: string) => {
|
|
setAPIKeyScopes((current) =>
|
|
current.includes(scope) ? current.filter((entry) => entry !== scope) : [...current, scope],
|
|
)
|
|
}
|
|
|
|
const handleAdminAuditProductChange = async (value: string) => {
|
|
setAdminAuditProduct(value)
|
|
}
|
|
|
|
const getOperatorIdentity = (productSlug: string) =>
|
|
OPERATOR_IDENTITIES.find((entry) => entry.slug === productSlug)
|
|
|
|
return (
|
|
<main className="container mx-auto px-4 py-4 sm:py-6">
|
|
<PageIntro
|
|
compact
|
|
eyebrow="Access Control"
|
|
title="Wallet Login, RPC Access & API Tokens"
|
|
description="Wallet sign-in, API keys, and subscription-gated RPC products."
|
|
actions={[
|
|
{ href: '/wallet', label: 'Wallet' },
|
|
{ href: '/system', label: 'System' },
|
|
]}
|
|
/>
|
|
|
|
{message ? (
|
|
<Card className="mb-6 border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
|
<p className="text-sm text-emerald-900 dark:text-emerald-100">{message}</p>
|
|
</Card>
|
|
) : null}
|
|
{error ? (
|
|
<Card className="mb-6 border border-red-200 bg-red-50/70 dark:border-red-900/50 dark:bg-red-950/20">
|
|
<p className="text-sm text-red-900 dark:text-red-100">{error}</p>
|
|
</Card>
|
|
) : null}
|
|
|
|
<SectionTabs
|
|
idPrefix="access"
|
|
ariaLabel="Access console sections"
|
|
tabs={accessTabs}
|
|
activeTab={accessTab}
|
|
onChange={setAccessTab}
|
|
className="mb-4"
|
|
/>
|
|
|
|
<TabPanel idPrefix="access" tabId="products" activeTab={accessTab}>
|
|
<div className="mb-4 grid gap-4 lg:grid-cols-3">
|
|
{products.map((product) => (
|
|
<Card key={product.slug} title={product.name}>
|
|
<div className="space-y-3 text-sm text-gray-700 dark:text-gray-300">
|
|
<div className="flex flex-wrap gap-2">
|
|
<EntityBadge label={product.provider} tone="info" />
|
|
<EntityBadge label={`vmid ${product.vmid}`} />
|
|
<EntityBadge label={product.default_tier} tone="success" />
|
|
<EntityBadge label={product.billing_model} tone="warning" />
|
|
{product.requires_approval ? <EntityBadge label="approval required" tone="warning" /> : <EntityBadge label="self-service" tone="success" />}
|
|
</div>
|
|
<p>{product.description}</p>
|
|
<div>
|
|
<div className="font-semibold text-gray-900 dark:text-white">HTTP</div>
|
|
<code className="break-all text-xs">{product.http_url}</code>
|
|
</div>
|
|
{product.ws_url ? (
|
|
<div>
|
|
<div className="font-semibold text-gray-900 dark:text-white">WS</div>
|
|
<code className="break-all text-xs">{product.ws_url}</code>
|
|
</div>
|
|
) : null}
|
|
<div>
|
|
<div className="font-semibold text-gray-900 dark:text-white">Use cases</div>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{product.use_cases.map((item) => (
|
|
<EntityBadge key={item} label={item} className="normal-case tracking-normal" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
{getOperatorIdentity(product.slug) ? (
|
|
<div>
|
|
<div className="font-semibold text-gray-900 dark:text-white">Primary operator / deployer</div>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
<EntityBadge label={getOperatorIdentity(product.slug)?.label || product.provider} tone="info" />
|
|
<EntityBadge label={`vmid ${getOperatorIdentity(product.slug)?.vmid || product.vmid}`} />
|
|
</div>
|
|
<code className="mt-2 block break-all text-xs">{getOperatorIdentity(product.slug)?.address}</code>
|
|
</div>
|
|
) : null}
|
|
{user ? (
|
|
<div className="border-t border-gray-200 pt-3 dark:border-gray-700">
|
|
{(() => {
|
|
const subscription = getSubscriptionForProduct(product.slug)
|
|
if (subscription) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap gap-2">
|
|
<EntityBadge label={subscription.status} tone={subscription.status === 'active' ? 'success' : 'warning'} />
|
|
<EntityBadge label={subscription.tier} />
|
|
<EntityBadge label={`${subscription.requestsUsed}/${subscription.monthlyQuota || 0}`} tone="info" />
|
|
</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
{subscription.notes || 'Subscription record present.'}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleRequestSubscription(product.slug, product.default_tier)}
|
|
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
|
>
|
|
{product.requires_approval ? 'Request access' : 'Activate access'}
|
|
</button>
|
|
)
|
|
})()}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</TabPanel>
|
|
|
|
<TabPanel idPrefix="access" tabId="account" activeTab={accessTab}>
|
|
<div className="grid gap-4 lg:grid-cols-[1fr_1.2fr]">
|
|
<div className="space-y-4">
|
|
<DisclosureSection title="Wallet authentication" defaultOpen={Boolean(walletSession)} alwaysCollapsible>
|
|
<Card>
|
|
<div className="space-y-4 text-sm text-gray-700 dark:text-gray-300">
|
|
<p>
|
|
Use a connected wallet for standard account sign-in, then access subscriptions, API keys, and managed RPC controls with the same authenticated session.
|
|
</p>
|
|
{walletSession ? (
|
|
<div className="space-y-3 rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="flex flex-wrap gap-2">
|
|
<EntityBadge label="wallet sign-in active" tone="success" />
|
|
<EntityBadge label={walletSession.track} tone="info" />
|
|
{walletSession.permissions.map((permission) => (
|
|
<EntityBadge key={permission} label={permission} className="normal-case tracking-normal" />
|
|
))}
|
|
</div>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
Your wallet address remains private within the access console. This session is treated as account sign-in, not a public identifier.
|
|
</p>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
Session expires {formatTimestamp(walletSession.expiresAt)}
|
|
</div>
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={handleWalletDisconnect}
|
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
|
|
>
|
|
Disconnect wallet
|
|
</button>
|
|
<Link href="/wallet" className="rounded-lg border border-primary-300 px-4 py-2 text-sm font-medium text-primary-700 hover:bg-primary-50 dark:border-primary-800 dark:text-primary-300 dark:hover:bg-primary-950/20">
|
|
Open wallet tools
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-2xl border border-dashed border-primary-300 bg-primary-50/60 p-4 dark:border-primary-700/50 dark:bg-primary-950/20">
|
|
<div className="mb-3 text-sm text-primary-900 dark:text-primary-100">
|
|
No wallet session is active. Connect a browser wallet to sign in to your account and unlock the access-management plane.
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleWalletConnect()}
|
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
|
>
|
|
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</DisclosureSection>
|
|
|
|
<DisclosureSection title="Operator identities" defaultOpen={false} alwaysCollapsible>
|
|
<Card>
|
|
<div className="space-y-3">
|
|
{OPERATOR_IDENTITIES.map((identity) => (
|
|
<div key={identity.slug} className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="flex flex-wrap gap-2">
|
|
<EntityBadge label={identity.label} tone="info" />
|
|
<EntityBadge label={identity.slug} />
|
|
<EntityBadge label={`vmid ${identity.vmid}`} tone="warning" />
|
|
</div>
|
|
<code className="mt-2 block break-all text-xs text-gray-700 dark:text-gray-300">{identity.address}</code>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
</DisclosureSection>
|
|
|
|
<Card title={user ? `Signed in as ${user.username}` : 'Create or Access Account'}>
|
|
{user ? (
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
{user.email}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={handleSignOut}
|
|
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
|
|
>
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
<form onSubmit={handleRegister} className="space-y-3">
|
|
<Field label="Email" value={email} onChange={setEmail} type="email" />
|
|
<Field label="Username" value={username} onChange={setUsername} />
|
|
<Field label="Password" value={password} onChange={setPassword} type="password" />
|
|
<button type="submit" className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700">
|
|
Register
|
|
</button>
|
|
</form>
|
|
<form onSubmit={handleLogin} className="space-y-3 border-t border-gray-200 pt-4 dark:border-gray-700">
|
|
<Field label="Email" value={email} onChange={setEmail} type="email" />
|
|
<Field label="Password" value={password} onChange={setPassword} type="password" />
|
|
<button type="submit" className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white">
|
|
Sign in
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{user ? (
|
|
<Card title="Create API Key">
|
|
<form onSubmit={handleCreateAPIKey} className="space-y-3">
|
|
<Field label="Key name" value={apiKeyName} onChange={setAPIKeyName} />
|
|
<label className="block">
|
|
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tier</span>
|
|
<select value={apiKeyTier} onChange={(event) => setAPIKeyTier(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
|
<option value="free">free</option>
|
|
<option value="pro">pro</option>
|
|
<option value="enterprise">enterprise</option>
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Product</span>
|
|
<select value={apiKeyProduct} onChange={(event) => setAPIKeyProduct(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
|
{products.map((product) => (
|
|
<option key={product.slug} value={product.slug}>{product.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Expiry</span>
|
|
<select value={apiKeyExpiresDays} onChange={(event) => setAPIKeyExpiresDays(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
|
<option value="7">7 days</option>
|
|
<option value="30">30 days</option>
|
|
<option value="90">90 days</option>
|
|
<option value="365">365 days</option>
|
|
<option value="never">No expiry</option>
|
|
</select>
|
|
</label>
|
|
<Field label="Monthly quota override (optional)" value={apiKeyMonthlyQuota} onChange={setAPIKeyMonthlyQuota} />
|
|
<div>
|
|
<span className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">Scopes</span>
|
|
<div className="flex flex-wrap gap-2">
|
|
{ACCESS_SCOPE_OPTIONS.map((scope) => (
|
|
<label key={scope} className="inline-flex items-center gap-2 rounded-full border border-gray-300 px-3 py-2 text-sm dark:border-gray-700">
|
|
<input
|
|
type="checkbox"
|
|
checked={apiKeyScopes.includes(scope)}
|
|
onChange={() => handleScopeToggle(scope)}
|
|
/>
|
|
<span>{scope}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<button type="submit" className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700">
|
|
Issue key
|
|
</button>
|
|
</form>
|
|
{createdKey ? (
|
|
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-950/20">
|
|
<div className="mb-2 text-sm font-semibold text-amber-900 dark:text-amber-100">Plaintext API key</div>
|
|
<code className="block break-all text-xs text-amber-900 dark:text-amber-100">{createdKey}</code>
|
|
</div>
|
|
) : null}
|
|
</Card>
|
|
) : null}
|
|
</div>
|
|
|
|
<Card title="Issued API Keys">
|
|
{user ? (
|
|
apiKeys.length > 0 ? (
|
|
<div className="space-y-4">
|
|
{apiKeys.map((key) => (
|
|
<div key={key.id} className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<div className="font-semibold text-gray-900 dark:text-white">{key.name}</div>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
<EntityBadge label={key.tier} tone="success" />
|
|
{key.productSlug ? <EntityBadge label={key.productSlug} tone="info" /> : null}
|
|
<EntityBadge label={`${key.rateLimitPerSecond}/s`} tone="info" />
|
|
<EntityBadge label={`${key.rateLimitPerMinute}/min`} />
|
|
<EntityBadge label={`${key.requestsUsed}/${key.monthlyQuota || 0}`} />
|
|
{key.approved ? <EntityBadge label="approved" tone="success" /> : <EntityBadge label="pending" tone="warning" />}
|
|
{key.revoked ? <EntityBadge label="revoked" tone="warning" /> : <EntityBadge label="active" tone="success" />}
|
|
{key.expiresAt ? <EntityBadge label={`expires ${formatDate(key.expiresAt)}`} /> : <EntityBadge label="no expiry" />}
|
|
</div>
|
|
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
|
Created {formatTimestamp(key.createdAt)}
|
|
{key.lastUsedAt ? ` · Last used ${formatTimestamp(key.lastUsedAt)}` : ' · Not used yet'}
|
|
</div>
|
|
</div>
|
|
{!key.revoked ? (
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleRotate(key)}
|
|
className="rounded-lg border border-primary-300 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-50 dark:border-primary-800 dark:text-primary-300 dark:hover:bg-primary-950/20"
|
|
>
|
|
Rotate
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleRevoke(key.id)}
|
|
className="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-950/20"
|
|
>
|
|
Revoke
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{key.scopes.length > 0 ? (
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{key.scopes.map((scope) => (
|
|
<EntityBadge key={`${key.id}-${scope}`} label={scope} className="normal-case tracking-normal" />
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">No API keys issued yet.</p>
|
|
)
|
|
) : (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
Sign in to issue and manage RPC access keys for Core, Thirdweb, and Alltra products.
|
|
</p>
|
|
)}
|
|
<div className="mt-6 border-t border-gray-200 pt-4 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400">
|
|
Billing, quotas, and paywalls can be layered onto this access plane next. The current slice establishes identity, product discovery, and key lifecycle management.
|
|
</div>
|
|
{user && usage.length > 0 ? (
|
|
<div className="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
|
|
<div className="mb-3 text-sm font-semibold text-gray-900 dark:text-white">Usage Summary</div>
|
|
<div className="space-y-3">
|
|
{usage.map((item) => (
|
|
<div key={item.product_slug} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<EntityBadge label={item.product_slug} tone="info" />
|
|
<EntityBadge label={`${item.active_keys} active keys`} />
|
|
</div>
|
|
<div className="mt-2 text-gray-600 dark:text-gray-400">
|
|
{formatInteger(item.requests_used)} requests used / {formatInteger(item.monthly_quota)} monthly quota
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{user ? (
|
|
<div className="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
|
|
<div className="mb-3 flex flex-wrap items-end justify-between gap-3">
|
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">Recent API Activity</div>
|
|
<label className="block min-w-[10rem]">
|
|
<span className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300">Entries shown</span>
|
|
<select
|
|
value={auditLimit}
|
|
onChange={(event) => setAuditLimit(event.target.value)}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
|
>
|
|
<option value="10">10</option>
|
|
<option value="20">20</option>
|
|
<option value="50">50</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
{auditEntries.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{auditEntries.map((entry) => (
|
|
<div key={`audit-${entry.id}`} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<EntityBadge label={entry.productSlug || 'unscoped'} tone="info" />
|
|
<EntityBadge label={entry.methodName || 'unknown method'} />
|
|
<EntityBadge label={`${entry.requestCount} req`} />
|
|
</div>
|
|
<div className="mt-2 font-medium text-gray-900 dark:text-white">{entry.keyName || entry.apiKeyId}</div>
|
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{formatTimestamp(entry.createdAt)}
|
|
{entry.lastIp ? ` · ${entry.lastIp}` : ''}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">No API usage has been logged yet for this account.</p>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
|
<Link href="/wallet" className="text-primary-600 hover:underline">Wallet →</Link>
|
|
<Link href="/system" className="text-primary-600 hover:underline">System →</Link>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</TabPanel>
|
|
|
|
{user?.is_admin ? (
|
|
<TabPanel idPrefix="access" tabId="admin" activeTab={accessTab}>
|
|
<div className="space-y-4">
|
|
<Card title="Pending access review">
|
|
<div className="mb-4 flex flex-wrap items-end gap-3">
|
|
<label className="block min-w-[12rem]">
|
|
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Subscription status</span>
|
|
<select
|
|
value={adminSubscriptionStatus}
|
|
onChange={(event) => setAdminSubscriptionStatus(event.target.value)}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
|
>
|
|
<option value="pending">Pending</option>
|
|
<option value="active">Active</option>
|
|
<option value="suspended">Suspended</option>
|
|
<option value="revoked">Revoked</option>
|
|
<option value="">All statuses</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
{adminSubscriptions.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{adminSubscriptions.map((subscription) => (
|
|
<div key={subscription.id} className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<div className="font-semibold text-gray-900 dark:text-white">{subscription.productSlug}</div>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
<EntityBadge label={subscription.status} tone={subscription.status === 'active' ? 'success' : 'warning'} />
|
|
<EntityBadge label={subscription.tier} />
|
|
<EntityBadge label={`${formatInteger(subscription.monthlyQuota)} quota`} tone="info" />
|
|
{subscription.requiresApproval ? <EntityBadge label="restricted product" tone="warning" /> : null}
|
|
</div>
|
|
<label className="mt-3 block">
|
|
<span className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">Admin note</span>
|
|
<input
|
|
type="text"
|
|
value={adminActionNotes[subscription.id] || ''}
|
|
onChange={(event) =>
|
|
setAdminActionNotes((current) => ({
|
|
...current,
|
|
[subscription.id]: event.target.value,
|
|
}))
|
|
}
|
|
placeholder="Reason, approval scope, or operator note"
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button type="button" onClick={() => void handleAdminSubscriptionAction(subscription.id, 'active')} className="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700">Approve</button>
|
|
<button type="button" onClick={() => void handleAdminSubscriptionAction(subscription.id, 'suspended')} className="rounded-lg border border-amber-300 px-3 py-2 text-sm font-medium text-amber-800 hover:bg-amber-50 dark:border-amber-800 dark:text-amber-300">Suspend</button>
|
|
<button type="button" onClick={() => void handleAdminSubscriptionAction(subscription.id, 'revoked')} className="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-300">Revoke</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">No subscriptions match the current review filter.</p>
|
|
)}
|
|
</Card>
|
|
<Card title="Platform audit feed">
|
|
<div className="mb-4 grid gap-3 sm:grid-cols-2">
|
|
<label className="block">
|
|
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Filter by product</span>
|
|
<select value={adminAuditProduct} onChange={(event) => void handleAdminAuditProductChange(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
|
<option value="">All products</option>
|
|
{products.map((product) => (
|
|
<option key={`audit-${product.slug}`} value={product.slug}>{product.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Entries shown</span>
|
|
<select value={adminAuditLimit} onChange={(event) => setAdminAuditLimit(event.target.value)} className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
|
<option value="20">20</option>
|
|
<option value="50">50</option>
|
|
<option value="100">100</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
{adminAuditEntries.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{adminAuditEntries.map((entry) => (
|
|
<div key={`admin-audit-${entry.id}`} className="rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-900/40">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<EntityBadge label={entry.productSlug || 'unscoped'} tone="info" />
|
|
<EntityBadge label={entry.methodName || 'unknown method'} />
|
|
<EntityBadge label={`${entry.requestCount} req`} />
|
|
</div>
|
|
<div className="mt-2 font-medium text-gray-900 dark:text-white">{entry.keyName || entry.apiKeyId}</div>
|
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{formatTimestamp(entry.createdAt)}
|
|
{entry.lastIp ? ` · ${entry.lastIp}` : ''}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">No recent validated RPC traffic matches the current filter.</p>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</TabPanel>
|
|
) : null}
|
|
</main>
|
|
)
|
|
}
|