feat: Introduce public sandbox binding API and refactor client-side fetching to use it, enhancing upstream error handling for sandbox-related routes.

This commit is contained in:
Haitao Pan 2026-02-06 18:18:58 +08:00
parent 0252a0d81e
commit 7a5bf406d5

View File

@ -0,0 +1,201 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Card from '../../components/Card'
import { useUserStore } from '@lib/userStore'
type AssumeStatus = {
isAssuming: boolean
target?: string
}
const SANDBOX_EMAIL = 'sandbox@svc.plus'
async function fetchAssumeStatus(): Promise<AssumeStatus> {
const res = await fetch('/api/sandbox/assume/status', { method: 'GET', cache: 'no-store', credentials: 'include' })
const payload = (await res.json().catch(() => null)) as any
return {
isAssuming: Boolean(payload?.isAssuming),
target: typeof payload?.target === 'string' ? payload.target : undefined,
}
}
export default function RootAssumeSandboxPanel() {
const user = useUserStore((state) => state.user)
const isRoot = useMemo(() => {
const email = user?.email?.trim().toLowerCase() ?? ''
return email === 'admin@svc.plus' && Boolean(user?.isAdmin)
}, [user?.email, user?.isAdmin])
const [status, setStatus] = useState<AssumeStatus>({ isAssuming: false })
const [isLoading, setIsLoading] = useState(true)
const [isBusy, setIsBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
// Future-proof: allowlist targets can be extended later.
const [draftTarget, setDraftTarget] = useState<string>(SANDBOX_EMAIL)
// Two-step confirmation (select -> confirm apply).
const [pendingConfirm, setPendingConfirm] = useState(false)
useEffect(() => {
let cancelled = false
void (async () => {
try {
setIsLoading(true)
const next = await fetchAssumeStatus()
if (cancelled) return
setStatus(next)
} catch (error) {
if (cancelled) return
setStatus({ isAssuming: false })
} finally {
if (cancelled) return
setIsLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
const handleAssume = async () => {
if (!isRoot || isBusy) return
try {
setIsBusy(true)
setMessage(null)
const res = await fetch('/api/sandbox/assume', { method: 'POST', cache: 'no-store', credentials: 'include' })
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as any
throw new Error((payload && (payload.message || payload.error)) || `Assume failed (${res.status})`)
}
setMessage('已切换到 Sandbox 视角(只读)')
// Hard refresh ensures the new xc_session cookie is used everywhere immediately.
window.location.reload()
} catch (error) {
setMessage(`错误:${error instanceof Error ? error.message : '切换失败'}`)
} finally {
setIsBusy(false)
}
}
const handleRevert = async () => {
if (!isRoot || isBusy) return
try {
setIsBusy(true)
setMessage(null)
const res = await fetch('/api/sandbox/assume/revert', { method: 'POST', cache: 'no-store', credentials: 'include' })
if (!res.ok) {
const payload = (await res.json().catch(() => null)) as any
throw new Error((payload && (payload.message || payload.error)) || `Revert failed (${res.status})`)
}
setMessage('已退出 Sandbox恢复 Root 会话')
window.location.reload()
} catch (error) {
setMessage(`错误:${error instanceof Error ? error.message : '退出失败'}`)
} finally {
setIsBusy(false)
}
}
const confirmLabel = status.isAssuming ? '确认退出' : '确认切换'
const primaryDisabled = !isRoot || isBusy || isLoading
return (
<Card>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">Root Assume </h2>
<p className="text-sm text-gray-600">
Root Sandbox Sandbox allowlist
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<label className="flex flex-1 flex-col gap-2 text-sm font-medium text-gray-700">
<select
value={draftTarget}
disabled={primaryDisabled || status.isAssuming}
onChange={(e) => {
setDraftTarget(e.target.value)
setPendingConfirm(false)
setMessage(null)
}}
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={SANDBOX_EMAIL}>sandbox@svc.plus</option>
</select>
</label>
<div className="flex gap-2">
{pendingConfirm ? (
<>
<button
type="button"
onClick={() => {
if (status.isAssuming) {
void handleRevert()
} else {
void handleAssume()
}
}}
disabled={primaryDisabled || (!status.isAssuming && draftTarget !== SANDBOX_EMAIL)}
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-purple-700 disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-500"
>
{isBusy ? '处理中…' : confirmLabel}
</button>
<button
type="button"
onClick={() => setPendingConfirm(false)}
disabled={isBusy}
className="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
>
</button>
</>
) : (
<button
type="button"
onClick={() => {
setPendingConfirm(true)
setMessage(null)
}}
disabled={primaryDisabled}
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-purple-700 disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-500"
>
{status.isAssuming ? '退出 Sandbox' : '切换到 Sandbox'}
</button>
)}
</div>
</div>
<div className="space-y-1 rounded-md bg-gray-50 p-3">
{status.isAssuming ? (
<div className="flex items-center gap-2 text-xs text-gray-700">
<div className="h-2 w-2 rounded-full bg-amber-500" />
Assume<span className="font-bold">{status.target || SANDBOX_EMAIL}</span>
</div>
) : (
<div className="flex items-center gap-2 text-xs text-gray-500">
<div className="h-2 w-2 rounded-full bg-gray-300" />
Assume
</div>
)}
<p className="pl-4 text-[10px] text-gray-400">
Sandbox Assume root `xc_session_root` host-only httpOnly
</p>
</div>
{message ? (
<p className={`text-xs font-medium ${message.startsWith('错误') ? 'text-red-600' : 'text-green-600'}`}>
{message}
</p>
) : null}
</div>
</Card>
)
}