Remove sandbox guest identity exposure

This commit is contained in:
Haitao Pan 2026-04-12 17:12:28 +08:00
parent 37c5788263
commit 22e95e5bcb
24 changed files with 122 additions and 1024 deletions

View File

@ -24,7 +24,7 @@ flowchart TB
AgentAPI["/api/agent-server/[...segments]\n/api/agent/[...segments]"] AgentAPI["/api/agent-server/[...segments]\n/api/agent/[...segments]"]
RagAPI["/api/rag/query\n/api/askai"] RagAPI["/api/rag/query\n/api/askai"]
UtilAPI["/api/users\n/api/ping\n/api/content-meta\n/api/render-markdown\n/api/dl-index/*\n/api/marketing/home-stats\n/api/integrations/*\n/api/moltbot/chat\n/api/openclaw/assistant\n/api/task/[...segments]\n/api/xworkmate/profile"] UtilAPI["/api/users\n/api/ping\n/api/content-meta\n/api/render-markdown\n/api/dl-index/*\n/api/marketing/home-stats\n/api/integrations/*\n/api/moltbot/chat\n/api/openclaw/assistant\n/api/task/[...segments]\n/api/xworkmate/profile"]
SandboxAPI["/api/sandbox/*"] GuestAPI["/api/guest/*"]
end end
Accounts["accounts.svc.plus"] Accounts["accounts.svc.plus"]
@ -65,10 +65,10 @@ flowchart TB
| Auth | `/api/auth/login`, `/api/auth/register`, `/api/auth/register/send`, `/api/auth/register/verify`, `/api/auth/verify-email`, `/api/auth/verify-email/send`, `/api/auth/session`, `/api/auth/token/exchange` | Login, registration, token exchange, session lookup | `accounts.svc.plus/api/auth/*` | | Auth | `/api/auth/login`, `/api/auth/register`, `/api/auth/register/send`, `/api/auth/register/verify`, `/api/auth/verify-email`, `/api/auth/verify-email/send`, `/api/auth/session`, `/api/auth/token/exchange` | Login, registration, token exchange, session lookup | `accounts.svc.plus/api/auth/*` |
| MFA | `/api/auth/mfa/status`, `/api/auth/mfa/setup`, `/api/auth/mfa/verify`, `/api/auth/mfa/disable` | TOTP setup and verification | `accounts.svc.plus/api/auth/*` | | MFA | `/api/auth/mfa/status`, `/api/auth/mfa/setup`, `/api/auth/mfa/verify`, `/api/auth/mfa/disable` | TOTP setup and verification | `accounts.svc.plus/api/auth/*` |
| OAuth / billing | `/api/auth/oauth/login/[provider]`, `/api/auth/stripe/checkout`, `/api/auth/stripe/portal`, `/api/auth/subscriptions`, `/api/auth/subscriptions/cancel` | OAuth redirects and billing actions | `accounts.svc.plus/api/auth/*` | | OAuth / billing | `/api/auth/oauth/login/[provider]`, `/api/auth/stripe/checkout`, `/api/auth/stripe/portal`, `/api/auth/subscriptions`, `/api/auth/subscriptions/cancel` | OAuth redirects and billing actions | `accounts.svc.plus/api/auth/*` |
| Admin | `/api/admin/settings`, `/api/admin/homepage-video`, `/api/admin/users/*`, `/api/admin/blacklist/*`, `/api/admin/sandbox/*` | Account admin operations | `accounts.svc.plus/api/*` | | Admin | `/api/admin/settings`, `/api/admin/homepage-video`, `/api/admin/users/*`, `/api/admin/blacklist/*` | Account admin operations | `accounts.svc.plus/api/*` |
| Agent bridge | `/api/agent-server/[...segments]`, `/api/agent/[...segments]` | Agent registry/status and legacy alias | `accounts.svc.plus` | | Agent bridge | `/api/agent-server/[...segments]`, `/api/agent/[...segments]` | Agent registry/status and legacy alias | `accounts.svc.plus` |
| RAG | `/api/rag/query`, `/api/askai` | Retrieval and answer generation | `rag-server.svc.plus` | | RAG | `/api/rag/query`, `/api/askai` | Retrieval and answer generation | `rag-server.svc.plus` |
| Sandbox / session shaping | `/api/sandbox/assume`, `/api/sandbox/assume/revert`, `/api/sandbox/assume/status`, `/api/sandbox/binding` | Guest / demo identity switching | `accounts.svc.plus/api/auth/*` and internal sandbox reads | | Guest / demo runtime | `/api/guest/binding` | Guest read-only node resolution for demo access | `accounts.svc.plus/api/sandbox/binding` |
| Content / docs | `/api/content-meta`, `/api/render-markdown`, `/api/blogs/latest`, `/api/dl-index/*` | Docs/content rendering and download manifests | docs / CDN / download service | | Content / docs | `/api/content-meta`, `/api/render-markdown`, `/api/blogs/latest`, `/api/dl-index/*` | Docs/content rendering and download manifests | docs / CDN / download service |
| Integrations | `/api/integrations/defaults`, `/api/integrations/probe`, `/api/marketing/home-stats` | Integration defaults, health probes, marketing metrics | config-dependent external services | | Integrations | `/api/integrations/defaults`, `/api/integrations/probe`, `/api/marketing/home-stats` | Integration defaults, health probes, marketing metrics | config-dependent external services |
| Misc | `/api/ping`, `/api/users`, `/api/xworkmate/profile`, `/api/task/[...segments]`, `/api/openclaw/assistant`, `/api/moltbot/chat`, `/api/render-markdown` | Health, user lookup, profile, task and assistant proxies | `accounts.svc.plus`, internal API, task services | | Misc | `/api/ping`, `/api/users`, `/api/xworkmate/profile`, `/api/task/[...segments]`, `/api/openclaw/assistant`, `/api/moltbot/chat`, `/api/render-markdown` | Health, user lookup, profile, task and assistant proxies | `accounts.svc.plus`, internal API, task services |

