Remove sandbox guest identity exposure
This commit is contained in:
parent
37c5788263
commit
22e95e5bcb
@ -24,7 +24,7 @@ flowchart TB
|
||||
AgentAPI["/api/agent-server/[...segments]\n/api/agent/[...segments]"]
|
||||
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"]
|
||||
SandboxAPI["/api/sandbox/*"]
|
||||
GuestAPI["/api/guest/*"]
|
||||
end
|
||||
|
||||
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/*` |
|
||||
| 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/*` |
|
||||
| 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` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@ -31,7 +31,7 @@ Published commit: `0fab89e`
|
||||
- Split observability into a tri-view workspace and refined panel assistant routing.
|
||||
- Unified navigation structure and persistent AI sidebar behavior.
|
||||
- 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.
|
||||
|
||||
#### Docs And Setup
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
- 将 observability 工作区拆分为 tri-view,并优化 panel 助手路由。
|
||||
- 统一导航结构与持久化 AI sidebar 行为。
|
||||
- 登录与注册流程改为使用服务端解析后的 account service URL。
|
||||
- 体验账号与演示账号统一收敛到 `sandbox@svc.plus`。
|
||||
- 体验与演示模式不得在公开 UI 或会话载荷中暴露其后端承载账号身份。
|
||||
- 为集成配置增加基于 vault 的 token 查询能力。
|
||||
|
||||
#### 文档与安装
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { SESSION_COOKIE_NAME, clearSessionCookie } from "@lib/authGateway";
|
||||
import { resolvePublicUserEmail } from "@lib/publicUserIdentity";
|
||||
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
|
||||
|
||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
|
||||
@ -212,10 +213,15 @@ export async function GET(request: NextRequest) {
|
||||
const normalizedUser = identifier
|
||||
? { ...rawUser, id: identifier, uuid: identifier }
|
||||
: rawUser;
|
||||
const publicEmail = resolvePublicUserEmail({
|
||||
email: normalizedUser.email,
|
||||
role: normalizedRole,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
...normalizedUser,
|
||||
email: publicEmail,
|
||||
mfaEnabled: derivedMfaEnabled,
|
||||
mfaPending: derivedMfaPending,
|
||||
mfa: normalizedMfa,
|
||||
|
||||
@ -50,7 +50,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
return NextResponse.json(payload, { status: response.status })
|
||||
} 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 })
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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' : '' })
|
||||
}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { hasPublicUserEmail } from "@lib/publicUserIdentity";
|
||||
import { useUserStore } from "@lib/userStore";
|
||||
import type { UserRole } from "@lib/userStore";
|
||||
import { useLanguage } from "@i18n/LanguageProvider";
|
||||
@ -56,7 +55,6 @@ export default function Header({
|
||||
isCollapsed,
|
||||
}: HeaderProps) {
|
||||
const { language } = useLanguage();
|
||||
const router = useRouter();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const isLoading = useUserStore((state) => state.isLoading);
|
||||
const role: UserRole = user?.role ?? "guest";
|
||||
@ -68,137 +66,10 @@ export default function Header({
|
||||
const badgeClasses = isLoading
|
||||
? "bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)] opacity-70"
|
||||
: badge.className;
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
const shouldRenderPublicEmail = hasPublicUserEmail(user?.email);
|
||||
|
||||
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">
|
||||
{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 gap-4">
|
||||
<button
|
||||
@ -251,10 +122,7 @@ export default function Header({
|
||||
<span className="text-[13px] font-semibold text-[var(--color-text)]">
|
||||
{accountLabel}
|
||||
</span>
|
||||
<span>
|
||||
{user?.email ??
|
||||
(isLoading ? "Checking session…" : "Not signed in")}
|
||||
</span>
|
||||
{shouldRenderPublicEmail ? <span>{user?.email}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -20,6 +20,7 @@ import { AskAIButton } from "./AskAIButton";
|
||||
import ReleaseChannelSelector, {
|
||||
ReleaseChannel,
|
||||
} from "./ReleaseChannelSelector";
|
||||
import { hasPublicUserEmail } from "@lib/publicUserIdentity";
|
||||
import { useUserStore } from "@lib/userStore";
|
||||
// import SearchComponent from './search'
|
||||
|
||||
@ -61,6 +62,7 @@ export default function Navbar() {
|
||||
user?.username?.charAt(0)?.toUpperCase() ??
|
||||
user?.email?.charAt(0)?.toUpperCase() ??
|
||||
"?";
|
||||
const shouldRenderPublicEmail = hasPublicUserEmail(user?.email);
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
const accountMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@ -534,7 +536,9 @@ export default function Navbar() {
|
||||
<p className="text-sm font-semibold text-text">
|
||||
{user.username}
|
||||
</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 className="py-1 text-sm text-text">
|
||||
<Link
|
||||
@ -648,9 +652,11 @@ export default function Navbar() {
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{user.username}
|
||||
</p>
|
||||
<p className="truncate text-xs text-text-muted">
|
||||
{user.email}
|
||||
</p>
|
||||
{shouldRenderPublicEmail ? (
|
||||
<p className="truncate text-xs text-text-muted">
|
||||
{user.email}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -9,6 +9,7 @@ import { translations } from "../i18n/translations";
|
||||
import LanguageToggle from "./LanguageToggle";
|
||||
// import { AskAIButton } from "./AskAIButton";
|
||||
import ReleaseChannelSelector from "./ReleaseChannelSelector";
|
||||
import { hasPublicUserEmail } from "@lib/publicUserIdentity";
|
||||
import { useUserStore } from "@lib/userStore";
|
||||
import { useMoltbotStore } from "@lib/moltbotStore";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
@ -47,6 +48,7 @@ export default function UnifiedNavigation() {
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
const accountMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const isChinese = language === "zh";
|
||||
const shouldRenderPublicEmail = hasPublicUserEmail(user?.email);
|
||||
|
||||
useEffect(() => {
|
||||
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">
|
||||
{user.username}
|
||||
</p>
|
||||
<p className="text-[12px] text-text-muted leading-none break-all">
|
||||
{user.email}
|
||||
</p>
|
||||
{shouldRenderPublicEmail ? (
|
||||
<p className="text-[12px] text-text-muted leading-none break-all">
|
||||
{user.email}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
|
||||
31
src/lib/publicUserIdentity.test.ts
Normal file
31
src/lib/publicUserIdentity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
src/lib/publicUserIdentity.ts
Normal file
19
src/lib/publicUserIdentity.ts
Normal 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;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { resolvePublicUserEmail } from '@lib/publicUserIdentity'
|
||||
|
||||
export type UserRole = 'guest' | 'user' | 'operator' | 'admin'
|
||||
|
||||
@ -65,8 +66,6 @@ const KNOWN_ROLE_MAP: Record<string, UserRole> = {
|
||||
member: 'user',
|
||||
}
|
||||
|
||||
const GUEST_SANDBOX_TENANT_ID = 'guest-sandbox'
|
||||
const GUEST_SANDBOX_TENANT_NAME = 'Guest Sandbox'
|
||||
function normalizeRole(input?: string | null): UserRole {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return 'guest'
|
||||
@ -153,6 +152,10 @@ async function fetchSessionUser(): Promise<User | null> {
|
||||
}
|
||||
|
||||
const normalizedRole = normalizeRole(role)
|
||||
const publicEmail = resolvePublicUserEmail({
|
||||
email,
|
||||
role: normalizedRole,
|
||||
})
|
||||
const rawRole = typeof role === 'string' ? role.trim().toLowerCase() : ''
|
||||
const normalizedGroups = Array.isArray(groups)
|
||||
? groups
|
||||
@ -218,9 +221,9 @@ async function fetchSessionUser(): Promise<User | null> {
|
||||
uuid: identifier,
|
||||
proxyUuid: normalizedProxyUuid,
|
||||
proxyUuidExpiresAt: normalizedProxyUuidExpiresAt,
|
||||
email,
|
||||
email: publicEmail,
|
||||
name: normalizedName,
|
||||
username: normalizedUsername ?? email,
|
||||
username: normalizedUsername ?? publicEmail,
|
||||
mfaEnabled: Boolean(mfaEnabled ?? mfa?.totpEnabled),
|
||||
mfaPending: Boolean(mfaPending ?? mfa?.totpPending) && !Boolean(mfaEnabled ?? mfa?.totpEnabled),
|
||||
mfa: normalizedMfa,
|
||||
|
||||
@ -7,8 +7,9 @@ import { Copy } from 'lucide-react'
|
||||
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import { hasPublicUserEmail } from '@lib/publicUserIdentity'
|
||||
import { useUserStore } from '@lib/userStore'
|
||||
import { fetchSandboxNodeBinding } from '../lib/sandboxNodeBinding'
|
||||
import { fetchGuestNodeBinding } from '../lib/guestNodeBinding'
|
||||
|
||||
import Card from './Card'
|
||||
import VlessQrCard from './VlessQrCard'
|
||||
@ -49,13 +50,13 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
|
||||
const refresh = useUserStore((state) => state.refresh)
|
||||
const logout = useUserStore((state) => state.logout)
|
||||
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 uuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? '—'
|
||||
const vlessUuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? null
|
||||
const username = user?.username ?? '—'
|
||||
const email = user?.email ?? '—'
|
||||
const email = hasPublicUserEmail(user?.email) ? user?.email : '—'
|
||||
const docsUrl = mfaCopy.actions.docsUrl
|
||||
const isGuestSandboxReadOnly = Boolean(user?.isGuest && user?.isReadOnly)
|
||||
const guestUuidExpiresAtText = useMemo(() => {
|
||||
@ -121,16 +122,16 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGuestSandboxReadOnly) {
|
||||
setSandboxBoundNodeAddress(null)
|
||||
setGuestBoundNodeAddress(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const binding = await fetchSandboxNodeBinding()
|
||||
const binding = await fetchGuestNodeBinding()
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
setSandboxBoundNodeAddress(binding?.address ?? null)
|
||||
setGuestBoundNodeAddress(binding?.address ?? null)
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
@ -236,8 +237,8 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
|
||||
<VlessQrCard
|
||||
uuid={vlessUuid}
|
||||
copy={copy.cards.vless}
|
||||
allowSandboxFallbackNode={isGuestSandboxReadOnly}
|
||||
boundNodeAddress={sandboxBoundNodeAddress}
|
||||
allowGuestReadOnlyFallbackNode={isGuestSandboxReadOnly}
|
||||
boundNodeAddress={guestBoundNodeAddress}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
|
||||
@ -33,7 +33,7 @@ export type VlessQrCopy = {
|
||||
interface VlessQrCardProps {
|
||||
uuid: string | null | undefined
|
||||
copy: VlessQrCopy
|
||||
allowSandboxFallbackNode?: boolean
|
||||
allowGuestReadOnlyFallbackNode?: boolean
|
||||
boundNodeAddress?: string | null
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ interface VlessQrCardProps {
|
||||
export default function VlessQrCard({
|
||||
uuid,
|
||||
copy,
|
||||
allowSandboxFallbackNode = false,
|
||||
allowGuestReadOnlyFallbackNode = false,
|
||||
boundNodeAddress,
|
||||
}: VlessQrCardProps) {
|
||||
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()
|
||||
if (!address || address === '*') return false
|
||||
|
||||
if (allowSandboxFallbackNode) {
|
||||
// In sandbox mode, allow internal agents so the user can see their bound node
|
||||
// even if it belongs to the shared token bucket.
|
||||
if (allowGuestReadOnlyFallbackNode) {
|
||||
// Guest read-only mode still needs to expose the bound node, even when it
|
||||
// would otherwise be filtered out as an internal shared node.
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip the redundant Internal Agents (Shared Token) node
|
||||
return !(name.includes('internal agents') && name.includes('shared token'))
|
||||
})
|
||||
}, [allNodes, allowSandboxFallbackNode])
|
||||
}, [allNodes, allowGuestReadOnlyFallbackNode])
|
||||
const [selectedNode, setSelectedNode] = useState<VlessNode | null>(null)
|
||||
const [preferredTransport, setPreferredTransport] = useState<VlessTransport>('tcp')
|
||||
const [isSelectorOpen, setIsSelectorOpen] = useState(false)
|
||||
@ -81,10 +81,11 @@ export default function VlessQrCard({
|
||||
return matched
|
||||
}
|
||||
|
||||
// If we are in sandbox mode and API failed or node not found in list, create a synthetic fallback
|
||||
if (allowSandboxFallbackNode) {
|
||||
// If the guest read-only binding points to a node that is not present in the
|
||||
// live list, synthesize a minimal fallback so QR generation still works.
|
||||
if (allowGuestReadOnlyFallbackNode) {
|
||||
return {
|
||||
name: 'Sandbox Node',
|
||||
name: 'Guest Node',
|
||||
address: boundNodeAddress,
|
||||
port: 443,
|
||||
transport: 'tcp',
|
||||
@ -102,7 +103,7 @@ export default function VlessQrCard({
|
||||
|
||||
// 3. No fallback node
|
||||
return undefined
|
||||
}, [allNodes, allowSandboxFallbackNode, boundNodeAddress, nodes, selectedNode])
|
||||
}, [allNodes, allowGuestReadOnlyFallbackNode, boundNodeAddress, nodes, selectedNode])
|
||||
|
||||
const effectiveNode = useMemo((): VlessNode | 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)]">
|
||||
<p className="font-semibold">❌ 运行节点配置缺失</p>
|
||||
<p className="mt-1">
|
||||
{allowSandboxFallbackNode
|
||||
? '演示模式账号未发现有效的节点映射。请确认后端已完成 Sandbox 节点绑定逻辑。'
|
||||
{allowGuestReadOnlyFallbackNode
|
||||
? '演示模式账号未发现有效的节点映射。请确认后端已完成 guest 节点绑定逻辑。'
|
||||
: `无法从服务器获取代理节点列表${nodesError ? `(${nodesError.message})` : ''}。请检查 API 接口是否正常。`
|
||||
}
|
||||
</p>
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
'use client'
|
||||
|
||||
export type SandboxNodeBinding = {
|
||||
export type GuestNodeBinding = {
|
||||
address: string
|
||||
name?: string
|
||||
updatedAt: number
|
||||
updatedBy?: string
|
||||
}
|
||||
|
||||
export async function fetchSandboxNodeBinding(): Promise<SandboxNodeBinding | null> {
|
||||
export async function fetchGuestNodeBinding(): Promise<GuestNodeBinding | null> {
|
||||
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) {
|
||||
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,
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch sandbox node binding', error)
|
||||
console.warn('Failed to fetch guest node binding', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -9,7 +9,7 @@ import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
import { useUserStore } from '@lib/userStore'
|
||||
import { fetchAgentNodes } from '../lib/fetchAgentNodes'
|
||||
import { fetchSandboxNodeBinding } from '../lib/sandboxNodeBinding'
|
||||
import { fetchGuestNodeBinding } from '../lib/guestNodeBinding'
|
||||
|
||||
|
||||
interface VlessNode {
|
||||
@ -46,8 +46,8 @@ export default function UserCenterAgentRoute() {
|
||||
const visibleNodes = useMemo(() => {
|
||||
return (nodes ?? []).filter((node) => {
|
||||
if (isGuestSandboxReadOnly) {
|
||||
// In sandbox mode, allow internal agents so the user can see their bound node
|
||||
// even if it belongs to the shared token bucket.
|
||||
// Guest read-only mode still needs to expose the bound node even when the
|
||||
// node belongs to the shared internal pool.
|
||||
const address = (node.address || '').trim()
|
||||
return address && address !== '*'
|
||||
}
|
||||
@ -64,7 +64,7 @@ export default function UserCenterAgentRoute() {
|
||||
}
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const binding = await fetchSandboxNodeBinding()
|
||||
const binding = await fetchGuestNodeBinding()
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
@ -74,7 +74,7 @@ export default function UserCenterAgentRoute() {
|
||||
return
|
||||
}
|
||||
setBoundNode({
|
||||
name: binding.name || 'Sandbox Node',
|
||||
name: binding.name || 'Guest Node',
|
||||
address: binding.address,
|
||||
port: 443,
|
||||
transport: 'tcp',
|
||||
@ -90,7 +90,7 @@ export default function UserCenterAgentRoute() {
|
||||
// Default behavior: show all displayable nodes.
|
||||
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.
|
||||
if (isGuestSandboxReadOnly && 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) {
|
||||
return [boundNode]
|
||||
}
|
||||
|
||||
@ -17,8 +17,6 @@ import UserGroupManagement, {
|
||||
type ManagedUser,
|
||||
type CreateManagedUserInput,
|
||||
} from "../management/components/UserGroupManagement";
|
||||
import SandboxNodeBindingPanel from "../management/components/SandboxNodeBindingPanel";
|
||||
import RootAssumeSandboxPanel from "../management/components/RootAssumeSandboxPanel";
|
||||
import HomepageVideoSettingsPanel from "../management/components/HomepageVideoSettingsPanel";
|
||||
import { EmailBlacklist } from "../management/components/EmailBlacklist";
|
||||
import Breadcrumbs from "@/app/panel/components/Breadcrumbs";
|
||||
@ -518,12 +516,6 @@ export default function UserCenterManagementRoute() {
|
||||
onCreateCustomUser={handleCreateCustomUser}
|
||||
onManageBlacklist={() => setIsBlacklistOpen(true)}
|
||||
/>
|
||||
{canCreateCustomUser ? (
|
||||
<>
|
||||
<RootAssumeSandboxPanel />
|
||||
<SandboxNodeBindingPanel />
|
||||
</>
|
||||
) : null}
|
||||
<EmailBlacklist
|
||||
isOpen={isBlacklistOpen}
|
||||
onClose={() => setIsBlacklistOpen(false)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user