diff --git a/docs/architecture/web-console/overview.md b/docs/architecture/web-console/overview.md index 3e21f42..80fc9b8 100644 --- a/docs/architecture/web-console/overview.md +++ b/docs/architecture/web-console/overview.md @@ -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 | diff --git a/docs/governance/release-process.md b/docs/governance/release-process.md index 3ced766..bdd030d 100644 --- a/docs/governance/release-process.md +++ b/docs/governance/release-process.md @@ -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 diff --git a/docs/zh/governance/release-process.md b/docs/zh/governance/release-process.md index af40544..eb812d6 100644 --- a/docs/zh/governance/release-process.md +++ b/docs/zh/governance/release-process.md @@ -33,7 +33,7 @@ - 将 observability 工作区拆分为 tri-view,并优化 panel 助手路由。 - 统一导航结构与持久化 AI sidebar 行为。 - 登录与注册流程改为使用服务端解析后的 account service URL。 -- 体验账号与演示账号统一收敛到 `sandbox@svc.plus`。 +- 体验与演示模式不得在公开 UI 或会话载荷中暴露其后端承载账号身份。 - 为集成配置增加基于 vault 的 token 查询能力。 #### 文档与安装 diff --git a/src/app/api/admin/sandbox/[...segments]/route.ts b/src/app/api/admin/sandbox/[...segments]/route.ts deleted file mode 100644 index 798c935..0000000 --- a/src/app/api/admin/sandbox/[...segments]/route.ts +++ /dev/null @@ -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( - { 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( - { error: "forbidden" }, - { status: 403 }, - ); - } - } else { - if (!(await userHasRoleOrPermission(user, READ_ROLES, READ_PERMISSIONS))) { - return NextResponse.json( - { 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( - { error: "invalid_response" }, - { status: 502 }, - ); - } - - return NextResponse.json(payload, { status: response.status }); - } catch (err: any) { - return NextResponse.json( - { 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); -} diff --git a/src/app/api/admin/sandbox/bind/route.ts b/src/app/api/admin/sandbox/bind/route.ts deleted file mode 100644 index cdc58da..0000000 --- a/src/app/api/admin/sandbox/bind/route.ts +++ /dev/null @@ -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({ error: 'unauthenticated' }, { status: 401 }) - } - - const access = await evaluateAccountAdminAccess(user, { - roles: REQUIRED_ROLES, - permissions: WRITE_PERMISSIONS, - rootOnly: true, - }) - if (!access.allowed) { - return NextResponse.json({ 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({ 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({ error: 'upstream_unreachable' }, { status: 502 }) - } -} diff --git a/src/app/api/admin/sandbox/binding/route.ts b/src/app/api/admin/sandbox/binding/route.ts deleted file mode 100644 index d4722c6..0000000 --- a/src/app/api/admin/sandbox/binding/route.ts +++ /dev/null @@ -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({ error: 'unauthenticated' }, { status: 401 }) - } - - const access = await evaluateAccountAdminAccess(user, { - roles: REQUIRED_ROLES, - permissions: READ_PERMISSIONS, - rootOnly: true, - }) - if (!access.allowed) { - return NextResponse.json({ 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({ 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({ error: 'upstream_unreachable' }, { status: 502 }) - } -} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 0b61ced..8c0b17e 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -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, diff --git a/src/app/api/sandbox/binding/route.ts b/src/app/api/guest/binding/route.ts similarity index 96% rename from src/app/api/sandbox/binding/route.ts rename to src/app/api/guest/binding/route.ts index 4fa9993..7adc6e8 100644 --- a/src/app/api/sandbox/binding/route.ts +++ b/src/app/api/guest/binding/route.ts @@ -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({ error: 'upstream_unreachable' }, { status: 502 }) } } diff --git a/src/app/api/sandbox/assume/revert/route.ts b/src/app/api/sandbox/assume/revert/route.ts deleted file mode 100644 index e9c9d0b..0000000 --- a/src/app/api/sandbox/assume/revert/route.ts +++ /dev/null @@ -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 { - 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( - { error: "not_assuming" }, - { status: 400 }, - ); - } - - if (!(await verifyRootToken(rootToken))) { - return NextResponse.json( - { 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; -} diff --git a/src/app/api/sandbox/assume/route.ts b/src/app/api/sandbox/assume/route.ts deleted file mode 100644 index d1594dc..0000000 --- a/src/app/api/sandbox/assume/route.ts +++ /dev/null @@ -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( - { error: "unauthenticated" }, - { status: 401 }, - ); - } - - const access = await evaluateAccountAdminAccess(user, { - roles: REQUIRED_ROLES, - permissions: WRITE_PERMISSIONS, - rootOnly: true, - }); - if (!access.allowed) { - return NextResponse.json( - { 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( - { 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( - { error: "upstream_unreachable" }, - { status: 502 }, - ); - } -} diff --git a/src/app/api/sandbox/assume/status/route.ts b/src/app/api/sandbox/assume/status/route.ts deleted file mode 100644 index 2c11fd5..0000000 --- a/src/app/api/sandbox/assume/status/route.ts +++ /dev/null @@ -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' : '' }) -} - diff --git a/src/app/panel/components/Header.tsx b/src/app/panel/components/Header.tsx index 9a996e7..b51296e 100644 --- a/src/app/panel/components/Header.tsx +++ b/src/app/panel/components/Header.tsx @@ -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 (
- {assumeStatus.isAssuming ? ( -
-
- {language === "zh" - ? `当前处于 Assume: ${assumeStatus.target || "sandbox@svc.plus"}(只读视角)` - : `Assuming: ${assumeStatus.target || "sandbox@svc.plus"} (read-only view)`} -
- -
- ) : ( -
- {isRoot ? ( - - ) : null} -
- )} -
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 0a18deb..df38bb1 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -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(null); @@ -534,7 +536,9 @@ export default function Navbar() {

{user.username}

-

{user.email}

+ {shouldRenderPublicEmail ? ( +

{user.email}

+ ) : null}
{user.username}

-

- {user.email} -

+ {shouldRenderPublicEmail ? ( +

+ {user.email} +

+ ) : null}
diff --git a/src/components/UnifiedNavigation.tsx b/src/components/UnifiedNavigation.tsx index 2a8852e..8872384 100644 --- a/src/components/UnifiedNavigation.tsx +++ b/src/components/UnifiedNavigation.tsx @@ -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(null); const isChinese = language === "zh"; + const shouldRenderPublicEmail = hasPublicUserEmail(user?.email); useEffect(() => { if (typeof window === "undefined") return; @@ -356,9 +358,11 @@ export default function UnifiedNavigation() {

{user.username}

-

- {user.email} -

+ {shouldRenderPublicEmail ? ( +

+ {user.email} +

+ ) : null}
diff --git a/src/lib/publicUserIdentity.test.ts b/src/lib/publicUserIdentity.test.ts new file mode 100644 index 0000000..1dd88c9 --- /dev/null +++ b/src/lib/publicUserIdentity.test.ts @@ -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); + }); +}); diff --git a/src/lib/publicUserIdentity.ts b/src/lib/publicUserIdentity.ts new file mode 100644 index 0000000..c4df8f7 --- /dev/null +++ b/src/lib/publicUserIdentity.ts @@ -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; +} diff --git a/src/lib/userStore.ts b/src/lib/userStore.ts index 02eaaef..fb0e81c 100644 --- a/src/lib/userStore.ts +++ b/src/lib/userStore.ts @@ -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 = { 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 { } 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 { 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, diff --git a/src/modules/extensions/builtin/user-center/components/UserOverview.tsx b/src/modules/extensions/builtin/user-center/components/UserOverview.tsx index 6a12667..8822c12 100644 --- a/src/modules/extensions/builtin/user-center/components/UserOverview.tsx +++ b/src/modules/extensions/builtin/user-center/components/UserOverview.tsx @@ -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(null) + const [guestBoundNodeAddress, setGuestBoundNodeAddress] = useState(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 diff --git a/src/modules/extensions/builtin/user-center/components/VlessQrCard.tsx b/src/modules/extensions/builtin/user-center/components/VlessQrCard.tsx index 7227f0d..74720c6 100644 --- a/src/modules/extensions/builtin/user-center/components/VlessQrCard.tsx +++ b/src/modules/extensions/builtin/user-center/components/VlessQrCard.tsx @@ -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('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(null) const [preferredTransport, setPreferredTransport] = useState('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({

❌ 运行节点配置缺失

- {allowSandboxFallbackNode - ? '演示模式账号未发现有效的节点映射。请确认后端已完成 Sandbox 节点绑定逻辑。' + {allowGuestReadOnlyFallbackNode + ? '演示模式账号未发现有效的节点映射。请确认后端已完成 guest 节点绑定逻辑。' : `无法从服务器获取代理节点列表${nodesError ? `(${nodesError.message})` : ''}。请检查 API 接口是否正常。` }

diff --git a/src/modules/extensions/builtin/user-center/lib/sandboxNodeBinding.ts b/src/modules/extensions/builtin/user-center/lib/guestNodeBinding.ts similarity index 74% rename from src/modules/extensions/builtin/user-center/lib/sandboxNodeBinding.ts rename to src/modules/extensions/builtin/user-center/lib/guestNodeBinding.ts index b91f505..12fd454 100644 --- a/src/modules/extensions/builtin/user-center/lib/sandboxNodeBinding.ts +++ b/src/modules/extensions/builtin/user-center/lib/guestNodeBinding.ts @@ -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 { +export async function fetchGuestNodeBinding(): Promise { 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 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 } } diff --git a/src/modules/extensions/builtin/user-center/management/components/RootAssumeSandboxPanel.tsx b/src/modules/extensions/builtin/user-center/management/components/RootAssumeSandboxPanel.tsx deleted file mode 100644 index cc4b6cb..0000000 --- a/src/modules/extensions/builtin/user-center/management/components/RootAssumeSandboxPanel.tsx +++ /dev/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 { - const res = await fetch('/api/sandbox/assume/status', { method: 'GET', cache: 'no-store', credentials: 'include' }) - const payload = (await res.json().catch(() => null)) as any - return { - isAssuming: Boolean(payload?.isAssuming), - target: typeof payload?.target === 'string' ? payload.target : undefined, - } -} - -export default function RootAssumeSandboxPanel() { - const user = useUserStore((state) => state.user) - - const isRoot = useMemo(() => { - const email = user?.email?.trim().toLowerCase() ?? '' - return email === 'admin@svc.plus' && Boolean(user?.isAdmin) - }, [user?.email, user?.isAdmin]) - - const [status, setStatus] = useState({ isAssuming: false }) - const [isLoading, setIsLoading] = useState(true) - const [isBusy, setIsBusy] = useState(false) - const [message, setMessage] = useState(null) - - // Future-proof: allowlist targets can be extended later. - const [draftTarget, setDraftTarget] = useState(SANDBOX_EMAIL) - - // Two-step confirmation (select -> confirm apply). - const [pendingConfirm, setPendingConfirm] = useState(false) - - useEffect(() => { - let cancelled = false - void (async () => { - try { - setIsLoading(true) - const next = await fetchAssumeStatus() - if (cancelled) return - setStatus(next) - } catch (error) { - if (cancelled) return - setStatus({ isAssuming: false }) - } finally { - if (cancelled) return - setIsLoading(false) - } - })() - return () => { - cancelled = true - } - }, []) - - const handleAssume = async () => { - if (!isRoot || isBusy) return - try { - setIsBusy(true) - setMessage(null) - const res = await fetch('/api/sandbox/assume', { method: 'POST', cache: 'no-store', credentials: 'include' }) - if (!res.ok) { - const payload = (await res.json().catch(() => null)) as any - throw new Error((payload && (payload.message || payload.error)) || `Assume failed (${res.status})`) - } - setMessage('已切换到 Sandbox 视角(只读)') - // Hard refresh ensures the new xc_session cookie is used everywhere immediately. - window.location.reload() - } catch (error) { - setMessage(`错误:${error instanceof Error ? error.message : '切换失败'}`) - } finally { - setIsBusy(false) - } - } - - const handleRevert = async () => { - if (!isRoot || isBusy) return - try { - setIsBusy(true) - setMessage(null) - const res = await fetch('/api/sandbox/assume/revert', { method: 'POST', cache: 'no-store', credentials: 'include' }) - if (!res.ok) { - const payload = (await res.json().catch(() => null)) as any - throw new Error((payload && (payload.message || payload.error)) || `Revert failed (${res.status})`) - } - setMessage('已退出 Sandbox,恢复 Root 会话') - window.location.reload() - } catch (error) { - setMessage(`错误:${error instanceof Error ? error.message : '退出失败'}`) - } finally { - setIsBusy(false) - } - } - - const confirmLabel = status.isAssuming ? '确认退出' : '确认切换' - const primaryDisabled = !isRoot || isBusy || isLoading - - return ( - -
-
-

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

-

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

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

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

-
- - {message ? ( -

- {message} -

- ) : null} -
- - ) -} - diff --git a/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx b/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx deleted file mode 100644 index 6c30736..0000000 --- a/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx +++ /dev/null @@ -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('user-center-agent-nodes', fetchAgentNodes, { - revalidateOnFocus: false, - }) - const [message, setMessage] = useState(null) - - const [activeBinding, setActiveBinding] = useState<{ address: string; updatedAt?: number } | null>(null) - const [draftAddress, setDraftAddress] = useState('') - 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 ( - -
-
-

Root 管理员专用:Sandbox Node 绑定节点

-

选择并“确认应用”后,Sandbox@svc.plus 将固定使用该节点生成配置。

-
- -
- - - -
- -
- {currentActive ? ( -
-
- 当前活跃绑定:{currentActive.address} -
- ) : ( -
-
- 当前未绑定任何节点 -
- )} - {currentActive?.updatedAt ? ( -

- 最后更新时间:{new Date(currentActive.updatedAt).toLocaleString()} -

- ) : null} -
- - {error &&

⚠️ 节点列表加载失败:{error.message}

} - {message && ( -

- {message} -

- )} -
- - ) -} diff --git a/src/modules/extensions/builtin/user-center/routes/agent.tsx b/src/modules/extensions/builtin/user-center/routes/agent.tsx index e44abdb..7051efa 100644 --- a/src/modules/extensions/builtin/user-center/routes/agent.tsx +++ b/src/modules/extensions/builtin/user-center/routes/agent.tsx @@ -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] } diff --git a/src/modules/extensions/builtin/user-center/routes/management.tsx b/src/modules/extensions/builtin/user-center/routes/management.tsx index af49c98..8d10fb3 100644 --- a/src/modules/extensions/builtin/user-center/routes/management.tsx +++ b/src/modules/extensions/builtin/user-center/routes/management.tsx @@ -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 ? ( - <> - - - - ) : null} setIsBlacklistOpen(false)}