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]"]
|
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 |
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 查询能力。
|
||||||
|
|
||||||
#### 文档与安装
|
#### 文档与安装
|
||||||
|
|||||||
@ -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 { 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,
|
||||||
|
|||||||
@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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";
|
"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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
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'
|
'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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 { 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]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user