diff --git a/src/app/api/admin/sandbox/bind/route.ts b/src/app/api/admin/sandbox/bind/route.ts new file mode 100644 index 0000000..cd303dc --- /dev/null +++ b/src/app/api/admin/sandbox/bind/route.ts @@ -0,0 +1,59 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' + +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'] + +type ErrorPayload = { + error: string +} + +function isAllowedRootEmail(email?: string): boolean { + return email?.trim().toLowerCase() === 'admin@svc.plus' +} + +export async function POST(request: NextRequest) { + const session = await getAccountSession(request) + const user = session.user + + if (!user || !session.token) { + return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }) + } + + if (!(await userHasRole(user, REQUIRED_ROLES))) { + return NextResponse.json({ error: 'forbidden' }, { status: 403 }) + } + + if (!isAllowedRootEmail(user.email)) { + return NextResponse.json({ error: 'root_only' }, { status: 403 }) + } + + const headers = new Headers({ + Authorization: `Bearer ${session.token}`, + Accept: 'application/json', + }) + + const body = await request.text() + 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', + }) + + const payload = await response.json().catch(() => null) + if (payload === null) { + return NextResponse.json({ error: 'invalid_response' }, { status: 502 }) + } + + return NextResponse.json(payload, { status: response.status }) +} + diff --git a/src/app/api/admin/sandbox/binding/route.ts b/src/app/api/admin/sandbox/binding/route.ts new file mode 100644 index 0000000..189333f --- /dev/null +++ b/src/app/api/admin/sandbox/binding/route.ts @@ -0,0 +1,52 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' + +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'] + +type ErrorPayload = { + error: string +} + +function isAllowedRootEmail(email?: string): boolean { + return email?.trim().toLowerCase() === 'admin@svc.plus' +} + +export async function GET(request: NextRequest) { + const session = await getAccountSession(request) + const user = session.user + + if (!user || !session.token) { + return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }) + } + + if (!(await userHasRole(user, REQUIRED_ROLES))) { + return NextResponse.json({ error: 'forbidden' }, { status: 403 }) + } + + if (!isAllowedRootEmail(user.email)) { + return NextResponse.json({ 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', + }) + + const payload = await response.json().catch(() => null) + if (payload === null) { + return NextResponse.json({ error: 'invalid_response' }, { status: 502 }) + } + + return NextResponse.json(payload, { status: response.status }) +} + diff --git a/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx b/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx index a063ca9..c0e1dcd 100644 --- a/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx +++ b/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx @@ -46,6 +46,7 @@ export default function SandboxNodeBindingPanel() { const currentBinding = useMemo(() => getSandboxNodeBinding(), []) const [draftAddress, setDraftAddress] = useState(currentBinding?.address ?? '') + const [isSaving, setIsSaving] = useState(false) useEffect(() => { // Initial load from server to stay in sync @@ -72,9 +73,11 @@ export default function SandboxNodeBindingPanel() { return (current?.address ?? '') !== draftAddress }, [draftAddress]) - const handleApply = async () => { - const address = draftAddress.trim() + const handleApply = async (rawAddress: string) => { + const address = rawAddress.trim() try { + setIsSaving(true) + setMessage(null) const response = await fetch('/api/admin/sandbox/bind', { method: 'POST', headers: { @@ -104,6 +107,8 @@ export default function SandboxNodeBindingPanel() { // Refresh local state if needed (though we already updated it) } catch (err: any) { setMessage(`错误:${err.message}`) + } finally { + setIsSaving(false) } } @@ -124,8 +129,10 @@ export default function SandboxNodeBindingPanel() { value={draftAddress} disabled={isLoading || !nodes} onChange={(e) => { - setDraftAddress(e.target.value) - setMessage(null) + const next = e.target.value + setDraftAddress(next) + // 自动确认:选择后立即提交到服务器 + void handleApply(next) }} 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" > @@ -139,14 +146,14 @@ export default function SandboxNodeBindingPanel() { @@ -179,4 +186,3 @@ export default function SandboxNodeBindingPanel() { ) } -