feat(console): add root-only sandbox node binding and complete panel breadcrumbs
This commit is contained in:
parent
ca90e45a8c
commit
64de5ff3d1
@ -8,6 +8,7 @@ import { Copy } from 'lucide-react'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import { useUserStore } from '@lib/userStore'
|
||||
import { getSandboxNodeBinding } from '../lib/sandboxNodeBinding'
|
||||
|
||||
import Card from './Card'
|
||||
import VlessQrCard from './VlessQrCard'
|
||||
@ -48,6 +49,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
|
||||
const refresh = useUserStore((state) => state.refresh)
|
||||
const logout = useUserStore((state) => state.logout)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [sandboxBoundNodeAddress, setSandboxBoundNodeAddress] = useState<string | null>(null)
|
||||
|
||||
const displayName = useMemo(() => resolveDisplayName(user), [user])
|
||||
const uuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? '—'
|
||||
@ -120,6 +122,15 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
|
||||
router.refresh()
|
||||
}, [logout, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGuestSandboxReadOnly) {
|
||||
setSandboxBoundNodeAddress(null)
|
||||
return
|
||||
}
|
||||
const binding = getSandboxNodeBinding()
|
||||
setSandboxBoundNodeAddress(binding?.address ?? null)
|
||||
}, [isGuestSandboxReadOnly])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGuestSandboxReadOnly || !user?.proxyUuidExpiresAt) {
|
||||
return
|
||||
@ -202,7 +213,12 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
|
||||
<p className="mt-3 text-xs text-[var(--color-text-subtle)]">{copy.cards.uuid.description}</p>
|
||||
</Card>
|
||||
|
||||
<VlessQrCard uuid={vlessUuid} copy={copy.cards.vless} allowSandboxFallbackNode={isGuestSandboxReadOnly} />
|
||||
<VlessQrCard
|
||||
uuid={vlessUuid}
|
||||
copy={copy.cards.vless}
|
||||
allowSandboxFallbackNode={isGuestSandboxReadOnly}
|
||||
boundNodeAddress={sandboxBoundNodeAddress}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">{copy.cards.username.label}</p>
|
||||
|
||||
@ -55,6 +55,7 @@ interface VlessQrCardProps {
|
||||
uuid: string | null | undefined
|
||||
copy: VlessQrCopy
|
||||
allowSandboxFallbackNode?: boolean
|
||||
boundNodeAddress?: string | null
|
||||
}
|
||||
|
||||
function buildSandboxFallbackNode(): VlessNode {
|
||||
@ -76,7 +77,12 @@ function buildSandboxFallbackNode(): VlessNode {
|
||||
}
|
||||
}
|
||||
|
||||
export default function VlessQrCard({ uuid, copy, allowSandboxFallbackNode = false }: VlessQrCardProps) {
|
||||
export default function VlessQrCard({
|
||||
uuid,
|
||||
copy,
|
||||
allowSandboxFallbackNode = false,
|
||||
boundNodeAddress,
|
||||
}: VlessQrCardProps) {
|
||||
const { data: nodes, error: nodesError } = useSWR<VlessNode[]>('/api/agent-server/v1/nodes', fetcher)
|
||||
const [selectedNode, setSelectedNode] = useState<VlessNode | null>(null)
|
||||
const [preferredTransport, setPreferredTransport] = useState<VlessTransport>('tcp')
|
||||
@ -89,10 +95,16 @@ export default function VlessQrCard({ uuid, copy, allowSandboxFallbackNode = fal
|
||||
|
||||
const rawNode = useMemo(() => {
|
||||
if (selectedNode) return selectedNode
|
||||
if (boundNodeAddress && nodes?.length) {
|
||||
const matched = nodes.find((node) => node.address === boundNodeAddress)
|
||||
if (matched) {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
if (nodes && nodes[0]) return nodes[0]
|
||||
if (allowSandboxFallbackNode && uuid) return buildSandboxFallbackNode()
|
||||
return undefined
|
||||
}, [allowSandboxFallbackNode, nodes, selectedNode, uuid])
|
||||
}, [allowSandboxFallbackNode, boundNodeAddress, nodes, selectedNode, uuid])
|
||||
|
||||
const effectiveNode = useMemo((): VlessNode | undefined => {
|
||||
if (!rawNode) return undefined
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
const SANDBOX_NODE_BINDING_KEY = 'xcontrol.sandbox.node.binding.v1'
|
||||
|
||||
export type SandboxNodeBinding = {
|
||||
address: string
|
||||
name?: string
|
||||
updatedAt: number
|
||||
updatedBy?: string
|
||||
}
|
||||
|
||||
export function getSandboxNodeBinding(): SandboxNodeBinding | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(SANDBOX_NODE_BINDING_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as SandboxNodeBinding
|
||||
if (typeof parsed?.address === 'string' && parsed.address.trim().length > 0) {
|
||||
return {
|
||||
address: parsed.address.trim(),
|
||||
name: typeof parsed.name === 'string' && parsed.name.trim().length > 0 ? parsed.name.trim() : undefined,
|
||||
updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(),
|
||||
updatedBy: typeof parsed.updatedBy === 'string' && parsed.updatedBy.trim().length > 0 ? parsed.updatedBy.trim() : undefined,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse sandbox node binding', error)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function setSandboxNodeBinding(binding: { address: string; name?: string; updatedBy?: string }) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const payload: SandboxNodeBinding = {
|
||||
address: binding.address.trim(),
|
||||
name: binding.name?.trim() || undefined,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: binding.updatedBy?.trim() || undefined,
|
||||
}
|
||||
|
||||
window.localStorage.setItem(SANDBOX_NODE_BINDING_KEY, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
export function clearSandboxNodeBinding() {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
window.localStorage.removeItem(SANDBOX_NODE_BINDING_KEY)
|
||||
}
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
|
||||
import Card from '../../components/Card'
|
||||
import type { VlessNode } from '../../lib/vless'
|
||||
import {
|
||||
clearSandboxNodeBinding,
|
||||
getSandboxNodeBinding,
|
||||
setSandboxNodeBinding,
|
||||
} from '../../lib/sandboxNodeBinding'
|
||||
|
||||
async function fetcher(url: string): Promise<VlessNode[]> {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(payload && typeof payload.message === 'string' && payload.message) ||
|
||||
(payload && typeof payload.error === 'string' && payload.error) ||
|
||||
`Request failed (${response.status})`
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload as VlessNode[]
|
||||
}
|
||||
if (payload && Array.isArray((payload as { nodes?: unknown }).nodes)) {
|
||||
return (payload as { nodes: VlessNode[] }).nodes
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export default function SandboxNodeBindingPanel() {
|
||||
const { data: nodes, error, isLoading } = useSWR<VlessNode[]>('/api/agent-server/v1/nodes', fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
})
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const current = getSandboxNodeBinding()
|
||||
const selectedAddress = current?.address ?? ''
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => nodes?.find((node) => node.address === selectedAddress) ?? null,
|
||||
[nodes, selectedAddress],
|
||||
)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Root 管理员专用:Sandbox Node 绑定节点</h2>
|
||||
<p className="text-sm text-gray-600">仅允许绑定 1 个节点,Sandbox@svc.plus 会使用该节点生成 VLESS 配置。</p>
|
||||
|
||||
<label className="flex flex-col gap-2 text-sm text-gray-700">
|
||||
绑定节点
|
||||
<select
|
||||
value={selectedAddress}
|
||||
disabled={isLoading || !nodes || nodes.length === 0}
|
||||
onChange={(event) => {
|
||||
const address = event.target.value.trim()
|
||||
if (!address) {
|
||||
clearSandboxNodeBinding()
|
||||
setMessage('已清空绑定节点')
|
||||
return
|
||||
}
|
||||
const node = nodes?.find((item) => item.address === address)
|
||||
if (!node) {
|
||||
setMessage('节点不存在,无法绑定')
|
||||
return
|
||||
}
|
||||
setSandboxNodeBinding({
|
||||
address: node.address,
|
||||
name: node.name,
|
||||
updatedBy: 'root',
|
||||
})
|
||||
setMessage(`已绑定:${node.name || node.address}`)
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200"
|
||||
>
|
||||
<option value="">不绑定(清空)</option>
|
||||
{(nodes ?? []).map((node) => (
|
||||
<option key={node.address} value={node.address}>
|
||||
{node.name} ({node.address})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{selectedNode ? (
|
||||
<p className="text-xs text-gray-600">
|
||||
当前绑定:<span className="font-medium text-gray-800">{selectedNode.name || selectedNode.address}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{current?.updatedAt ? (
|
||||
<p className="text-xs text-gray-500">最后更新:{new Date(current.updatedAt).toLocaleString()}</p>
|
||||
) : null}
|
||||
{error ? <p className="text-xs text-red-600">节点加载失败:{error.message}</p> : null}
|
||||
{message ? <p className="text-xs text-green-700">{message}</p> : null}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
|
||||
import MfaSetupPanel from '../account/MfaSetupPanel'
|
||||
import SubscriptionPanel from '../account/SubscriptionPanel'
|
||||
import UserOverview from '../components/UserOverview'
|
||||
@ -11,6 +12,12 @@ export default function UserCenterAccountRoute() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/panel' },
|
||||
{ label: 'Account', href: '/panel/account' },
|
||||
]}
|
||||
/>
|
||||
<UserOverview hideMfaMainPrompt />
|
||||
{!isReadOnlyRole ? <MfaSetupPanel showSummary={false} /> : null}
|
||||
{!isReadOnlyRole ? <SubscriptionPanel /> : null}
|
||||
|
||||
@ -1,10 +1,19 @@
|
||||
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
|
||||
import Card from '../components/Card'
|
||||
|
||||
export default function UserCenterApiRoute() {
|
||||
return (
|
||||
<Card>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">API Status</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">View backend API health and toggle feature matrices.</p>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/panel' },
|
||||
{ label: 'APIs', href: '/panel/api' },
|
||||
]}
|
||||
/>
|
||||
<Card>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">API Status</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">View backend API health and toggle feature matrices.</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,15 @@
|
||||
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
|
||||
import UserOverview from '../components/UserOverview'
|
||||
|
||||
export default function UserCenterHomeRoute() {
|
||||
return <UserOverview />
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/panel' },
|
||||
]}
|
||||
/>
|
||||
<UserOverview />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
|
||||
import Card from '../components/Card'
|
||||
|
||||
export default function UserCenterLdpRoute() {
|
||||
@ -12,22 +13,30 @@ export default function UserCenterLdpRoute() {
|
||||
]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">LDP Management</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">Explore low-latency directory plane modules.</p>
|
||||
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
{links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="group flex items-center justify-between rounded-xl border border-gray-200 px-4 py-3 text-sm font-medium text-gray-700 transition hover:border-purple-400 hover:text-purple-600"
|
||||
>
|
||||
{link.label}
|
||||
<span className="text-xs text-gray-400 transition group-hover:text-purple-400">Coming soon</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/panel' },
|
||||
{ label: 'LDP', href: '/panel/ldp' },
|
||||
]}
|
||||
/>
|
||||
<Card>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">LDP Management</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">Explore low-latency directory plane modules.</p>
|
||||
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
{links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="group flex items-center justify-between rounded-xl border border-gray-200 px-4 py-3 text-sm font-medium text-gray-700 transition hover:border-purple-400 hover:text-purple-600"
|
||||
>
|
||||
{link.label}
|
||||
<span className="text-xs text-gray-400 transition group-hover:text-purple-400">Coming soon</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import UserGroupManagement, {
|
||||
type ManagedUser,
|
||||
type CreateManagedUserInput,
|
||||
} from '../management/components/UserGroupManagement'
|
||||
import SandboxNodeBindingPanel from '../management/components/SandboxNodeBindingPanel'
|
||||
import { EmailBlacklist } from '../management/components/EmailBlacklist'
|
||||
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
|
||||
import { resolveAccess } from '@lib/accessControl'
|
||||
@ -364,6 +365,7 @@ export default function UserCenterManagementRoute() {
|
||||
onCreateCustomUser={handleCreateCustomUser}
|
||||
onManageBlacklist={() => setIsBlacklistOpen(true)}
|
||||
/>
|
||||
{canCreateCustomUser ? <SandboxNodeBindingPanel /> : null}
|
||||
<EmailBlacklist isOpen={isBlacklistOpen} onClose={() => setIsBlacklistOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
|
||||
import Card from '../components/Card'
|
||||
import BillingOptionsPanel from '../account/BillingOptionsPanel'
|
||||
import SubscriptionPanel from '../account/SubscriptionPanel'
|
||||
@ -12,6 +13,12 @@ export default function UserCenterSubscriptionRoute() {
|
||||
if (isReadOnlyRole) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/panel' },
|
||||
{ label: 'Subscription', href: '/panel/subscription' },
|
||||
]}
|
||||
/>
|
||||
<Card>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">支付与订阅</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
@ -24,6 +31,12 @@ export default function UserCenterSubscriptionRoute() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/panel' },
|
||||
{ label: 'Subscription', href: '/panel/subscription' },
|
||||
]}
|
||||
/>
|
||||
<Card>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">支付与订阅</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
|
||||
import ThemePreferenceCard from '../account/ThemePreferenceCard'
|
||||
|
||||
export default function UserCenterThemeRoute() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Dashboard', href: '/panel' },
|
||||
{ label: 'Appearance', href: '/panel/appearance' },
|
||||
]}
|
||||
/>
|
||||
<ThemePreferenceCard />
|
||||
</div>
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user