View File

@ -31,7 +31,7 @@ Published commit: `0fab89e`
- Split observability into a tri-view workspace and refined panel assistant routing. - Split observability into a tri-view workspace and refined panel assistant routing.
- Unified navigation structure and persistent AI sidebar behavior. - Unified navigation structure and persistent AI sidebar behavior.
- Improved login and registration flows by using server-resolved account service URLs. - Improved login and registration flows by using server-resolved account service URLs.
- Consolidated demo and experience account handling around `sandbox@svc.plus`. - Guest and demo access must not expose any backing account identity in public UI or session payloads.
- Added vault-backed token lookup for integrations. - Added vault-backed token lookup for integrations.
#### Docs And Setup #### Docs And Setup

View File

@ -33,7 +33,7 @@
- 将 observability 工作区拆分为 tri-view并优化 panel 助手路由。 - 将 observability 工作区拆分为 tri-view并优化 panel 助手路由。
- 统一导航结构与持久化 AI sidebar 行为。 - 统一导航结构与持久化 AI sidebar 行为。
- 登录与注册流程改为使用服务端解析后的 account service URL。 - 登录与注册流程改为使用服务端解析后的 account service URL。
- 体验账号与演示账号统一收敛到 `sandbox@svc.plus` - 体验与演示模式不得在公开 UI 或会话载荷中暴露其后端承载账号身份
- 为集成配置增加基于 vault 的 token 查询能力。 - 为集成配置增加基于 vault 的 token 查询能力。
#### 文档与安装 #### 文档与安装

View File

