From 7a5bf406d5b8de8b07bf40d41cf30855b7118869 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 6 Feb 2026 18:18:58 +0800 Subject: [PATCH] feat: Introduce public sandbox binding API and refactor client-side fetching to use it, enhancing upstream error handling for sandbox-related routes. --- .../components/RootAssumeSandboxPanel.tsx | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 src/modules/extensions/builtin/user-center/management/components/RootAssumeSandboxPanel.tsx diff --git a/src/modules/extensions/builtin/user-center/management/components/RootAssumeSandboxPanel.tsx b/src/modules/extensions/builtin/user-center/management/components/RootAssumeSandboxPanel.tsx new file mode 100644 index 0000000..cc4b6cb --- /dev/null +++ b/src/modules/extensions/builtin/user-center/management/components/RootAssumeSandboxPanel.tsx @@ -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 { + 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({ isAssuming: false }) + const [isLoading, setIsLoading] = useState(true) + const [isBusy, setIsBusy] = useState(false) + const [message, setMessage] = useState(null) + + // Future-proof: allowlist targets can be extended later. + const [draftTarget, setDraftTarget] = useState(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 ( + +
+
+

Root 管理员专用:Assume 调试入口

+

+ Root 可切换到 Sandbox 视角执行排查,但权限仍受 Sandbox 账号限制(只读)。后续可扩展 allowlist 以协助其他用户调试。 +

+
+ +
+ + +
+ {pendingConfirm ? ( + <> + + + + ) : ( + + )} +
+
+ +
+ {status.isAssuming ? ( +
+
+ 当前处于 Assume:{status.target || SANDBOX_EMAIL} +
+ ) : ( +
+
+ 当前未处于 Assume +
+ )} +

+ 安全约束:目标白名单硬编码;Sandbox 禁止密码登录;Assume 仅 root 可用;`xc_session_root` 为 host-only httpOnly。 +

+
+ + {message ? ( +

+ {message} +

+ ) : null} +
+ + ) +} +