refactor(auth): remove guest console mode

This commit is contained in:
Haitao Pan 2026-04-12 19:28:31 +08:00
parent 107e9879a6
commit ddb2a7b627
16 changed files with 181 additions and 451 deletions

View File

@ -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 });
});
});

View File

@ -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,
},

View File

@ -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<ErrorPayload>({ 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<ErrorPayload>({ 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<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
}

View File

@ -10,10 +10,6 @@ import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
const ROLE_BADGES: Record<UserRole, { label: string; className: string }> = {
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

View File

@ -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({
<div className="whitespace-pre-wrap rounded-[var(--radius-lg)] border border-[color:var(--color-danger-border)] bg-[var(--color-danger-muted)]/40 px-3 py-2 text-sm text-[var(--color-danger-foreground)]">
{errorMessage}
</div>
{guestSessionExpired ? (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => router.push("/login")}
className="tactile-button tactile-button-primary px-3 text-xs"
>
{copy.login}
</button>
<button
type="button"
onClick={() => router.push("/register")}
className="tactile-button tactile-button-soft px-3 text-xs text-[var(--color-text)]"
>
{copy.register}
</button>
</div>
) : null}
</div>
) : null}

View File

@ -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: [

View File

@ -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,
});
});
});

View File

@ -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<UserRole>();
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,
};
}

View File

@ -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",

View File

@ -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;
}

View File

@ -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<string, UserRole> = {
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<User | null> {
@ -105,8 +102,6 @@ async function fetchSessionUser(): Promise<User | null> {
role?: string
groups?: string[]
permissions?: string[]
proxyUuid?: string
proxyUuidExpiresAt?: string
readOnly?: boolean
tenantId?: string
tenants?: TenantMembership[]
@ -172,14 +167,6 @@ async function fetchSessionUser(): Promise<User | null> {
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<User | null> {
return {
id: identifier,
uuid: identifier,
proxyUuid: normalizedProxyUuid,
proxyUuidExpiresAt: normalizedProxyUuidExpiresAt,
email: publicEmail,
name: normalizedName,
username: normalizedUsername ?? publicEmail,
@ -230,7 +215,6 @@ async function fetchSessionUser(): Promise<User | null> {
role: normalizedRole,
groups: normalizedGroups,
permissions: normalizedPermissions,
isGuest: normalizedRole === 'guest',
isUser: normalizedRole === 'user',
isOperator: normalizedRole === 'operator',
isAdmin: normalizedRole === 'admin',

View File

@ -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<string | null>(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 (
<div className="space-y-6 text-[var(--color-text)] transition-colors">
<div>
<p className="text-sm text-[var(--color-text-subtle)] opacity-90">{copy.uuidNote}</p>
{isGuestSandboxReadOnly ? (
<p className="mt-2 rounded-[var(--radius-md)] border border-[color:var(--color-warning-muted)] bg-[var(--color-warning-muted)] px-3 py-2 text-xs text-[var(--color-warning-foreground)]">
{language === 'zh'
? (
<>
Guest user使 VLESS UUID 1 {guestUuidExpiresAtText ? `(下次刷新约 ${guestUuidExpiresAtText}` : ''}
<Link href="/register" className="ml-1 text-[var(--color-primary)] hover:underline">
访 &rarr;
</Link>
</>
)
: (
<>
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})` : ''}.
<Link href="/register" className="ml-1 text-[var(--color-primary)] hover:underline">
Register for persistent access &rarr;
</Link>
</>
)}
</p>
) : null}
</div>
{shouldShowMfaMainPrompt && requiresSetup ? (
@ -241,8 +147,6 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
<VlessQrCard
uuid={vlessUuid}
copy={copy.cards.vless}
allowGuestReadOnlyFallbackNode={isGuestSandboxReadOnly}
boundNodeAddress={guestBoundNodeAddress}
/>
<Card>

View File

@ -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<VlessNode[]>('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<VlessNode | null>(null)
const [preferredTransport, setPreferredTransport] = useState<VlessTransport>('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({
<div className="rounded-md border border-[color:var(--color-warning-border)] bg-[var(--color-warning-muted)] p-3 text-xs text-[var(--color-warning-foreground)]">
<p className="font-semibold"> </p>
<p className="mt-1">
{allowGuestReadOnlyFallbackNode
? '演示模式账号未发现有效的节点映射。请确认后端已完成 guest 节点绑定逻辑。'
: `无法从服务器获取代理节点列表${nodesError ? `${nodesError.message}` : ''}。请检查 API 接口是否正常。`
}
{`无法从服务器获取代理节点列表${nodesError ? `${nodesError.message}` : ''}。请检查 API 接口是否正常。`}
</p>
</div>
) : !effectiveNode ? (

View File

@ -1,35 +0,0 @@
'use client'
export type GuestNodeBinding = {
address: string
name?: string
updatedAt: number
updatedBy?: string
}
export async function fetchGuestNodeBinding(): Promise<GuestNodeBinding | null> {
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
}
}

View File

@ -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<VlessNode[]>('user-center-agent-nodes', fetchAgentNodes)
const [boundNode, setBoundNode] = useState<VlessNode | null>(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<string | null>(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<string, VlessNode[]> = {
@ -164,7 +101,7 @@ export default function UserCenterAgentRoute() {
<div className="grid gap-6">
{error && !(isGuestSandboxReadOnly && boundNode) && (
{error && (
<div className="rounded-xl border border-[color:var(--color-danger-border)] bg-[var(--color-danger-muted)]/30 px-4 py-3 text-sm text-[var(--color-danger-foreground)]">
{language === 'zh'
? `节点列表加载失败:${error.message}`

View File

@ -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<string, AccountUserRole> = {
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);