diff --git a/src/app/api/auth/session/route.test.ts b/src/app/api/auth/session/route.test.ts new file mode 100644 index 0000000..23b968c --- /dev/null +++ b/src/app/api/auth/session/route.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment node + +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const cookiesMock = vi.hoisted(() => vi.fn()); +const ORIGINAL_ENV = { ...process.env }; + +vi.mock("next/headers", () => ({ + cookies: cookiesMock, +})); + +describe("/api/auth/session", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + cookiesMock.mockReset(); + process.env = { ...ORIGINAL_ENV }; + process.env.ACCOUNT_SERVICE_URL = "https://accounts.svc.plus"; + }); + + afterAll(() => { + vi.unstubAllGlobals(); + process.env = ORIGINAL_ENV; + }); + + it("drops guest sessions instead of exposing them as authenticated users", async () => { + cookiesMock.mockResolvedValue({ + get(name: string) { + if (name === "xc_session") { + return { value: "guest-session-token" }; + } + return undefined; + }, + }); + + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + user: { + id: "guest-1", + email: "guest@svc.plus", + role: "guest", + username: "guest", + }, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const { GET } = await import("./route"); + const request = new NextRequest("https://console.svc.plus/api/auth/session", { + headers: { + host: "console.svc.plus", + }, + }); + + const response = await GET(request); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ user: null }); + }); +}); diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 8c0b17e..effee78 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -10,8 +10,6 @@ const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); type AccountUser = { id?: string; uuid?: string; - proxyUuid?: string; - proxyUuidExpiresAt?: string; name?: string; username?: string; email: string; @@ -41,13 +39,15 @@ type SessionResponse = { error?: string; }; -function normalizeRole(role: unknown): string { +type AuthenticatedRole = "user" | "operator" | "admin"; + +function normalizeRole(role: unknown): AuthenticatedRole | null { if (typeof role !== "string") { - return "user"; + return null; } const normalized = role.trim().toLowerCase(); if (!normalized) { - return "user"; + return null; } if (normalized === "root" || normalized === "super_admin") { return "admin"; @@ -55,7 +55,14 @@ function normalizeRole(role: unknown): string { if (normalized === "readonly" || normalized === "read_only") { return "user"; } - return normalized; + if ( + normalized === "user" || + normalized === "operator" || + normalized === "admin" + ) { + return normalized; + } + return null; } async function fetchSession(token: string, requestHost?: string | null) { @@ -113,6 +120,11 @@ export async function GET(request: NextRequest) { const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled; const normalizedRole = normalizeRole(rawUser.role); + if (!normalizedRole) { + const response = NextResponse.json({ user: null }); + clearSessionCookie(response, requestHost ?? undefined); + return response; + } const rawRole = typeof rawUser.role === "string" ? rawUser.role.trim().toLowerCase() : ""; const normalizedGroups = Array.isArray(rawUser.groups) @@ -143,15 +155,6 @@ export async function GET(request: NextRequest) { normalizedGroups.some((group) => group.toLowerCase() === "readonly role") || rawRole === "readonly" || rawRole === "read_only"; - const normalizedProxyUuid = - typeof rawUser.proxyUuid === "string" && rawUser.proxyUuid.trim().length > 0 - ? rawUser.proxyUuid.trim() - : undefined; - const normalizedProxyUuidExpiresAt = - typeof rawUser.proxyUuidExpiresAt === "string" && - rawUser.proxyUuidExpiresAt.trim().length > 0 - ? rawUser.proxyUuidExpiresAt.trim() - : undefined; const normalizedTenantId = typeof rawUser.tenantId === "string" && rawUser.tenantId.trim().length > 0 @@ -229,8 +232,6 @@ export async function GET(request: NextRequest) { groups: normalizedGroups, permissions: normalizedPermissions, readOnly: normalizedReadOnly, - proxyUuid: normalizedProxyUuid, - proxyUuidExpiresAt: normalizedProxyUuidExpiresAt, tenantId: normalizedTenantId, tenants: normalizedTenants, }, diff --git a/src/app/api/guest/binding/route.ts b/src/app/api/guest/binding/route.ts deleted file mode 100644 index 7adc6e8..0000000 --- a/src/app/api/guest/binding/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -export const dynamic = 'force-dynamic' - -import { NextRequest, NextResponse } from 'next/server' - -import { getAccountServiceApiBaseUrl } from '@server/serviceConfig' -import { getAccountSession } from '@server/account/session' -import { buildInternalServiceHeaders, isServiceTokenConfigured } from '@server/internalServiceAuth' - -const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() - -type ErrorPayload = { - error: string -} - -export async function GET(request: NextRequest) { - const session = await getAccountSession(request) - const canUseInternalToken = isServiceTokenConfigured() - if (!session.token && !canUseInternalToken) { - return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }) - } - - try { - const headers = session.token - ? new Headers({ - Authorization: `Bearer ${session.token}`, - Accept: 'application/json', - }) - : buildInternalServiceHeaders({ - Accept: 'application/json', - }) - - const response = await fetch(`${ACCOUNT_API_BASE}/sandbox/binding`, { - method: 'GET', - headers, - 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 guest node binding', error) - return NextResponse.json({ error: 'upstream_unreachable' }, { status: 502 }) - } -} diff --git a/src/app/panel/components/Header.tsx b/src/app/panel/components/Header.tsx index 150611a..ba70df7 100644 --- a/src/app/panel/components/Header.tsx +++ b/src/app/panel/components/Header.tsx @@ -10,10 +10,6 @@ import { useLanguage } from "@i18n/LanguageProvider"; import { translations } from "@i18n/translations"; const ROLE_BADGES: Record = { - guest: { - label: "Guest", - className: "bg-[var(--color-badge-muted)] text-[var(--color-text-subtle)]", - }, user: { label: "User", className: @@ -55,9 +51,10 @@ export default function Header({ isCollapsed, }: HeaderProps) { const { language } = useLanguage(); + const navCopy = translations[language].nav.account; const user = useUserStore((state) => state.user); const isLoading = useUserStore((state) => state.isLoading); - const role: UserRole = user?.role ?? "guest"; + const role: UserRole = user?.role ?? "user"; const badge = ROLE_BADGES[role]; const shouldRenderPublicEmail = hasPublicUserEmail({ email: user?.email, @@ -67,7 +64,7 @@ export default function Header({ user?.name ?? user?.username ?? (shouldRenderPublicEmail ? user?.email : undefined) ?? - "Guest user"; + navCopy.title; const accountInitial = resolveAccountInitial(accountLabel); const statusBadge = isLoading ? "Syncing" : badge.label; const badgeClasses = isLoading diff --git a/src/components/openclaw/OpenClawAssistantPane.tsx b/src/components/openclaw/OpenClawAssistantPane.tsx index 8960ad0..9d71eb3 100644 --- a/src/components/openclaw/OpenClawAssistantPane.tsx +++ b/src/components/openclaw/OpenClawAssistantPane.tsx @@ -93,7 +93,6 @@ type PersistedPairingRequiredLookup = { const PAIRING_REQUIRED_SESSION_STORAGE_KEY = "openclaw:pairing-required-state"; const PAIRING_REQUIRED_STATE_TTL_MS = 1000 * 60 * 60 * 12; -const PAIRING_REQUIRED_GUEST_TTL_MS = 1000 * 60 * 60; export type OpenClawAssistantViewState = { connectionState: ConnectionState; @@ -436,7 +435,6 @@ export function OpenClawAssistantPane({ const [errorMessage, setErrorMessage] = useState(""); const [isSending, setIsSending] = useState(false); const [isCapturing, setIsCapturing] = useState(false); - const [guestSessionExpired, setGuestSessionExpired] = useState(false); const defaultsLoaded = useOpenClawConsoleStore( (state) => state.defaultsLoaded, @@ -482,9 +480,7 @@ export function OpenClawAssistantPane({ const minimalPage = variant === "page"; const pairingPersistenceUserId = sessionUser?.uuid?.trim() || sessionUser?.id?.trim() || "anonymous"; - const pairingPersistenceTtlMs = sessionUser?.isGuest - ? PAIRING_REQUIRED_GUEST_TTL_MS - : PAIRING_REQUIRED_STATE_TTL_MS; + const pairingPersistenceTtlMs = PAIRING_REQUIRED_STATE_TTL_MS; const pairingPersistenceScope = buildPairingPersistenceScope({ openclawUrl, openclawOrigin, @@ -572,13 +568,6 @@ export function OpenClawAssistantPane({ "当前没有可用的 OpenClaw 地址。先到融合设置填写 gateway / vault / APISIX,再回来启动 XWorkmate。", "No OpenClaw endpoint is available yet. Configure gateway, vault, and APISIX first, then return to XWorkmate.", ), - guestSessionExpired: pickCopy( - isChinese, - "演示模式已超过 1 小时。请注册或登录后继续使用助手。", - "Demo mode has exceeded 1 hour. Register or sign in to continue using the assistant.", - ), - login: pickCopy(isChinese, "登录", "Sign in"), - register: pickCopy(isChinese, "注册", "Register"), openIntegrations: pickCopy( isChinese, "打开接口集成", @@ -724,7 +713,6 @@ export function OpenClawAssistantPane({ return; } lastPairingRequiredSignatureRef.current = signature; - setGuestSessionExpired(false); persistPairingRequiredState({ signature, errorMessage: formattedMessage, @@ -1059,23 +1047,14 @@ export function OpenClawAssistantPane({ if (persisted.state) { lastPairingRequiredSignatureRef.current = persisted.state.signature; lastConnectPairingSignatureRef.current = persisted.state.signature; - setGuestSessionExpired(false); setConnectionState("error"); setErrorMessage(persisted.state.errorMessage); - return; - } - - if (sessionUser?.isGuest && persisted.expired) { lastPairingRequiredSignatureRef.current = null; - lastConnectPairingSignatureRef.current = "guest-session-expired"; - setGuestSessionExpired(true); - setConnectionState("error"); - setErrorMessage(copy.guestSessionExpired); } - }, [copy.guestSessionExpired, pairingPersistenceScope, sessionUser?.isGuest]); + }, [pairingPersistenceScope]); useEffect(() => { - if (!defaultsLoaded || bootstrappedRef.current || guestSessionExpired) { + if (!defaultsLoaded || bootstrappedRef.current) { return; } @@ -1088,7 +1067,6 @@ export function OpenClawAssistantPane({ }, [ connectGateway, defaultsLoaded, - guestSessionExpired, initialSessionKey, openclawUrl, ]); @@ -1096,7 +1074,6 @@ export function OpenClawAssistantPane({ useEffect(() => { lastConnectPairingSignatureRef.current = null; lastPairingRequiredSignatureRef.current = null; - setGuestSessionExpired(false); }, [ openclawOrigin, openclawToken, @@ -1553,24 +1530,6 @@ export function OpenClawAssistantPane({
{errorMessage}
- {guestSessionExpired ? ( -
- - -
- ) : null} ) : null} diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 0eb1c1f..a492cba 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -544,7 +544,6 @@ export type Translation = { title: string register: string login: string - demo: string welcome: string logout: string userCenter: string @@ -668,7 +667,6 @@ export const translations: Record<'en' | 'zh', Translation> = { title: 'Account', register: 'Register', login: 'Login', - demo: 'Guest user(演示模式)', welcome: 'Welcome, {username}', logout: 'Sign out', userCenter: 'User Center', @@ -711,7 +709,7 @@ export const translations: Record<'en' | 'zh', Translation> = { userNotFound: 'We could not find an account with that username.', genericError: 'We could not sign you in. Please try again later.', serviceUnavailable: 'The account service is temporarily unavailable. Please try again shortly.', - disclaimer: 'This Guest user(演示模式) login keeps your username in memory only to personalize navigation while you browse.', + disclaimer: 'Your session stays on this device only and is used solely to keep the console signed in while you browse.', }, termsTitle: 'Terms of Service', termsPoints: [ @@ -1482,7 +1480,6 @@ export const translations: Record<'en' | 'zh', Translation> = { title: '账户', register: '注册', login: '登录', - demo: 'Guest user(演示模式)', welcome: '欢迎,{username}', logout: '退出登录', userCenter: '用户中心', @@ -1525,7 +1522,7 @@ export const translations: Record<'en' | 'zh', Translation> = { userNotFound: '未找到该用户名对应的账户。', genericError: '登录失败,请稍后再试。', serviceUnavailable: '账户服务暂时不可用,请稍后再试。', - disclaimer: '此 Guest user(演示模式) 登录仅会在浏览期间保留用户名,以便展示个性化的导航体验。', + disclaimer: '登录态仅保存在当前设备,用于在浏览期间保持控制台会话。', }, termsTitle: '服务条款', termsPoints: [ diff --git a/src/lib/accessControl.test.ts b/src/lib/accessControl.test.ts new file mode 100644 index 0000000..13327c5 --- /dev/null +++ b/src/lib/accessControl.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAccess } from "@lib/accessControl"; + +describe("accessControl", () => { + it("blocks unauthenticated access when login is required", () => { + expect( + resolveAccess(null, { + requireLogin: true, + }), + ).toMatchObject({ + allowed: false, + reason: "unauthenticated", + }); + }); + + it("allows anonymous access only when guests are explicitly allowed", () => { + expect( + resolveAccess(null, { + allowGuests: true, + }), + ).toMatchObject({ + allowed: true, + }); + }); +}); diff --git a/src/lib/accessControl.ts b/src/lib/accessControl.ts index 58d6c59..e88af4a 100644 --- a/src/lib/accessControl.ts +++ b/src/lib/accessControl.ts @@ -8,7 +8,7 @@ type AccessReason = "unauthenticated" | "forbidden"; export type AccessDecision = { allowed: boolean; reason?: AccessReason; - userRole: UserRole; + userRole: UserRole | null; userTenants?: TenantMembership[]; tenantId?: string; }; @@ -20,7 +20,7 @@ export type AccessRule = { permissions?: string[]; }; -const EVERYONE_ROLES: UserRole[] = ["guest", "user", "operator", "admin"]; +const KNOWN_ROLES: UserRole[] = ["user", "operator", "admin"]; function normalizeRoles(roles?: UserRole[]): UserRole[] | undefined { if (!roles || roles.length === 0) { @@ -28,7 +28,7 @@ function normalizeRoles(roles?: UserRole[]): UserRole[] | undefined { } const known = new Set(); for (const role of roles) { - if (EVERYONE_ROLES.includes(role)) { + if (KNOWN_ROLES.includes(role)) { known.add(role); } } @@ -59,25 +59,27 @@ export function resolveAccess( normalizedRule.permissions, ); - const role: UserRole = user?.role ?? "guest"; - const isAuthenticated = Boolean(user); const allowGuests = normalizedRule.allowGuests ?? - (!normalizedRoles || normalizedRoles.includes("guest")); - const requiresLogin = - normalizedRule.requireLogin ?? - (!allowGuests || - Boolean(normalizedPermissions && normalizedPermissions.length > 0) || - Boolean(normalizedRoles && !normalizedRoles.includes("guest"))); + (!normalizedRule.requireLogin && + !normalizedRoles && + !normalizedPermissions); + const requiresLogin = Boolean(normalizedRule.requireLogin); - if (!isAuthenticated && requiresLogin) { - if (allowGuests) { - // Guests explicitly allowed to pass through. - } else { - return { allowed: false, reason: "unauthenticated", userRole: role }; + if (!user) { + if ( + requiresLogin || + !allowGuests || + Boolean(normalizedRoles?.length) || + Boolean(normalizedPermissions?.length) + ) { + return { allowed: false, reason: "unauthenticated", userRole: null }; } + + return { allowed: true, userRole: null }; } + const role: UserRole = user.role; const userPermissions = new Set(user?.permissions ?? []); const roleAllowed = normalizedRoles ? normalizedRoles.includes(role) @@ -96,22 +98,16 @@ export function resolveAccess( normalizedPermissions.length > 0 ) { if (!roleAllowed && !permissionAllowed) { - if (!isAuthenticated && allowGuests) { - return { allowed: false, reason: "unauthenticated", userRole: role }; - } return { allowed: false, - reason: isAuthenticated ? "forbidden" : "unauthenticated", + reason: "forbidden", userRole: role, }; } } else if (normalizedRoles && !roleAllowed) { - if (!isAuthenticated && allowGuests) { - return { allowed: false, reason: "unauthenticated", userRole: role }; - } return { allowed: false, - reason: isAuthenticated ? "forbidden" : "unauthenticated", + reason: "forbidden", userRole: role, }; } @@ -129,7 +125,7 @@ export function resolveAccess( if (missing) { return { allowed: false, - reason: isAuthenticated ? "forbidden" : "unauthenticated", + reason: "forbidden", userRole: role, }; } diff --git a/src/lib/publicUserIdentity.test.ts b/src/lib/publicUserIdentity.test.ts index bcc7fdf..feb8340 100644 --- a/src/lib/publicUserIdentity.test.ts +++ b/src/lib/publicUserIdentity.test.ts @@ -6,16 +6,7 @@ import { } 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", () => { + it("returns the public email value when present", () => { expect( resolvePublicUserEmail({ email: "admin@svc.plus", @@ -24,14 +15,16 @@ describe("publicUserIdentity", () => { ).toBe("admin@svc.plus"); }); + it("normalizes empty public emails", () => { + expect( + resolvePublicUserEmail({ + email: " ", + }), + ).toBe(""); + }); + it("detects whether a public email should be rendered", () => { expect(hasPublicUserEmail({ email: "" })).toBe(false); - expect( - hasPublicUserEmail({ - email: "sandbox@svc.plus", - role: "guest", - }), - ).toBe(false); expect( hasPublicUserEmail({ email: "admin@svc.plus", diff --git a/src/lib/publicUserIdentity.ts b/src/lib/publicUserIdentity.ts index f9b0197..9f5e7db 100644 --- a/src/lib/publicUserIdentity.ts +++ b/src/lib/publicUserIdentity.ts @@ -10,11 +10,7 @@ export function resolvePublicUserEmail(input: { email?: string | null; role?: string | null; }): string { - const normalizedRole = normalizeRole(input.role); - if (normalizedRole === "guest") { - return ""; - } - + void normalizeRole(input.role); return normalizeText(input.email); } @@ -22,9 +18,6 @@ export function hasPublicUserEmail(input: { email?: string | null; role?: string | null; }): boolean { - if (normalizeRole(input.role) === "guest") { - return false; - } - + void normalizeRole(input.role); return normalizeText(input.email).length > 0; } diff --git a/src/lib/userStore.ts b/src/lib/userStore.ts index fb0e81c..b0cd77e 100644 --- a/src/lib/userStore.ts +++ b/src/lib/userStore.ts @@ -3,7 +3,7 @@ import { create } from 'zustand' import { resolvePublicUserEmail } from '@lib/publicUserIdentity' -export type UserRole = 'guest' | 'user' | 'operator' | 'admin' +export type UserRole = 'user' | 'operator' | 'admin' export type TenantMembership = { id: string @@ -14,8 +14,6 @@ export type TenantMembership = { type User = { id: string uuid: string - proxyUuid?: string - proxyUuidExpiresAt?: string email: string name?: string username: string @@ -24,7 +22,6 @@ type User = { role: UserRole groups: string[] permissions: string[] - isGuest: boolean isUser: boolean isOperator: boolean isAdmin: boolean @@ -68,15 +65,15 @@ const KNOWN_ROLE_MAP: Record = { function normalizeRole(input?: string | null): UserRole { if (!input || typeof input !== 'string') { - return 'guest' + return 'user' } const normalized = input.trim().toLowerCase() if (!normalized) { - return 'guest' + return 'user' } - return KNOWN_ROLE_MAP[normalized] ?? 'guest' + return KNOWN_ROLE_MAP[normalized] ?? 'user' } async function fetchSessionUser(): Promise { @@ -105,8 +102,6 @@ async function fetchSessionUser(): Promise { role?: string groups?: string[] permissions?: string[] - proxyUuid?: string - proxyUuidExpiresAt?: string readOnly?: boolean tenantId?: string tenants?: TenantMembership[] @@ -172,14 +167,6 @@ async function fetchSessionUser(): Promise { rawRole === 'read_only' || normalizedGroups.some((value) => value.toLowerCase() === 'readonly role') const normalizedReadOnly = Boolean(sessionUser.readOnly) || inferredReadOnly - const normalizedProxyUuid = - typeof sessionUser.proxyUuid === 'string' && sessionUser.proxyUuid.trim().length > 0 - ? sessionUser.proxyUuid.trim() - : undefined - const normalizedProxyUuidExpiresAt = - typeof sessionUser.proxyUuidExpiresAt === 'string' && sessionUser.proxyUuidExpiresAt.trim().length > 0 - ? sessionUser.proxyUuidExpiresAt.trim() - : undefined const normalizedTenantId = typeof sessionUser.tenantId === 'string' && sessionUser.tenantId.trim().length > 0 @@ -219,8 +206,6 @@ async function fetchSessionUser(): Promise { return { id: identifier, uuid: identifier, - proxyUuid: normalizedProxyUuid, - proxyUuidExpiresAt: normalizedProxyUuidExpiresAt, email: publicEmail, name: normalizedName, username: normalizedUsername ?? publicEmail, @@ -230,7 +215,6 @@ async function fetchSessionUser(): Promise { role: normalizedRole, groups: normalizedGroups, permissions: normalizedPermissions, - isGuest: normalizedRole === 'guest', isUser: normalizedRole === 'user', isOperator: normalizedRole === 'operator', isAdmin: normalizedRole === 'admin', diff --git a/src/modules/extensions/builtin/user-center/components/UserOverview.tsx b/src/modules/extensions/builtin/user-center/components/UserOverview.tsx index 0ac19cc..c214c29 100644 --- a/src/modules/extensions/builtin/user-center/components/UserOverview.tsx +++ b/src/modules/extensions/builtin/user-center/components/UserOverview.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { Copy } from 'lucide-react' @@ -9,33 +9,10 @@ import { useLanguage } from '@i18n/LanguageProvider' import { translations } from '@i18n/translations' import { hasPublicUserEmail } from '@lib/publicUserIdentity' import { useUserStore } from '@lib/userStore' -import { fetchGuestNodeBinding } from '../lib/guestNodeBinding' import Card from './Card' import VlessQrCard from './VlessQrCard' -function resolveDisplayName( - user: { - name?: string - username: string - email: string - } | null, -) { - if (!user) { - return '访客' - } - - if (user.name && user.name.trim().length > 0) { - return user.name.trim() - } - - if (user.username && user.username.trim().length > 0) { - return user.username.trim() - } - - return user.email -} - type UserOverviewProps = { hideMfaMainPrompt?: boolean } @@ -46,33 +23,18 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview const copy = translations[language].userCenter.overview const mfaCopy = translations[language].userCenter.mfa const user = useUserStore((state) => state.user) - const isLoading = useUserStore((state) => state.isLoading) - const refresh = useUserStore((state) => state.refresh) const logout = useUserStore((state) => state.logout) const [copied, setCopied] = useState(false) - const [guestBoundNodeAddress, setGuestBoundNodeAddress] = useState(null) const shouldRenderPublicEmail = hasPublicUserEmail({ email: user?.email, role: user?.role, }) - const displayName = useMemo(() => resolveDisplayName(user), [user]) - const uuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? '—' - const vlessUuid = user?.proxyUuid ?? user?.uuid ?? user?.id ?? null + const uuid = user?.uuid ?? user?.id ?? '—' + const vlessUuid = user?.uuid ?? user?.id ?? null const username = user?.username ?? '—' const email = shouldRenderPublicEmail ? user?.email : '—' const docsUrl = mfaCopy.actions.docsUrl - const isGuestSandboxReadOnly = Boolean(user?.isGuest && user?.isReadOnly) - const guestUuidExpiresAtText = useMemo(() => { - if (!isGuestSandboxReadOnly || !user?.proxyUuidExpiresAt) { - return null - } - const date = new Date(user.proxyUuidExpiresAt) - if (Number.isNaN(date.getTime())) { - return null - } - return date.toLocaleString() - }, [isGuestSandboxReadOnly, user?.proxyUuidExpiresAt]) const mfaStatusLabel = useMemo(() => { if (user?.mfaEnabled) { @@ -88,7 +50,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview const shouldShowMfaMainPrompt = !hideMfaMainPrompt && !user?.isReadOnly const handleCopy = useCallback(async () => { - const identifier = user?.proxyUuid ?? user?.uuid ?? user?.id + const identifier = user?.uuid ?? user?.id if (!identifier) { return } @@ -112,7 +74,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview } catch (error) { console.warn('Failed to copy UUID', error) } - }, [user?.id, user?.proxyUuid, user?.uuid]) + }, [user?.id, user?.uuid]) const handleGoToSetup = useCallback(() => { router.push('/panel/account?setupMfa=1') @@ -124,66 +86,10 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview router.refresh() }, [logout, router]) - useEffect(() => { - if (!isGuestSandboxReadOnly) { - setGuestBoundNodeAddress(null) - return - } - let cancelled = false - void (async () => { - const binding = await fetchGuestNodeBinding() - if (cancelled) { - return - } - setGuestBoundNodeAddress(binding?.address ?? null) - })() - return () => { - cancelled = true - } - }, [isGuestSandboxReadOnly]) - - useEffect(() => { - if (!isGuestSandboxReadOnly || !user?.proxyUuidExpiresAt) { - return - } - const expiresAt = new Date(user.proxyUuidExpiresAt).getTime() - if (!Number.isFinite(expiresAt)) { - return - } - const delay = Math.max(1000, expiresAt - Date.now() + 1500) - const timer = window.setTimeout(() => { - void refresh() - }, delay) - return () => { - window.clearTimeout(timer) - } - }, [isGuestSandboxReadOnly, refresh, user?.proxyUuidExpiresAt]) - return (

{copy.uuidNote}

- {isGuestSandboxReadOnly ? ( -

- {language === 'zh' - ? ( - <> - Guest user(演示模式)为只读模式:可浏览控制台、可使用 VLESS 二维码,但不能修改任何配置。UUID 每 1 小时自动刷新{guestUuidExpiresAtText ? `(下次刷新约 ${guestUuidExpiresAtText})` : ''}。 - - 立即注册账号以获得持久访问权限 → - - - ) - : ( - <> - Guest user (demo mode) runs in read-only mode: browse safely and use the VLESS QR code, but no configuration changes are allowed. UUID rotates every hour{guestUuidExpiresAtText ? ` (next refresh around ${guestUuidExpiresAtText})` : ''}. - - Register for persistent access → - - - )} -

- ) : null}
{shouldShowMfaMainPrompt && requiresSetup ? ( @@ -241,8 +147,6 @@ 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 74720c6..c572d5b 100644 --- a/src/modules/extensions/builtin/user-center/components/VlessQrCard.tsx +++ b/src/modules/extensions/builtin/user-center/components/VlessQrCard.tsx @@ -33,16 +33,12 @@ export type VlessQrCopy = { interface VlessQrCardProps { uuid: string | null | undefined copy: VlessQrCopy - allowGuestReadOnlyFallbackNode?: boolean - boundNodeAddress?: string | null } export default function VlessQrCard({ uuid, copy, - allowGuestReadOnlyFallbackNode = false, - boundNodeAddress, }: VlessQrCardProps) { const { data: allNodes, error: nodesError } = useSWR('user-center-agent-nodes', fetchAgentNodes) @@ -52,16 +48,10 @@ export default function VlessQrCard({ const address = (node.address || '').trim() if (!address || address === '*') return false - 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, allowGuestReadOnlyFallbackNode]) + }, [allNodes]) const [selectedNode, setSelectedNode] = useState(null) const [preferredTransport, setPreferredTransport] = useState('tcp') const [isSelectorOpen, setIsSelectorOpen] = useState(false) @@ -74,36 +64,11 @@ export default function VlessQrCard({ const rawNode = useMemo(() => { if (selectedNode) return selectedNode - // 1. Try to use the node bound by root management (search in all nodes, including filtered ones) - if (boundNodeAddress) { - const matched = (allNodes ?? []).find((node) => node.address === boundNodeAddress) - if (matched) { - return matched - } - - // 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: 'Guest Node', - address: boundNodeAddress, - port: 443, - transport: 'tcp', - security: 'tls', - flow: 'xtls-rprx-vision', - // These templates are needed for URI generation if the API missed them - uri_scheme_tcp: 'vless://${UUID}@${DOMAIN}:${PORT}?encryption=none&flow=${FLOW}&security=tls&sni=${SNI}&fp=${FP}&type=tcp#${TAG}', - uri_scheme_xhttp: 'vless://${UUID}@${DOMAIN}:${PORT}?encryption=none&security=tls&sni=${SNI}&fp=${FP}&type=xhttp&mode=${MODE}&path=${PATH}#${TAG}', - } as VlessNode - } - } - - // 2. Default to the first visible (non-filtered) node + // Default to the first visible (non-filtered) node. if (nodes && nodes[0]) return nodes[0] - // 3. No fallback node return undefined - }, [allNodes, allowGuestReadOnlyFallbackNode, boundNodeAddress, nodes, selectedNode]) + }, [nodes, selectedNode]) const effectiveNode = useMemo((): VlessNode | undefined => { if (!rawNode) return undefined @@ -297,10 +262,7 @@ export default function VlessQrCard({

❌ 运行节点配置缺失

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

) : !effectiveNode ? ( diff --git a/src/modules/extensions/builtin/user-center/lib/guestNodeBinding.ts b/src/modules/extensions/builtin/user-center/lib/guestNodeBinding.ts deleted file mode 100644 index 12fd454..0000000 --- a/src/modules/extensions/builtin/user-center/lib/guestNodeBinding.ts +++ /dev/null @@ -1,35 +0,0 @@ -'use client' - -export type GuestNodeBinding = { - address: string - name?: string - updatedAt: number - updatedBy?: string -} - -export async function fetchGuestNodeBinding(): Promise { - try { - const response = await fetch('/api/guest/binding', { method: 'GET', cache: 'no-store' }) - if (!response.ok) { - return null - } - const payload = (await response.json().catch(() => null)) as any - if (!payload || typeof payload.address !== 'string') { - return null - } - const address = payload.address.trim() - if (!address) { - return null - } - return { - address, - name: typeof payload.name === 'string' && payload.name.trim().length > 0 ? payload.name.trim() : undefined, - updatedAt: typeof payload.updatedAt === 'number' ? payload.updatedAt : Date.now(), - updatedBy: - typeof payload.updatedBy === 'string' && payload.updatedBy.trim().length > 0 ? payload.updatedBy.trim() : undefined, - } - } catch (error) { - console.warn('Failed to fetch guest node binding', error) - return null - } -} diff --git a/src/modules/extensions/builtin/user-center/routes/agent.tsx b/src/modules/extensions/builtin/user-center/routes/agent.tsx index 7051efa..dfe34de 100644 --- a/src/modules/extensions/builtin/user-center/routes/agent.tsx +++ b/src/modules/extensions/builtin/user-center/routes/agent.tsx @@ -1,15 +1,13 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' import useSWR from 'swr' import { Server, MapPin, Plus, ExternalLink, RefreshCw } from 'lucide-react' import Breadcrumbs from '@/app/panel/components/Breadcrumbs' import { useLanguage } from '@i18n/LanguageProvider' import { translations } from '@i18n/translations' -import { useUserStore } from '@lib/userStore' import { fetchAgentNodes } from '../lib/fetchAgentNodes' -import { fetchGuestNodeBinding } from '../lib/guestNodeBinding' interface VlessNode { @@ -39,75 +37,14 @@ function isDisplayableNode(node: VlessNode): boolean { export default function UserCenterAgentRoute() { const { language } = useLanguage() const t = translations[language].userCenter - const user = useUserStore((state) => state.user) const { data: nodes, error, isLoading, mutate } = useSWR('user-center-agent-nodes', fetchAgentNodes) - const [boundNode, setBoundNode] = useState(null) - const isGuestSandboxReadOnly = Boolean(user?.isGuest && user?.isReadOnly) const visibleNodes = useMemo(() => { - return (nodes ?? []).filter((node) => { - if (isGuestSandboxReadOnly) { - // 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 !== '*' - } - return isDisplayableNode(node) - }) - }, [nodes, isGuestSandboxReadOnly]) - const [boundAddress, setBoundAddress] = useState(null) - - useEffect(() => { - if (!isGuestSandboxReadOnly) { - setBoundNode(null) - setBoundAddress(null) - return - } - let cancelled = false - void (async () => { - const binding = await fetchGuestNodeBinding() - if (cancelled) { - return - } - setBoundAddress(binding?.address ?? null) - if (!binding?.address) { - setBoundNode(null) - return - } - setBoundNode({ - name: binding.name || 'Guest Node', - address: binding.address, - port: 443, - transport: 'tcp', - security: 'tls', - } as any) - })() - return () => { - cancelled = true - } - }, [isGuestSandboxReadOnly]) + return (nodes ?? []).filter((node) => isDisplayableNode(node)) + }, [nodes]) const effectiveNodes = useMemo(() => { - // Default behavior: show all displayable nodes. - const base = visibleNodes.length > 0 ? [...visibleNodes] : [] - - // 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) - const preferred = matched ?? boundNode ?? null - if (preferred) { - const rest = base.filter((n) => n.address !== preferred.address) - return [preferred, ...rest] - } - } - - // Fallback if no nodes were returned by the API but the guest binding exists. - if (isGuestSandboxReadOnly && boundNode && base.length === 0) { - return [boundNode] - } - - return base - }, [isGuestSandboxReadOnly, nodes, visibleNodes, boundAddress, boundNode]) + return visibleNodes.length > 0 ? [...visibleNodes] : [] + }, [visibleNodes]) const groupedNodes = useMemo(() => { const groups: Record = { @@ -164,7 +101,7 @@ export default function UserCenterAgentRoute() {
- {error && !(isGuestSandboxReadOnly && boundNode) && ( + {error && (
{language === 'zh' ? `节点列表加载失败:${error.message}` diff --git a/src/server/account/session.ts b/src/server/account/session.ts index 2a0b09c..252c178 100644 --- a/src/server/account/session.ts +++ b/src/server/account/session.ts @@ -8,7 +8,7 @@ import { getAccountServiceApiBaseUrl } from "@server/serviceConfig"; const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); -export type AccountUserRole = "guest" | "user" | "operator" | "admin"; +export type AccountUserRole = "user" | "operator" | "admin"; export type AccountTenantMembership = { id: string; @@ -70,15 +70,15 @@ const KNOWN_ROLE_MAP: Record = { member: "user", }; -function normalizeRole(value: unknown): AccountUserRole { +function normalizeRole(value: unknown): AccountUserRole | null { if (typeof value !== "string") { - return "guest"; + return null; } const normalized = value.trim().toLowerCase(); if (!normalized) { - return "guest"; + return null; } - return KNOWN_ROLE_MAP[normalized] ?? "guest"; + return KNOWN_ROLE_MAP[normalized] ?? null; } function normalizeString(value: unknown): string | undefined { @@ -125,7 +125,7 @@ function normalizeTenants( entry.name = name; } const role = normalizeRole(raw.role); - if (role !== "guest") { + if (role) { entry.role = role; } normalized.push(entry); @@ -147,6 +147,9 @@ function buildUser( const name = normalizeString(raw.name); const username = normalizeString(raw.username) ?? name; const role = normalizeRole(raw.role); + if (!role) { + return null; + } const groups = normalizeStringList(raw.groups); const permissions = normalizeStringList(raw.permissions); const tenantId = normalizeString(raw.tenantId);