fix: read sandbox binding from backend + harden proxies
This commit is contained in:
parent
8f4f4b47bb
commit
b346e9430a
@ -42,18 +42,31 @@ export async function POST(request: NextRequest) {
|
||||
const contentType = request.headers.get('content-type') ?? 'application/json'
|
||||
headers.set('Content-Type', contentType)
|
||||
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/bind`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
cache: 'no-store',
|
||||
})
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/bind`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (payload === null) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
const text = await response.text().catch(() => '')
|
||||
return NextResponse.json(
|
||||
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (payload === null) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Failed to proxy sandbox bind', error)
|
||||
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
}
|
||||
|
||||
|
||||
@ -33,20 +33,33 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/binding`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/binding`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (payload === null) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
const text = await response.text().catch(() => '')
|
||||
return NextResponse.json(
|
||||
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (payload === null) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Failed to proxy sandbox binding', error)
|
||||
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
|
||||
}
|
||||
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
}
|
||||
|
||||
|
||||
82
src/app/api/sandbox/assume/revert/route.ts
Normal file
82
src/app/api/sandbox/assume/revert/route.ts
Normal file
@ -0,0 +1,82 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { applySessionCookie } from '@lib/authGateway'
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
const ROOT_BACKUP_COOKIE = 'xc_session_root'
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
}
|
||||
|
||||
function secureCookies(): boolean {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return true
|
||||
}
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || ''
|
||||
return baseUrl.toLowerCase().startsWith('https://')
|
||||
}
|
||||
|
||||
async function verifyRootToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${ACCOUNT_API_BASE}/session`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!res.ok) {
|
||||
return false
|
||||
}
|
||||
const payload = (await res.json().catch(() => null)) as any
|
||||
const email = typeof payload?.user?.email === 'string' ? payload.user.email.trim().toLowerCase() : ''
|
||||
return email === 'admin@svc.plus'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const rootToken = request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim() ?? ''
|
||||
if (!rootToken) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'not_assuming' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!(await verifyRootToken(rootToken))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'root_token_invalid' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Best-effort audit log on accounts.svc.plus. (Cookies are owned by console.)
|
||||
try {
|
||||
await fetch(`${ACCOUNT_API_BASE}/admin/assume/revert`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${rootToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to audit assume revert', error)
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true })
|
||||
applySessionCookie(response, rootToken)
|
||||
response.cookies.set({
|
||||
name: ROOT_BACKUP_COOKIE,
|
||||
value: '',
|
||||
httpOnly: true,
|
||||
secure: secureCookies(),
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
96
src/app/api/sandbox/assume/route.ts
Normal file
96
src/app/api/sandbox/assume/route.ts
Normal file
@ -0,0 +1,96 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { applySessionCookie, deriveMaxAgeFromExpires } from '@lib/authGateway'
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession, userHasRole } from '@server/account/session'
|
||||
import type { AccountUserRole } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
|
||||
|
||||
const ROOT_BACKUP_COOKIE = 'xc_session_root'
|
||||
const SANDBOX_EMAIL = 'sandbox@svc.plus'
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
}
|
||||
|
||||
function isAllowedRootEmail(email?: string): boolean {
|
||||
return email?.trim().toLowerCase() === 'admin@svc.plus'
|
||||
}
|
||||
|
||||
function secureCookies(): boolean {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return true
|
||||
}
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || ''
|
||||
return baseUrl.toLowerCase().startsWith('https://')
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getAccountSession(request)
|
||||
const user = session.user
|
||||
|
||||
if (!user || !session.token) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!(await userHasRole(user, REQUIRED_ROLES))) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!isAllowedRootEmail(user.email)) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${ACCOUNT_API_BASE}/admin/assume`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email: SANDBOX_EMAIL }),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const contentType = upstream.headers.get('content-type') ?? ''
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
const text = await upstream.text().catch(() => '')
|
||||
return NextResponse.json(
|
||||
{ error: 'upstream_non_json', upstreamStatus: upstream.status, upstreamBody: text.slice(0, 2048) } as any,
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const payload = (await upstream.json().catch(() => null)) as any
|
||||
if (!payload || typeof payload.token !== 'string') {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true, assumed: SANDBOX_EMAIL })
|
||||
|
||||
// Backup current root session token as a host-only cookie (do NOT set domain).
|
||||
response.cookies.set({
|
||||
name: ROOT_BACKUP_COOKIE,
|
||||
value: session.token,
|
||||
httpOnly: true,
|
||||
secure: secureCookies(),
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: deriveMaxAgeFromExpires(payload.expiresAt),
|
||||
})
|
||||
|
||||
// Switch main session to sandbox token.
|
||||
applySessionCookie(response, payload.token, deriveMaxAgeFromExpires(payload.expiresAt))
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to assume sandbox', error)
|
||||
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
11
src/app/api/sandbox/assume/status/route.ts
Normal file
11
src/app/api/sandbox/assume/status/route.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const ROOT_BACKUP_COOKIE = 'xc_session_root'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const isAssuming = Boolean(request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim())
|
||||
return NextResponse.json({ isAssuming, target: isAssuming ? 'sandbox@svc.plus' : '' })
|
||||
}
|
||||
|
||||
49
src/app/api/sandbox/binding/route.ts
Normal file
49
src/app/api/sandbox/binding/route.ts
Normal file
@ -0,0 +1,49 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
|
||||
import { getAccountSession } from '@server/account/session'
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||
|
||||
type ErrorPayload = {
|
||||
error: string
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getAccountSession(request)
|
||||
if (!session.token) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ACCOUNT_API_BASE}/sandbox/binding`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
const text = await response.text().catch(() => '')
|
||||
return NextResponse.json(
|
||||
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null)
|
||||
if (payload === null) {
|
||||
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
|
||||
}
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Failed to proxy sandbox binding (public)', error)
|
||||
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +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 { fetchSandboxNodeBinding } from '../lib/sandboxNodeBinding'
|
||||
|
||||
import Card from './Card'
|
||||
import VlessQrCard from './VlessQrCard'
|
||||
@ -127,8 +127,17 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
|
||||
setSandboxBoundNodeAddress(null)
|
||||
return
|
||||
}
|
||||
const binding = getSandboxNodeBinding()
|
||||
setSandboxBoundNodeAddress(binding?.address ?? null)
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const binding = await fetchSandboxNodeBinding()
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setSandboxBoundNodeAddress(binding?.address ?? null)
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isGuestSandboxReadOnly])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
'use client'
|
||||
|
||||
const SANDBOX_NODE_BINDING_KEY = 'xcontrol.sandbox.node.binding.v1'
|
||||
|
||||
export type SandboxNodeBinding = {
|
||||
address: string
|
||||
name?: string
|
||||
@ -9,52 +7,29 @@ export type SandboxNodeBinding = {
|
||||
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
|
||||
}
|
||||
|
||||
export async function fetchSandboxNodeBinding(): Promise<SandboxNodeBinding | 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,
|
||||
}
|
||||
const response = await fetch('/api/sandbox/binding', { method: 'GET', cache: 'no-store' })
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
const payload = (await response.json().catch(() => null)) as any
|
||||
if (!payload || typeof payload.address !== 'string') {
|
||||
return null
|
||||
}
|
||||
const address = payload.address.trim()
|
||||
if (!address) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
address,
|
||||
name: typeof payload.name === 'string' && payload.name.trim().length > 0 ? payload.name.trim() : undefined,
|
||||
updatedAt: typeof payload.updatedAt === 'number' ? payload.updatedAt : Date.now(),
|
||||
updatedBy:
|
||||
typeof payload.updatedBy === 'string' && payload.updatedBy.trim().length > 0 ? payload.updatedBy.trim() : undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse sandbox node binding', error)
|
||||
console.warn('Failed to fetch sandbox node binding', error)
|
||||
return null
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@ -5,11 +5,6 @@ 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, {
|
||||
@ -44,8 +39,8 @@ export default function SandboxNodeBindingPanel() {
|
||||
})
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const currentBinding = useMemo(() => getSandboxNodeBinding(), [])
|
||||
const [draftAddress, setDraftAddress] = useState<string>(currentBinding?.address ?? '')
|
||||
const [activeBinding, setActiveBinding] = useState<{ address: string; updatedAt?: number } | null>(null)
|
||||
const [draftAddress, setDraftAddress] = useState<string>('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@ -55,23 +50,18 @@ export default function SandboxNodeBindingPanel() {
|
||||
.then(data => {
|
||||
if (data && typeof data.address === 'string') {
|
||||
setDraftAddress(data.address)
|
||||
if (data.address) {
|
||||
setSandboxNodeBinding({
|
||||
address: data.address,
|
||||
updatedBy: 'server'
|
||||
})
|
||||
} else {
|
||||
clearSandboxNodeBinding()
|
||||
}
|
||||
setActiveBinding({
|
||||
address: data.address,
|
||||
updatedAt: typeof data.updatedAt === 'number' ? data.updatedAt : undefined,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('Failed to fetch binding from server', err))
|
||||
}, [])
|
||||
|
||||
const isChanged = useMemo(() => {
|
||||
const current = getSandboxNodeBinding()
|
||||
return (current?.address ?? '') !== draftAddress
|
||||
}, [draftAddress])
|
||||
return (activeBinding?.address ?? '') !== draftAddress
|
||||
}, [activeBinding?.address, draftAddress])
|
||||
|
||||
const handleApply = async (rawAddress: string) => {
|
||||
const address = rawAddress.trim()
|
||||
@ -92,15 +82,11 @@ export default function SandboxNodeBindingPanel() {
|
||||
}
|
||||
|
||||
if (!address) {
|
||||
clearSandboxNodeBinding()
|
||||
setActiveBinding({ address: '', updatedAt: Date.now() })
|
||||
setMessage('已成功清空绑定节点 (已同步至服务器)')
|
||||
} else {
|
||||
const node = nodes?.find((item) => item.address === address)
|
||||
setSandboxNodeBinding({
|
||||
address: address,
|
||||
name: node?.name || address,
|
||||
updatedBy: 'root',
|
||||
})
|
||||
setActiveBinding({ address, updatedAt: Date.now() })
|
||||
setMessage(`应用成功:已绑定至 ${node?.name || address} (已同步至服务器)`)
|
||||
}
|
||||
|
||||
@ -112,7 +98,7 @@ export default function SandboxNodeBindingPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const currentActive = getSandboxNodeBinding()
|
||||
const currentActive = activeBinding?.address ? activeBinding : null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@ -163,7 +149,7 @@ export default function SandboxNodeBindingPanel() {
|
||||
{currentActive ? (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-700">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
当前活跃绑定:<span className="font-bold">{currentActive.name || currentActive.address}</span>
|
||||
当前活跃绑定:<span className="font-bold">{currentActive.address}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
@ -171,11 +157,11 @@ export default function SandboxNodeBindingPanel() {
|
||||
当前未绑定任何节点
|
||||
</div>
|
||||
)}
|
||||
{currentActive?.updatedAt && (
|
||||
{currentActive?.updatedAt ? (
|
||||
<p className="pl-4 text-[10px] text-gray-400">
|
||||
最后更新时间:{new Date(currentActive.updatedAt).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-600">⚠️ 节点列表加载失败:{error.message}</p>}
|
||||
|
||||
@ -8,7 +8,7 @@ import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import { useUserStore } from '@lib/userStore'
|
||||
import { getSandboxNodeBinding } from '../lib/sandboxNodeBinding'
|
||||
import { fetchSandboxNodeBinding } from '../lib/sandboxNodeBinding'
|
||||
|
||||
interface VlessNode {
|
||||
name: string
|
||||
@ -67,32 +67,43 @@ export default function UserCenterAgentRoute() {
|
||||
const isGuestSandboxReadOnly = Boolean(
|
||||
user?.isReadOnly && (normalizedEmail === 'sandbox@svc.plus' || normalizedEmail === 'demo@svc.plus'),
|
||||
)
|
||||
const [boundAddress, setBoundAddress] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGuestSandboxReadOnly) {
|
||||
setBoundNode(null)
|
||||
setBoundAddress(null)
|
||||
return
|
||||
}
|
||||
const binding = getSandboxNodeBinding()
|
||||
if (!binding) {
|
||||
setBoundNode(null)
|
||||
return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const binding = await fetchSandboxNodeBinding()
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setBoundAddress(binding?.address ?? null)
|
||||
if (!binding?.address) {
|
||||
setBoundNode(null)
|
||||
return
|
||||
}
|
||||
setBoundNode({
|
||||
name: binding.name || 'Sandbox Node',
|
||||
address: binding.address,
|
||||
port: 443,
|
||||
transport: 'tcp',
|
||||
security: 'tls',
|
||||
} as any)
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
setBoundNode({
|
||||
name: binding.name || 'Sandbox Node',
|
||||
address: binding.address,
|
||||
port: 443,
|
||||
transport: 'tcp',
|
||||
security: 'tls',
|
||||
} as any)
|
||||
}, [isGuestSandboxReadOnly])
|
||||
|
||||
const effectiveNodes = useMemo(() => {
|
||||
// 1. If we have a bound node address (from root management), try to find it in the full list
|
||||
if (isGuestSandboxReadOnly && normalizedEmail) {
|
||||
const binding = getSandboxNodeBinding()
|
||||
if (binding?.address && nodes?.length) {
|
||||
const matched = nodes.find((n) => n.address === binding.address)
|
||||
if (boundAddress && nodes?.length) {
|
||||
const matched = nodes.find((n) => n.address === boundAddress)
|
||||
if (matched) {
|
||||
return [matched]
|
||||
}
|
||||
@ -106,7 +117,7 @@ export default function UserCenterAgentRoute() {
|
||||
|
||||
// 3. No fallback logic
|
||||
return []
|
||||
}, [isGuestSandboxReadOnly, nodes, visibleNodes, normalizedEmail])
|
||||
}, [isGuestSandboxReadOnly, nodes, visibleNodes, normalizedEmail, boundAddress])
|
||||
|
||||
const groupedNodes = useMemo(() => {
|
||||
const groups: Record<string, VlessNode[]> = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user