refactor(auth): remove guest console mode
This commit is contained in:
parent
107e9879a6
commit
ddb2a7b627
69
src/app/api/auth/session/route.test.ts
Normal file
69
src/app/api/auth/session/route.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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: [
|
||||
|
||||
26
src/lib/accessControl.test.ts
Normal file
26
src/lib/accessControl.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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">
|
||||
立即注册账号以获得持久访问权限 →
|
||||
</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 →
|
||||
</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>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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}`
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user