@ -1,120 +0,0 @@
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
import {
getAccountSession,
userHasPermission,
userHasRole,
userHasRoleOrPermission,
} from "@server/account/session";
import type { AccountUserRole } from "@server/account/session";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const READ_ROLES: AccountUserRole[] = ["admin", "operator"];
const WRITE_ROLES: AccountUserRole[] = ["admin"];
const READ_PERMISSIONS = ["admin.settings.read"];
const WRITE_PERMISSIONS = ["admin.settings.write"];
type ErrorPayload = {
error: string;
};
async function proxyRequest(request: NextRequest) {
const session = await getAccountSession(request);
const user = session.user;
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
const { pathname, search } = new URL(request.url);
// Map /api/admin/sandbox/... to backend /admin/sandbox/...
const segments = pathname.replace(/^\/api\/admin\/sandbox/, "");
const targetUrl = `${ACCOUNT_API_BASE}/admin/sandbox${segments}${search}`;
const method = request.method;
const isWrite = method !== "GET" && method !== "HEAD";
if (isWrite) {
if (
!(
(await userHasRole(user, WRITE_ROLES)) ||
(await userHasPermission(user, WRITE_PERMISSIONS))
)
) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
} else {
if (!(await userHasRoleOrPermission(user, READ_ROLES, READ_PERMISSIONS))) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
}
const headers = new Headers({
Authorization: `Bearer ${session.token}`,
Accept: "application/json",
});
let body: string | undefined;
if (isWrite) {
body = await request.text();
const contentType =
request.headers.get("content-type") ?? "application/json";
headers.set("Content-Type", contentType);
}
try {
const response = await fetch(targetUrl, {
method,
headers,
body,
cache: "no-store",
});
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 (err: any) {
return NextResponse.json<ErrorPayload>(
{ error: err.message },
{ status: 500 },
);
}
}
export async function GET(request: NextRequest) {
return proxyRequest(request);
}
export async function POST(request: NextRequest) {
return proxyRequest(request);
}
export async function PUT(request: NextRequest) {
return proxyRequest(request);
}
export async function PATCH(request: NextRequest) {
return proxyRequest(request);
}
export async function DELETE(request: NextRequest) {
return proxyRequest(request);
}

View File

@ -1,71 +0,0 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const WRITE_PERMISSIONS = ['admin.settings.write']
type ErrorPayload = {
error: string
}
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 })
}
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { 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)
try {
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/bind`, {
method: 'POST',
headers,
body,
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 bind', error)
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
}

View File

@ -1,64 +0,0 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
import { getAccountSession } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const READ_PERMISSIONS = ['admin.settings.read']
type ErrorPayload = {
error: string
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: READ_PERMISSIONS,
rootOnly: true,
})
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
}
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 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 })
}
}

View File

@ -2,6 +2,7 @@ import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE_NAME, clearSessionCookie } from "@lib/authGateway"; import { SESSION_COOKIE_NAME, clearSessionCookie } from "@lib/authGateway";
import { resolvePublicUserEmail } from "@lib/publicUserIdentity";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig"; import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
@ -212,10 +213,15 @@ export async function GET(request: NextRequest) {
const normalizedUser = identifier const normalizedUser = identifier
? { ...rawUser, id: identifier, uuid: identifier } ? { ...rawUser, id: identifier, uuid: identifier }
: rawUser; : rawUser;
const publicEmail = resolvePublicUserEmail({
email: normalizedUser.email,
role: normalizedRole,
});
return NextResponse.json({ return NextResponse.json({
user: { user: {
...normalizedUser, ...normalizedUser,
email: publicEmail,
mfaEnabled: derivedMfaEnabled, mfaEnabled: derivedMfaEnabled,
mfaPending: derivedMfaPending, mfaPending: derivedMfaPending,
mfa: normalizedMfa, mfa: normalizedMfa,

View File

@ -50,7 +50,7 @@ export async function GET(request: NextRequest) {
} }
return NextResponse.json(payload, { status: response.status }) return NextResponse.json(payload, { status: response.status })
} catch (error) { } catch (error) {
console.error('Failed to proxy sandbox binding (public)', error) console.error('Failed to proxy guest node binding', error)
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 }) return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
} }
} }

View File

@ -1,97 +0,0 @@
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,
undefined,
request.headers.get("host") ?? undefined,
);
response.cookies.set({
name: ROOT_BACKUP_COOKIE,
value: "",
httpOnly: true,
secure: secureCookies(),
sameSite: "lax",
path: "/",
maxAge: 0,
});
return response;
}

View File

@ -1,119 +0,0 @@
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server";
import { applySessionCookie, deriveMaxAgeFromExpires } from "@lib/authGateway";
import { evaluateAccountAdminAccess } from "@server/account/adminAccess";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
import { getAccountSession } from "@server/account/session";
import type { AccountUserRole } from "@server/account/session";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const REQUIRED_ROLES: AccountUserRole[] = ["admin"];
const WRITE_PERMISSIONS = ["admin.settings.write"];
const ROOT_BACKUP_COOKIE = "xc_session_root";
const SANDBOX_EMAIL = "sandbox@svc.plus";
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://");
}
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 },
);
}
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
});
if (!access.allowed) {
return NextResponse.json<ErrorPayload>(
{ error: access.reason ?? "forbidden" },
{ 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 only if it's NOT already an assumed session.
// Check if the current user is NOT the sandbox user.
if (user.email.toLowerCase() !== SANDBOX_EMAIL) {
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),
request.headers.get("host") ?? undefined,
);
return response;
} catch (error) {
console.error("Failed to assume sandbox", error);
return NextResponse.json<ErrorPayload>(
{ error: "upstream_unreachable" },
{ status: 502 },
);
}
}

View File

@ -1,11 +0,0 @@
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' : '' })
}

View File

@ -1,10 +1,9 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { ChevronLeft, ChevronRight, Menu } from "lucide-react"; import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { hasPublicUserEmail } from "@lib/publicUserIdentity";
import { useUserStore } from "@lib/userStore"; import { useUserStore } from "@lib/userStore";
import type { UserRole } from "@lib/userStore"; import type { UserRole } from "@lib/userStore";
import { useLanguage } from "@i18n/LanguageProvider"; import { useLanguage } from "@i18n/LanguageProvider";
@ -56,7 +55,6 @@ export default function Header({
isCollapsed, isCollapsed,
}: HeaderProps) { }: HeaderProps) {
const { language } = useLanguage(); const { language } = useLanguage();
const router = useRouter();
const user = useUserStore((state) => state.user); const user = useUserStore((state) => state.user);
const isLoading = useUserStore((state) => state.isLoading); const isLoading = useUserStore((state) => state.isLoading);
const role: UserRole = user?.role ?? "guest"; const role: UserRole = user?.role ?? "guest";
@ -68,137 +66,10 @@ export default function Header({
const badgeClasses = isLoading const badgeClasses = isLoading
? "bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] opacity-70" ? "bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] opacity-70"
: badge.className; : badge.className;
const shouldRenderPublicEmail = hasPublicUserEmail(user?.email);
const isRoot = useMemo(() => {
const email = user?.email?.trim().toLowerCase() ?? "";
return email === "admin@svc.plus" && role === "admin";
}, [role, user?.email]);
const [assumeStatus, setAssumeStatus] = useState<{
isAssuming: boolean;
target?: string;
}>({
isAssuming: false,
});
const [assumeBusy, setAssumeBusy] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await fetch("/api/sandbox/assume/status", {
method: "GET",
cache: "no-store",
});
const payload = (await res.json().catch(() => null)) as any;
if (cancelled) return;
setAssumeStatus({
isAssuming: Boolean(payload?.isAssuming),
target:
typeof payload?.target === "string" ? payload.target : undefined,
});
} catch {
if (cancelled) return;
setAssumeStatus({ isAssuming: false });
}
})();
return () => {
cancelled = true;
};
}, []);
const handleAssumeSandbox = async () => {
if (!isRoot || assumeBusy) return;
try {
setAssumeBusy(true);
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})`,
);
}
router.refresh();
// Ensure server-rendered parts reflect the new cookie immediately.
window.location.reload();
} finally {
setAssumeBusy(false);
}
};
const handleRevertAssume = async () => {
if (assumeBusy) return;
try {
setAssumeBusy(true);
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})`,
);
}
router.refresh();
window.location.reload();
} finally {
setAssumeBusy(false);
}
};
return ( return (
<header className="sticky top-0 z-30 overflow-hidden border-b border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] text-[var(--color-text)] shadow-[var(--shadow-soft)] backdrop-blur-xl transition-colors"> <header className="sticky top-0 z-30 overflow-hidden border-b border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] text-[var(--color-text)] shadow-[var(--shadow-soft)] backdrop-blur-xl transition-colors">
{assumeStatus.isAssuming ? (
<div className="flex items-center justify-between gap-3 px-4 py-2 text-xs md:px-5">
<div className="rounded-[8px] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] px-3 py-1.5 text-[var(--color-warning-foreground)]">
{language === "zh"
? `当前处于 Assume: ${assumeStatus.target || "sandbox@svc.plus"}(只读视角)`
: `Assuming: ${assumeStatus.target || "sandbox@svc.plus"} (read-only view)`}
</div>
<button
type="button"
onClick={() => void handleRevertAssume()}
disabled={assumeBusy}
className="tactile-button tactile-button-subtle min-h-8 px-3 text-[var(--color-warning-foreground)] disabled:opacity-60"
>
{assumeBusy
? language === "zh"
? "处理中…"
: "Working…"
: language === "zh"
? "退出 Sandbox"
: "Exit Sandbox"}
</button>
</div>
) : (
<div className="flex items-center justify-end gap-2 px-4 py-2 text-xs md:px-5">
{isRoot ? (
<button
type="button"
onClick={() => void handleAssumeSandbox()}
disabled={assumeBusy || isLoading}
className="tactile-button tactile-button-soft min-h-8 border border-[color:var(--color-primary-border)] px-3 text-[var(--color-primary)] disabled:opacity-60"
>
{assumeBusy
? language === "zh"
? "处理中…"
: "Working…"
: language === "zh"
? "切换到 Sandbox"
: "Assume Sandbox"}
</button>
) : null}
</div>
)}
<div className="flex items-center justify-between px-4 py-2.5 md:px-5"> <div className="flex items-center justify-between px-4 py-2.5 md:px-5">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
@ -251,10 +122,7 @@ export default function Header({
<span className="text-[13px] font-semibold text-[var(--color-text)]"> <span className="text-[13px] font-semibold text-[var(--color-text)]">
{accountLabel} {accountLabel}
</span> </span>
<span> {shouldRenderPublicEmail ? <span>{user?.email}</span> : null}
{user?.email ??
(isLoading ? "Checking session…" : "Not signed in")}
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -20,6 +20,7 @@ import { AskAIButton } from "./AskAIButton";
import ReleaseChannelSelector, { import ReleaseChannelSelector, {
ReleaseChannel, ReleaseChannel,
} from "./ReleaseChannelSelector"; } from "./ReleaseChannelSelector";
import { hasPublicUserEmail } from "@lib/publicUserIdentity";
import { useUserStore } from "@lib/userStore"; import { useUserStore } from "@lib/userStore";
// import SearchComponent from './search' // import SearchComponent from './search'
@ -61,6 +62,7 @@ export default function Navbar() {
user?.username?.charAt(0)?.toUpperCase() ?? user?.username?.charAt(0)?.toUpperCase() ??
user?.email?.charAt(0)?.toUpperCase() ?? user?.email?.charAt(0)?.toUpperCase() ??
"?"; "?";
const shouldRenderPublicEmail = hasPublicUserEmail(user?.email);
const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const accountMenuRef = useRef<HTMLDivElement | null>(null); const accountMenuRef = useRef<HTMLDivElement | null>(null);
@ -534,7 +536,9 @@ export default function Navbar() {
<p className="text-sm font-semibold text-text"> <p className="text-sm font-semibold text-text">
{user.username} {user.username}
</p> </p>
<p className="text-xs text-text-muted">{user.email}</p> {shouldRenderPublicEmail ? (
<p className="text-xs text-text-muted">{user.email}</p>
) : null}
</div> </div>
<div className="py-1 text-sm text-text"> <div className="py-1 text-sm text-text">
<Link <Link
@ -648,9 +652,11 @@ export default function Navbar() {
<p className="truncate text-sm font-semibold"> <p className="truncate text-sm font-semibold">
{user.username} {user.username}
</p> </p>
<p className="truncate text-xs text-text-muted"> {shouldRenderPublicEmail ? (
{user.email} <p className="truncate text-xs text-text-muted">
</p> {user.email}
</p>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@ -9,6 +9,7 @@ import { translations } from "../i18n/translations";
import LanguageToggle from "./LanguageToggle"; import LanguageToggle from "./LanguageToggle";
// import { AskAIButton } from "./AskAIButton"; // import { AskAIButton } from "./AskAIButton";
import ReleaseChannelSelector from "./ReleaseChannelSelector"; import ReleaseChannelSelector from "./ReleaseChannelSelector";
import { hasPublicUserEmail } from "@lib/publicUserIdentity";
import { useUserStore } from "@lib/userStore"; import { useUserStore } from "@lib/userStore";
import { useMoltbotStore } from "@lib/moltbotStore"; import { useMoltbotStore } from "@lib/moltbotStore";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
@ -47,6 +48,7 @@ export default function UnifiedNavigation() {
const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const accountMenuRef = useRef<HTMLDivElement | null>(null); const accountMenuRef = useRef<HTMLDivElement | null>(null);
const isChinese = language === "zh"; const isChinese = language === "zh";
const shouldRenderPublicEmail = hasPublicUserEmail(user?.email);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@ -356,9 +358,11 @@ export default function UnifiedNavigation() {
<p className="text-sm font-semibold text-text leading-none mb-1.5"> <p className="text-sm font-semibold text-text leading-none mb-1.5">
{user.username} {user.username}
</p> </p>
<p className="text-[12px] text-text-muted leading-none break-all"> {shouldRenderPublicEmail ? (
{user.email} <p className="text-[12px] text-text-muted leading-none break-all">
</p> {user.email}
</p>
) : null}
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
hasPublicUserEmail,
resolvePublicUserEmail,
} from "@lib/publicUserIdentity";
describe("publicUserIdentity", () => {
it("hides guest email values from the public session payload", () => {
expect(
resolvePublicUserEmail({
email: "sandbox@svc.plus",
role: "guest",
}),
).toBe("");
});
it("preserves non-guest emails", () => {
expect(
resolvePublicUserEmail({
email: "admin@svc.plus",
role: "admin",
}),
).toBe("admin@svc.plus");
});
it("detects whether a public email should be rendered", () => {
expect(hasPublicUserEmail("")).toBe(false);
expect(hasPublicUserEmail("guest@svc.plus")).toBe(true);
});
});

View File

@ -0,0 +1,19 @@
function normalizeText(value?: string | null): string {
return typeof value === "string" ? value.trim() : "";
}
export function resolvePublicUserEmail(input: {
email?: string | null;
role?: string | null;
}): string {
const normalizedRole = normalizeText(input.role).toLowerCase();
if (normalizedRole === "guest") {
return "";
}
return normalizeText(input.email);
}
export function hasPublicUserEmail(email?: string | null): boolean {
return normalizeText(email).length > 0;
}

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import { create } from 'zustand' import { create } from 'zustand'
import { resolvePublicUserEmail } from '@lib/publicUserIdentity'
export type UserRole = 'guest' | 'user' | 'operator' | 'admin' export type UserRole = 'guest' | 'user' | 'operator' | 'admin'
@ -65,8 +66,6 @@ const KNOWN_ROLE_MAP: Record<string, UserRole> = {
member: 'user', member: 'user',
} }
const GUEST_SANDBOX_TENANT_ID = 'guest-sandbox'
const GUEST_SANDBOX_TENANT_NAME = 'Guest Sandbox'
function normalizeRole(input?: string | null): UserRole { function normalizeRole(input?: string | null): UserRole {
if (!input || typeof input !== 'string') { if (!input || typeof input !== 'string') {
return 'guest' return 'guest'
@ -153,6 +152,10 @@ async function fetchSessionUser(): Promise<User | null> {
} }
const normalizedRole = normalizeRole(role) const normalizedRole = normalizeRole(role)
const publicEmail = resolvePublicUserEmail({
email,
role: normalizedRole,
})
const rawRole = typeof role === 'string' ? role.trim().toLowerCase() : '' const rawRole = typeof role === 'string' ? role.trim().toLowerCase() : ''
const normalizedGroups = Array.isArray(groups) const normalizedGroups = Array.isArray(groups)
? groups ? groups
@ -218,9 +221,9 @@ async function fetchSessionUser(): Promise<User | null> {
uuid: identifier, uuid: identifier,
proxyUuid: normalizedProxyUuid, proxyUuid: normalizedProxyUuid,
proxyUuidExpiresAt: normalizedProxyUuidExpiresAt, proxyUuidExpiresAt: normalizedProxyUuidExpiresAt,
email, email: publicEmail,
name: normalizedName, name: normalizedName,
username: normalizedUsername ?? email, username: normalizedUsername ?? publicEmail,
mfaEnabled: Boolean(mfaEnabled ?? mfa?.totpEnabled), mfaEnabled: Boolean(mfaEnabled ?? mfa?.totpEnabled),
mfaPending: Boolean(mfaPending ?? mfa?.totpPending) && !Boolean(mfaEnabled ?? mfa?.totpEnabled), mfaPending: Boolean(mfaPending ?? mfa?.totpPending) && !Boolean(mfaEnabled ?? mfa?.totpEnabled),
mfa: normalizedMfa, mfa: normalizedMfa,

View File

@ -7,8 +7,9 @@ import { Copy } from 'lucide-react'
import { useLanguage } from '@i18n/LanguageProvider' import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations' import { translations } from '@i18n/translations'
import { hasPublicUserEmail } from '@lib/publicUserIdentity'
import { useUserStore } from '@lib/userStore' import { useUserStore } from '@lib/userStore'
import { fetchSandboxNodeBinding } from '../lib/sandboxNodeBinding' import { fetchGuestNodeBinding } from '../lib/guestNodeBinding'
import Card from './Card' import Card from './Card'
import VlessQrCard from './VlessQrCard' import VlessQrCard from './VlessQrCard'
@ -49,13 +50,13 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
const refresh = useUserStore((state) => state.refresh) const refresh = useUserStore((state) => state.refresh)
const logout = useUserStore((state) => state.logout) const logout = useUserStore((state) => state.logout)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [sandboxBoundNodeAddress, setSandboxBoundNodeAddress] = useState<string | null>(null) const [guestBoundNodeAddress, setGuestBoundNodeAddress] = useState<string | null>(null)
const displayName = useMemo(() => resolveDisplayName(user), [user]) const displayName = useMemo(() => resolveDisplayName(user), [user])
const uuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? '—' const uuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? '—'
const vlessUuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? null const vlessUuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? null
const username = user?.username ?? '—' const username = user?.username ?? '—'
const email = user?.email ?? '—' const email = hasPublicUserEmail(user?.email) ? user?.email : '—'
const docsUrl = mfaCopy.actions.docsUrl const docsUrl = mfaCopy.actions.docsUrl
const isGuestSandboxReadOnly = Boolean(user?.isGuest && user?.isReadOnly) const isGuestSandboxReadOnly = Boolean(user?.isGuest && user?.isReadOnly)
const guestUuidExpiresAtText = useMemo(() => { const guestUuidExpiresAtText = useMemo(() => {
@ -121,16 +122,16 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
useEffect(() => { useEffect(() => {
if (!isGuestSandboxReadOnly) { if (!isGuestSandboxReadOnly) {
setSandboxBoundNodeAddress(null) setGuestBoundNodeAddress(null)
return return
} }
let cancelled = false let cancelled = false
void (async () => { void (async () => {
const binding = await fetchSandboxNodeBinding() const binding = await fetchGuestNodeBinding()
if (cancelled) { if (cancelled) {
return return
} }
setSandboxBoundNodeAddress(binding?.address ?? null) setGuestBoundNodeAddress(binding?.address ?? null)
})() })()
return () => { return () => {
cancelled = true cancelled = true
@ -236,8 +237,8 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
<VlessQrCard <VlessQrCard
uuid={vlessUuid} uuid={vlessUuid}
copy={copy.cards.vless} copy={copy.cards.vless}
allowSandboxFallbackNode={isGuestSandboxReadOnly} allowGuestReadOnlyFallbackNode={isGuestSandboxReadOnly}
boundNodeAddress={sandboxBoundNodeAddress} boundNodeAddress={guestBoundNodeAddress}
/> />
<Card> <Card>

View File

@ -33,7 +33,7 @@ export type VlessQrCopy = {
interface VlessQrCardProps { interface VlessQrCardProps {
uuid: string | null | undefined uuid: string | null | undefined
copy: VlessQrCopy copy: VlessQrCopy
allowSandboxFallbackNode?: boolean allowGuestReadOnlyFallbackNode?: boolean
boundNodeAddress?: string | null boundNodeAddress?: string | null
} }
@ -41,7 +41,7 @@ interface VlessQrCardProps {
export default function VlessQrCard({ export default function VlessQrCard({
uuid, uuid,
copy, copy,
allowSandboxFallbackNode = false, allowGuestReadOnlyFallbackNode = false,
boundNodeAddress, boundNodeAddress,
}: VlessQrCardProps) { }: VlessQrCardProps) {
const { data: allNodes, error: nodesError } = useSWR<VlessNode[]>('user-center-agent-nodes', fetchAgentNodes) const { data: allNodes, error: nodesError } = useSWR<VlessNode[]>('user-center-agent-nodes', fetchAgentNodes)
@ -52,16 +52,16 @@ export default function VlessQrCard({
const address = (node.address || '').trim() const address = (node.address || '').trim()
if (!address || address === '*') return false if (!address || address === '*') return false
if (allowSandboxFallbackNode) { if (allowGuestReadOnlyFallbackNode) {
// In sandbox mode, allow internal agents so the user can see their bound node // Guest read-only mode still needs to expose the bound node, even when it
// even if it belongs to the shared token bucket. // would otherwise be filtered out as an internal shared node.
return true return true
} }
// Skip the redundant Internal Agents (Shared Token) node // Skip the redundant Internal Agents (Shared Token) node
return !(name.includes('internal agents') && name.includes('shared token')) return !(name.includes('internal agents') && name.includes('shared token'))
}) })
}, [allNodes, allowSandboxFallbackNode]) }, [allNodes, allowGuestReadOnlyFallbackNode])
const [selectedNode, setSelectedNode] = useState<VlessNode | null>(null) const [selectedNode, setSelectedNode] = useState<VlessNode | null>(null)
const [preferredTransport, setPreferredTransport] = useState<VlessTransport>('tcp') const [preferredTransport, setPreferredTransport] = useState<VlessTransport>('tcp')
const [isSelectorOpen, setIsSelectorOpen] = useState(false) const [isSelectorOpen, setIsSelectorOpen] = useState(false)
@ -81,10 +81,11 @@ export default function VlessQrCard({
return matched return matched
} }
// If we are in sandbox mode and API failed or node not found in list, create a synthetic fallback // If the guest read-only binding points to a node that is not present in the
if (allowSandboxFallbackNode) { // live list, synthesize a minimal fallback so QR generation still works.
if (allowGuestReadOnlyFallbackNode) {
return { return {
name: 'Sandbox Node', name: 'Guest Node',
address: boundNodeAddress, address: boundNodeAddress,
port: 443, port: 443,
transport: 'tcp', transport: 'tcp',
@ -102,7 +103,7 @@ export default function VlessQrCard({
// 3. No fallback node // 3. No fallback node
return undefined return undefined
}, [allNodes, allowSandboxFallbackNode, boundNodeAddress, nodes, selectedNode]) }, [allNodes, allowGuestReadOnlyFallbackNode, boundNodeAddress, nodes, selectedNode])
const effectiveNode = useMemo((): VlessNode | undefined => { const effectiveNode = useMemo((): VlessNode | undefined => {
if (!rawNode) return undefined if (!rawNode) return undefined
@ -296,8 +297,8 @@ export default function VlessQrCard({
<div className="rounded-md border border-[color:var(--color-warning-border)] bg-[var(--color-warning-muted)] p-3 text-xs text-[var(--color-warning-foreground)]"> <div className="rounded-md border border-[color:var(--color-warning-border)] bg-[var(--color-warning-muted)] p-3 text-xs text-[var(--color-warning-foreground)]">
<p className="font-semibold"> </p> <p className="font-semibold"> </p>
<p className="mt-1"> <p className="mt-1">
{allowSandboxFallbackNode {allowGuestReadOnlyFallbackNode
? '演示模式账号未发现有效的节点映射。请确认后端已完成 Sandbox 节点绑定逻辑。' ? '演示模式账号未发现有效的节点映射。请确认后端已完成 guest 节点绑定逻辑。'
: `无法从服务器获取代理节点列表${nodesError ? `${nodesError.message}` : ''}。请检查 API 接口是否正常。` : `无法从服务器获取代理节点列表${nodesError ? `${nodesError.message}` : ''}。请检查 API 接口是否正常。`
} }
</p> </p>

View File

@ -1,15 +1,15 @@
'use client' 'use client'
export type SandboxNodeBinding = { export type GuestNodeBinding = {
address: string address: string
name?: string name?: string
updatedAt: number updatedAt: number
updatedBy?: string updatedBy?: string
} }
export async function fetchSandboxNodeBinding(): Promise<SandboxNodeBinding | null> { export async function fetchGuestNodeBinding(): Promise<GuestNodeBinding | null> {
try { try {
const response = await fetch('/api/sandbox/binding', { method: 'GET', cache: 'no-store' }) const response = await fetch('/api/guest/binding', { method: 'GET', cache: 'no-store' })
if (!response.ok) { if (!response.ok) {
return null return null
} }
@ -29,7 +29,7 @@ export async function fetchSandboxNodeBinding(): Promise<SandboxNodeBinding | nu
typeof payload.updatedBy === 'string' && payload.updatedBy.trim().length > 0 ? payload.updatedBy.trim() : undefined, typeof payload.updatedBy === 'string' && payload.updatedBy.trim().length > 0 ? payload.updatedBy.trim() : undefined,
} }
} catch (error) { } catch (error) {
console.warn('Failed to fetch sandbox node binding', error) console.warn('Failed to fetch guest node binding', error)
return null return null
} }
} }

View File

@ -1,201 +0,0 @@
'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>
)
}

View File

@ -1,150 +0,0 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import useSWR from 'swr'
import Card from '../../components/Card'
import { fetchAgentNodes } from '../../lib/fetchAgentNodes'
import type { VlessNode } from '../../lib/vless'
export default function SandboxNodeBindingPanel() {
const { data: nodes, error, isLoading } = useSWR<VlessNode[]>('user-center-agent-nodes', fetchAgentNodes, {
revalidateOnFocus: false,
})
const [message, setMessage] = useState<string | null>(null)
const [activeBinding, setActiveBinding] = useState<{ address: string; updatedAt?: number } | null>(null)
const [draftAddress, setDraftAddress] = useState<string>('')
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
// Initial load from server to stay in sync
fetch('/api/admin/sandbox/binding')
.then(res => res.json())
.then(data => {
if (data && typeof data.address === 'string') {
setDraftAddress(data.address)
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(() => {
return (activeBinding?.address ?? '') !== draftAddress
}, [activeBinding?.address, draftAddress])
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: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ address }),
})
if (!response.ok) {
const payload = await response.json().catch(() => ({}))
throw new Error(payload.message || `Failed to save binding (${response.status})`)
}
if (!address) {
setActiveBinding({ address: '', updatedAt: Date.now() })
setMessage('已成功清空绑定节点 (已同步至服务器)')
} else {
const node = nodes?.find((item) => item.address === address)
setActiveBinding({ address, updatedAt: Date.now() })
setMessage(`应用成功:已绑定至 ${node?.name || address} (已同步至服务器)`)
}
// Refresh local state if needed (though we already updated it)
} catch (err: any) {
setMessage(`错误:${err.message}`)
} finally {
setIsSaving(false)
}
}
const currentActive = activeBinding?.address ? activeBinding : null
return (
<Card>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">Root Sandbox Node </h2>
<p className="text-sm text-gray-600">Sandbox@svc.plus 使</p>
</div>
<div className="flex items-end gap-3">
<label className="flex flex-1 flex-col gap-2 text-sm font-medium text-gray-700">
<select
value={draftAddress}
disabled={isLoading || !nodes}
onChange={(e) => {
const next = e.target.value
setDraftAddress(next)
// 两段式:先选择,再点“确认应用”提交到服务器
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=""></option>
{(nodes ?? [])
.filter((node) => node.address !== '*')
.map((node) => (
<option key={node.address} value={node.address}>
{node.name} ({node.address})
</option>
))}
</select>
</label>
<button
onClick={() => void handleApply(draftAddress)}
disabled={!isChanged || isSaving}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${isChanged
? 'bg-purple-600 text-white hover:bg-purple-700 shadow-sm'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
{isSaving ? '保存中…' : '确认应用'}
</button>
</div>
<div className="space-y-1 rounded-md bg-gray-50 p-3">
{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.address}</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" />
</div>
)}
{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>}
{message && (
<p className={`text-xs font-medium ${message.startsWith('错误') ? 'text-red-600' : 'text-green-600'}`}>
{message}
</p>
)}
</div>
</Card>
)
}

View File

@ -9,7 +9,7 @@ import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations' import { translations } from '@i18n/translations'
import { useUserStore } from '@lib/userStore' import { useUserStore } from '@lib/userStore'
import { fetchAgentNodes } from '../lib/fetchAgentNodes' import { fetchAgentNodes } from '../lib/fetchAgentNodes'
import { fetchSandboxNodeBinding } from '../lib/sandboxNodeBinding' import { fetchGuestNodeBinding } from '../lib/guestNodeBinding'
interface VlessNode { interface VlessNode {
@ -46,8 +46,8 @@ export default function UserCenterAgentRoute() {
const visibleNodes = useMemo(() => { const visibleNodes = useMemo(() => {
return (nodes ?? []).filter((node) => { return (nodes ?? []).filter((node) => {
if (isGuestSandboxReadOnly) { if (isGuestSandboxReadOnly) {
// In sandbox mode, allow internal agents so the user can see their bound node // Guest read-only mode still needs to expose the bound node even when the
// even if it belongs to the shared token bucket. // node belongs to the shared internal pool.
const address = (node.address || '').trim() const address = (node.address || '').trim()
return address && address !== '*' return address && address !== '*'
} }
@ -64,7 +64,7 @@ export default function UserCenterAgentRoute() {
} }
let cancelled = false let cancelled = false
void (async () => { void (async () => {
const binding = await fetchSandboxNodeBinding() const binding = await fetchGuestNodeBinding()
if (cancelled) { if (cancelled) {
return return
} }
@ -74,7 +74,7 @@ export default function UserCenterAgentRoute() {
return return
} }
setBoundNode({ setBoundNode({
name: binding.name || 'Sandbox Node', name: binding.name || 'Guest Node',
address: binding.address, address: binding.address,
port: 443, port: 443,
transport: 'tcp', transport: 'tcp',
@ -90,7 +90,7 @@ export default function UserCenterAgentRoute() {
// Default behavior: show all displayable nodes. // Default behavior: show all displayable nodes.
const base = visibleNodes.length > 0 ? [...visibleNodes] : [] const base = visibleNodes.length > 0 ? [...visibleNodes] : []
// Guest sandbox behavior: if root has bound a preferred node, ensure it is first, // Guest read-only behavior: if an admin bound a preferred node, ensure it is first,
// but still show all regions/nodes to keep the demo experience useful. // but still show all regions/nodes to keep the demo experience useful.
if (isGuestSandboxReadOnly && boundAddress) { if (isGuestSandboxReadOnly && boundAddress) {
const matched = nodes?.find((n) => n.address === boundAddress) const matched = nodes?.find((n) => n.address === boundAddress)
@ -101,7 +101,7 @@ export default function UserCenterAgentRoute() {
} }
} }
// Fallback if no nodes were returned by the API but we are in sandbox mode // Fallback if no nodes were returned by the API but the guest binding exists.
if (isGuestSandboxReadOnly && boundNode && base.length === 0) { if (isGuestSandboxReadOnly && boundNode && base.length === 0) {
return [boundNode] return [boundNode]
} }

View File

@ -17,8 +17,6 @@ import UserGroupManagement, {
type ManagedUser, type ManagedUser,
type CreateManagedUserInput, type CreateManagedUserInput,
} from "../management/components/UserGroupManagement"; } from "../management/components/UserGroupManagement";
import SandboxNodeBindingPanel from "../management/components/SandboxNodeBindingPanel";
import RootAssumeSandboxPanel from "../management/components/RootAssumeSandboxPanel";
import HomepageVideoSettingsPanel from "../management/components/HomepageVideoSettingsPanel"; import HomepageVideoSettingsPanel from "../management/components/HomepageVideoSettingsPanel";
import { EmailBlacklist } from "../management/components/EmailBlacklist"; import { EmailBlacklist } from "../management/components/EmailBlacklist";
import Breadcrumbs from "@/app/panel/components/Breadcrumbs"; import Breadcrumbs from "@/app/panel/components/Breadcrumbs";
@ -518,12 +516,6 @@ export default function UserCenterManagementRoute() {
onCreateCustomUser={handleCreateCustomUser} onCreateCustomUser={handleCreateCustomUser}
onManageBlacklist={() => setIsBlacklistOpen(true)} onManageBlacklist={() => setIsBlacklistOpen(true)}
/> />
{canCreateCustomUser ? (
<>
<RootAssumeSandboxPanel />
<SandboxNodeBindingPanel />
</>
) : null}
<EmailBlacklist <EmailBlacklist
isOpen={isBlacklistOpen} isOpen={isBlacklistOpen}
onClose={() => setIsBlacklistOpen(false)} onClose={() => setIsBlacklistOpen(false)}