From 5d9de8ed1f49d41148b349ffaf20a0ed0963fe3c Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 17 Mar 2026 13:24:41 +0800 Subject: [PATCH 01/10] Add tenant-aware XWorkmate console flows --- src/app/(auth)/login/LoginContent.tsx | 4 +- src/app/AppProviders.tsx | 4 +- src/app/api/auth/login/route.ts | 190 ++++--- src/app/api/auth/mfa/disable/route.ts | 58 +- src/app/api/auth/mfa/verify/route.ts | 137 +++-- .../api/auth/oauth/login/[provider]/route.ts | 24 + src/app/api/auth/session/route.ts | 360 ++++++------ src/app/api/auth/token/exchange/route.ts | 115 ++-- src/app/api/sandbox/assume/revert/route.ts | 89 +-- src/app/api/sandbox/assume/route.ts | 110 ++-- src/app/api/xworkmate/profile/route.ts | 81 +++ src/app/xworkmate/admin/page.tsx | 56 ++ src/app/xworkmate/integrations/page.tsx | 53 ++ src/app/xworkmate/page.tsx | 36 +- .../xworkmate/XWorkmateProfileEditor.tsx | 524 ++++++++++++++++++ .../xworkmate/XWorkmateWorkspacePage.tsx | 213 +++++-- src/lib/authGateway.ts | 178 +++--- src/lib/xworkmate/host.ts | 39 ++ src/lib/xworkmate/types.ts | 76 +++ src/server/account/session.ts | 20 + src/server/xworkmate/profile.ts | 73 +++ src/state/openclawConsoleStore.ts | 344 ++++++++---- 22 files changed, 2127 insertions(+), 657 deletions(-) create mode 100644 src/app/api/auth/oauth/login/[provider]/route.ts create mode 100644 src/app/api/xworkmate/profile/route.ts create mode 100644 src/app/xworkmate/admin/page.tsx create mode 100644 src/app/xworkmate/integrations/page.tsx create mode 100644 src/components/xworkmate/XWorkmateProfileEditor.tsx create mode 100644 src/lib/xworkmate/host.ts create mode 100644 src/lib/xworkmate/types.ts create mode 100644 src/server/xworkmate/profile.ts diff --git a/src/app/(auth)/login/LoginContent.tsx b/src/app/(auth)/login/LoginContent.tsx index 9aa0139..8133c5c 100644 --- a/src/app/(auth)/login/LoginContent.tsx +++ b/src/app/(auth)/login/LoginContent.tsx @@ -74,8 +74,8 @@ export default function LoginContent({ `${accountServiceBaseUrl}/api/auth/login`; const socialButtonsDisabled = false; - const githubAuthUrl = `${accountServiceBaseUrl}/api/auth/oauth/login/github`; - const googleAuthUrl = `${accountServiceBaseUrl}/api/auth/oauth/login/google`; + const githubAuthUrl = "/api/auth/oauth/login/github"; + const googleAuthUrl = "/api/auth/oauth/login/google"; useEffect(() => { const exchangeCode = searchParams.get("exchange_code"); diff --git a/src/app/AppProviders.tsx b/src/app/AppProviders.tsx index a5c1d70..eb5275c 100644 --- a/src/app/AppProviders.tsx +++ b/src/app/AppProviders.tsx @@ -19,6 +19,7 @@ export function AppProviders({ }) { const { isOpen, isMinimized, close, toggleOpen } = useMoltbotStore(); const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults); + const setScope = useOpenClawConsoleStore((state) => state.setScope); const pathname = usePathname(); const [isMobileViewport, setIsMobileViewport] = useState(false); const isOpenClawWorkspace = @@ -29,8 +30,9 @@ export function AppProviders({ !isOpenClawWorkspace && isOpen && !isMinimized && !isMobileViewport; useEffect(() => { + setScope("global", assistantDefaults); applyDefaults(assistantDefaults); - }, [applyDefaults, assistantDefaults]); + }, [applyDefaults, assistantDefaults, setScope]); useEffect(() => { if (typeof window === "undefined") { diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 08021ba..042553a 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,123 +1,175 @@ -import { cookies } from 'next/headers' -import { NextRequest, NextResponse } from 'next/server' +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; -import { applyMfaCookie, applySessionCookie, clearMfaCookie, clearSessionCookie, deriveMaxAgeFromExpires, MFA_COOKIE_NAME } from '@lib/authGateway' -import { getAccountServiceApiBaseUrl } from '@server/serviceConfig' +import { + applyMfaCookie, + applySessionCookie, + clearMfaCookie, + clearSessionCookie, + deriveMaxAgeFromExpires, + MFA_COOKIE_NAME, +} from "@lib/authGateway"; +import { getAccountServiceApiBaseUrl } from "@server/serviceConfig"; -const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); type LoginPayload = { - email?: string - password?: string - remember?: boolean - totp?: string - code?: string - token?: string -} + email?: string; + password?: string; + remember?: boolean; + totp?: string; + code?: string; + token?: string; +}; type AccountLoginResponse = { - token?: string - expiresAt?: string - error?: string - mfaToken?: string - needMfa?: boolean - mfaEnabled?: boolean -} + token?: string; + expiresAt?: string; + error?: string; + mfaToken?: string; + needMfa?: boolean; + mfaEnabled?: boolean; +}; function normalizeEmail(value: unknown) { - return typeof value === 'string' ? value.trim().toLowerCase() : '' + return typeof value === "string" ? value.trim().toLowerCase() : ""; } function normalizeCode(value: unknown) { - return typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 6) : '' + return typeof value === "string" ? value.replace(/\D/g, "").slice(0, 6) : ""; } export async function POST(request: NextRequest) { - let payload: LoginPayload + let payload: LoginPayload; try { - payload = (await request.json()) as LoginPayload + payload = (await request.json()) as LoginPayload; } catch (error) { - console.error('Failed to decode login payload', error) - return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 }) + console.error("Failed to decode login payload", error); + return NextResponse.json( + { success: false, error: "invalid_request", needMfa: false }, + { status: 400 }, + ); } - const email = normalizeEmail(payload?.email) - const password = typeof payload?.password === 'string' ? payload.password : '' - const totpCode = normalizeCode(payload?.totp ?? payload?.code) - const remember = Boolean(payload?.remember) + const email = normalizeEmail(payload?.email); + const password = + typeof payload?.password === "string" ? payload.password : ""; + const totpCode = normalizeCode(payload?.totp ?? payload?.code); + const remember = Boolean(payload?.remember); if (!email || !password) { - return NextResponse.json({ success: false, error: 'missing_credentials', needMfa: false }, { status: 400 }) + return NextResponse.json( + { success: false, error: "missing_credentials", needMfa: false }, + { status: 400 }, + ); } try { - const loginBody: Record = { email, password } + const loginBody: Record = { email, password }; if (totpCode) { - loginBody.totpCode = totpCode + loginBody.totpCode = totpCode; } const response = await fetch(`${ACCOUNT_API_BASE}/login`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify(loginBody), - cache: 'no-store', - }) + cache: "no-store", + }); - const data = (await response.json().catch(() => ({}))) as AccountLoginResponse + const data = (await response + .json() + .catch(() => ({}))) as AccountLoginResponse; - if (response.ok && typeof data?.token === 'string' && data.token.length > 0) { - const maxAgeFromBackend = deriveMaxAgeFromExpires(data?.expiresAt) - const effectiveMaxAge = remember ? Math.max(maxAgeFromBackend, 60 * 60 * 24 * 30) : maxAgeFromBackend - const result = NextResponse.json({ success: true, error: null, needMfa: false }) - applySessionCookie(result, data.token, effectiveMaxAge) - clearMfaCookie(result) - return result + if ( + response.ok && + typeof data?.token === "string" && + data.token.length > 0 + ) { + const maxAgeFromBackend = deriveMaxAgeFromExpires(data?.expiresAt); + const effectiveMaxAge = remember + ? Math.max(maxAgeFromBackend, 60 * 60 * 24 * 30) + : maxAgeFromBackend; + const result = NextResponse.json({ + success: true, + error: null, + needMfa: false, + }); + applySessionCookie( + result, + data.token, + effectiveMaxAge, + request.headers.get("host") ?? undefined, + ); + clearMfaCookie(result); + return result; } - const errorCode = typeof data?.error === 'string' ? data.error : 'authentication_failed' - const needsMfa = Boolean(data?.needMfa || errorCode === 'mfa_required' || errorCode === 'mfa_setup_required') + const errorCode = + typeof data?.error === "string" ? data.error : "authentication_failed"; + const needsMfa = Boolean( + data?.needMfa || + errorCode === "mfa_required" || + errorCode === "mfa_setup_required", + ); - if ((response.status === 401 || response.status === 403 || needsMfa) && typeof data?.mfaToken === 'string') { - const result = NextResponse.json({ success: false, error: errorCode, needMfa: true }, { status: 401 }) - applyMfaCookie(result, data.mfaToken) - clearSessionCookie(result) - return result + if ( + (response.status === 401 || response.status === 403 || needsMfa) && + typeof data?.mfaToken === "string" + ) { + const result = NextResponse.json( + { success: false, error: errorCode, needMfa: true }, + { status: 401 }, + ); + applyMfaCookie(result, data.mfaToken); + clearSessionCookie(result, request.headers.get("host") ?? undefined); + return result; } - const statusCode = response.status || 401 - const result = NextResponse.json({ success: false, error: errorCode, needMfa: false }, { status: statusCode }) - clearSessionCookie(result) - clearMfaCookie(result) - return result + const statusCode = response.status || 401; + const result = NextResponse.json( + { success: false, error: errorCode, needMfa: false }, + { status: statusCode }, + ); + clearSessionCookie(result, request.headers.get("host") ?? undefined); + clearMfaCookie(result); + return result; } catch (error) { - console.error('Account service login proxy failed', error) - const result = NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: false }, { status: 502 }) - clearSessionCookie(result) - clearMfaCookie(result) - return result + console.error("Account service login proxy failed", error); + const result = NextResponse.json( + { success: false, error: "account_service_unreachable", needMfa: false }, + { status: 502 }, + ); + clearSessionCookie(result, request.headers.get("host") ?? undefined); + clearMfaCookie(result); + return result; } } export function GET() { return NextResponse.json( - { success: false, error: 'method_not_allowed', needMfa: false }, + { success: false, error: "method_not_allowed", needMfa: false }, { status: 405, headers: { - Allow: 'POST', + Allow: "POST", }, }, - ) + ); } export async function DELETE() { - const cookieStore = await cookies() - const response = NextResponse.json({ success: true, error: null, needMfa: false }) + const cookieStore = await cookies(); + const response = NextResponse.json({ + success: true, + error: null, + needMfa: false, + }); if (cookieStore.has(MFA_COOKIE_NAME)) { - clearMfaCookie(response) + clearMfaCookie(response); } - clearSessionCookie(response) - return response + clearSessionCookie(response); + return response; } diff --git a/src/app/api/auth/mfa/disable/route.ts b/src/app/api/auth/mfa/disable/route.ts index 718bbad..c9606ef 100644 --- a/src/app/api/auth/mfa/disable/route.ts +++ b/src/app/api/auth/mfa/disable/route.ts @@ -1,54 +1,66 @@ -import { cookies } from 'next/headers' -import { NextRequest, NextResponse } from 'next/server' +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; -import { SESSION_COOKIE_NAME, clearSessionCookie } from '@lib/authGateway' -import { getAccountServiceApiBaseUrl } from '@server/serviceConfig' +import { SESSION_COOKIE_NAME, clearSessionCookie } from "@lib/authGateway"; +import { getAccountServiceApiBaseUrl } from "@server/serviceConfig"; -const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); export async function POST(request: NextRequest) { - void request - const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim() + void request; + const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim(); if (!token) { - return NextResponse.json({ success: false, error: 'session_required' }, { status: 401 }) + return NextResponse.json( + { success: false, error: "session_required" }, + { status: 401 }, + ); } try { const response = await fetch(`${ACCOUNT_API_BASE}/mfa/disable`, { - method: 'POST', + method: "POST", headers: { Authorization: `Bearer ${token}`, }, - cache: 'no-store', - }) + cache: "no-store", + }); - const data = await response.json().catch(() => ({})) + const data = await response.json().catch(() => ({})); if (!response.ok) { - const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'mfa_disable_failed' + const errorCode = + typeof (data as { error?: string })?.error === "string" + ? data.error + : "mfa_disable_failed"; if (response.status === 401) { - const result = NextResponse.json({ success: false, error: errorCode }) - clearSessionCookie(result) - return result + const result = NextResponse.json({ success: false, error: errorCode }); + clearSessionCookie(result, request.headers.get("host") ?? undefined); + return result; } - return NextResponse.json({ success: false, error: errorCode }, { status: response.status || 400 }) + return NextResponse.json( + { success: false, error: errorCode }, + { status: response.status || 400 }, + ); } - return NextResponse.json({ success: true, error: null, data }) + return NextResponse.json({ success: true, error: null, data }); } catch (error) { - console.error('Account service MFA disable proxy failed', error) - return NextResponse.json({ success: false, error: 'account_service_unreachable' }, { status: 502 }) + console.error("Account service MFA disable proxy failed", error); + return NextResponse.json( + { success: false, error: "account_service_unreachable" }, + { status: 502 }, + ); } } export function GET() { return NextResponse.json( - { success: false, error: 'method_not_allowed' }, + { success: false, error: "method_not_allowed" }, { status: 405, headers: { - Allow: 'POST', + Allow: "POST", }, }, - ) + ); } diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts index 1cd40d6..a57789b 100644 --- a/src/app/api/auth/mfa/verify/route.ts +++ b/src/app/api/auth/mfa/verify/route.ts @@ -1,5 +1,5 @@ -import { cookies } from 'next/headers' -import { NextRequest, NextResponse } from 'next/server' +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; import { applyMfaCookie, @@ -8,107 +8,136 @@ import { clearSessionCookie, deriveMaxAgeFromExpires, MFA_COOKIE_NAME, -} from '@lib/authGateway' -import { getAccountServiceApiBaseUrl } from '@server/serviceConfig' +} from "@lib/authGateway"; +import { getAccountServiceApiBaseUrl } from "@server/serviceConfig"; -const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); type VerifyPayload = { - token?: string - code?: string - totp?: string -} + token?: string; + code?: string; + totp?: string; +}; type AccountVerifyResponse = { - token?: string - expiresAt?: string - mfaToken?: string - error?: string - retryAt?: string - user?: Record | null - mfa?: Record | null -} + token?: string; + expiresAt?: string; + mfaToken?: string; + error?: string; + retryAt?: string; + user?: Record | null; + mfa?: Record | null; +}; function normalizeString(value: unknown) { - return typeof value === 'string' ? value.trim() : '' + return typeof value === "string" ? value.trim() : ""; } function normalizeCode(value: unknown) { - return typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 6) : '' + return typeof value === "string" ? value.replace(/\D/g, "").slice(0, 6) : ""; } export async function POST(request: NextRequest) { - const cookieStore = await cookies() - let payload: VerifyPayload + const cookieStore = await cookies(); + let payload: VerifyPayload; try { - payload = (await request.json()) as VerifyPayload + payload = (await request.json()) as VerifyPayload; } catch (error) { - console.error('Failed to decode MFA verification payload', error) - return NextResponse.json({ success: false, error: 'invalid_request', needMfa: true }, { status: 400 }) + console.error("Failed to decode MFA verification payload", error); + return NextResponse.json( + { success: false, error: "invalid_request", needMfa: true }, + { status: 400 }, + ); } - const cookieToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? '' - const token = normalizeString(payload?.token || cookieToken) - const code = normalizeCode(payload?.code ?? payload?.totp) + const cookieToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? ""; + const token = normalizeString(payload?.token || cookieToken); + const code = normalizeCode(payload?.code ?? payload?.totp); if (!token) { - return NextResponse.json({ success: false, error: 'mfa_token_required', needMfa: true }, { status: 400 }) + return NextResponse.json( + { success: false, error: "mfa_token_required", needMfa: true }, + { status: 400 }, + ); } if (!code) { - return NextResponse.json({ success: false, error: 'mfa_code_required', needMfa: true }, { status: 400 }) + return NextResponse.json( + { success: false, error: "mfa_code_required", needMfa: true }, + { status: 400 }, + ); } try { const response = await fetch(`${ACCOUNT_API_BASE}/mfa/totp/verify`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ token, code }), - cache: 'no-store', - }) + cache: "no-store", + }); - const data = (await response.json().catch(() => ({}))) as AccountVerifyResponse + const data = (await response + .json() + .catch(() => ({}))) as AccountVerifyResponse; - if (response.ok && typeof data?.token === 'string' && data.token.length > 0) { - const result = NextResponse.json({ success: true, error: null, needMfa: false, data }) - applySessionCookie(result, data.token, deriveMaxAgeFromExpires(data?.expiresAt)) - clearMfaCookie(result) - return result + if ( + response.ok && + typeof data?.token === "string" && + data.token.length > 0 + ) { + const result = NextResponse.json({ + success: true, + error: null, + needMfa: false, + data, + }); + applySessionCookie( + result, + data.token, + deriveMaxAgeFromExpires(data?.expiresAt), + request.headers.get("host") ?? undefined, + ); + clearMfaCookie(result); + return result; } - const errorCode = typeof data?.error === 'string' ? data.error : 'mfa_verification_failed' + const errorCode = + typeof data?.error === "string" ? data.error : "mfa_verification_failed"; const result = NextResponse.json( { success: false, error: errorCode, needMfa: true, data }, { status: response.status || 400 }, - ) + ); - if (typeof data?.mfaToken === 'string' && data.mfaToken.trim()) { - applyMfaCookie(result, data.mfaToken) + if (typeof data?.mfaToken === "string" && data.mfaToken.trim()) { + applyMfaCookie(result, data.mfaToken); } else { - applyMfaCookie(result, token) + applyMfaCookie(result, token); } - clearSessionCookie(result) - return result + clearSessionCookie(result, request.headers.get("host") ?? undefined); + return result; } catch (error) { - console.error('Account service MFA verification proxy failed', error) - const result = NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: true }, { status: 502 }) - applyMfaCookie(result, token) - clearSessionCookie(result) - return result + console.error("Account service MFA verification proxy failed", error); + const result = NextResponse.json( + { success: false, error: "account_service_unreachable", needMfa: true }, + { status: 502 }, + ); + applyMfaCookie(result, token); + clearSessionCookie(result, request.headers.get("host") ?? undefined); + return result; } } export function GET() { return NextResponse.json( - { success: false, error: 'method_not_allowed', needMfa: true }, + { success: false, error: "method_not_allowed", needMfa: true }, { status: 405, headers: { - Allow: 'POST', + Allow: "POST", }, }, - ) + ); } diff --git a/src/app/api/auth/oauth/login/[provider]/route.ts b/src/app/api/auth/oauth/login/[provider]/route.ts new file mode 100644 index 0000000..302d1ac --- /dev/null +++ b/src/app/api/auth/oauth/login/[provider]/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getAccountServiceApiBaseUrl } from "@/server/serviceConfig"; + +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); +const ALLOWED_PROVIDERS = new Set(["github", "google"]); + +export async function GET( + request: NextRequest, + context: { params: Promise<{ provider: string }> }, +) { + const { provider } = await context.params; + const normalizedProvider = provider.trim().toLowerCase(); + if (!ALLOWED_PROVIDERS.has(normalizedProvider)) { + return NextResponse.json({ error: "provider_not_found" }, { status: 404 }); + } + + const target = new URL( + `${ACCOUNT_API_BASE}/oauth/login/${normalizedProvider}`, + ); + target.searchParams.set("frontend_url", request.nextUrl.origin); + + return NextResponse.redirect(target, { status: 307 }); +} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 58f39b1..4fbebac 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -1,116 +1,131 @@ -import { cookies } from 'next/headers' -import { NextRequest, NextResponse } from 'next/server' +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; -import { SESSION_COOKIE_NAME, clearSessionCookie } from '@lib/authGateway' -import { getAccountServiceApiBaseUrl, getAccountServiceBaseUrl } from '@server/serviceConfig' -import { buildInternalServiceHeaders, isServiceTokenConfigured } from '@server/internalServiceAuth' +import { SESSION_COOKIE_NAME, clearSessionCookie } from "@lib/authGateway"; +import { + getAccountServiceApiBaseUrl, + getAccountServiceBaseUrl, +} from "@server/serviceConfig"; +import { + buildInternalServiceHeaders, + isServiceTokenConfigured, +} from "@server/internalServiceAuth"; -const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() -const ACCOUNT_BASE = getAccountServiceBaseUrl() +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); +const ACCOUNT_BASE = getAccountServiceBaseUrl(); type AccountUser = { - id?: string - uuid?: string - proxyUuid?: string - proxyUuidExpiresAt?: string - name?: string - username?: string - email: string - mfaEnabled?: boolean - mfaPending?: boolean + id?: string; + uuid?: string; + proxyUuid?: string; + proxyUuidExpiresAt?: string; + name?: string; + username?: string; + email: string; + mfaEnabled?: boolean; + mfaPending?: boolean; mfa?: { - totpEnabled?: boolean - totpPending?: boolean - totpSecretIssuedAt?: string - totpConfirmedAt?: string - totpLockedUntil?: string - } - role?: string - groups?: string[] - permissions?: string[] - readOnly?: boolean - tenantId?: string + totpEnabled?: boolean; + totpPending?: boolean; + totpSecretIssuedAt?: string; + totpConfirmedAt?: string; + totpLockedUntil?: string; + }; + role?: string; + groups?: string[]; + permissions?: string[]; + readOnly?: boolean; + tenantId?: string; tenants?: Array<{ - id?: string - name?: string - role?: string - }> -} + id?: string; + name?: string; + role?: string; + }>; +}; type SessionResponse = { - user?: AccountUser | null - error?: string -} + user?: AccountUser | null; + error?: string; +}; type SandboxGuestResponse = { - email?: string - proxyUuid?: string - proxyUuidExpiresAt?: string - error?: string -} + email?: string; + proxyUuid?: string; + proxyUuidExpiresAt?: string; + error?: string; +}; function normalizeRole(role: unknown): string { - if (typeof role !== 'string') { - return 'user' + if (typeof role !== "string") { + return "user"; } - const normalized = role.trim().toLowerCase() + const normalized = role.trim().toLowerCase(); if (!normalized) { - return 'user' + return "user"; } - if (normalized === 'root' || normalized === 'super_admin') { - return 'admin' + if (normalized === "root" || normalized === "super_admin") { + return "admin"; } - if (normalized === 'readonly' || normalized === 'read_only') { - return 'user' + if (normalized === "readonly" || normalized === "read_only") { + return "user"; } - return normalized + return normalized; } -async function fetchSession(token: string) { +async function fetchSession(token: string, requestHost?: string | null) { try { const response = await fetch(`${ACCOUNT_API_BASE}/session`, { headers: { Authorization: `Bearer ${token}`, + ...(requestHost && requestHost.trim().length > 0 + ? { + "X-Forwarded-Host": requestHost.trim(), + } + : {}), }, - cache: 'no-store', - }) + cache: "no-store", + }); - const data = (await response.json().catch(() => ({}))) as SessionResponse - return { response, data } + const data = (await response.json().catch(() => ({}))) as SessionResponse; + return { response, data }; } catch (error) { - console.error('Session lookup proxy failed', error) - return { response: null, data: null } + console.error("Session lookup proxy failed", error); + return { response: null, data: null }; } } async function fetchSandboxGuest(): Promise { if (!isServiceTokenConfigured()) { - return null + return null; } try { const response = await fetch(`${ACCOUNT_BASE}/api/internal/sandbox/guest`, { - method: 'GET', + method: "GET", headers: buildInternalServiceHeaders({ - Accept: 'application/json', + Accept: "application/json", }), - cache: 'no-store', - }) + cache: "no-store", + }); if (!response.ok) { - return null + return null; } - const payload = (await response.json().catch(() => null)) as SandboxGuestResponse | null - const proxyUuid = typeof payload?.proxyUuid === 'string' ? payload.proxyUuid.trim() : '' + const payload = (await response + .json() + .catch(() => null)) as SandboxGuestResponse | null; + const proxyUuid = + typeof payload?.proxyUuid === "string" ? payload.proxyUuid.trim() : ""; if (!proxyUuid) { - return null + return null; } const proxyUuidExpiresAt = - typeof payload?.proxyUuidExpiresAt === 'string' && payload.proxyUuidExpiresAt.trim().length > 0 + typeof payload?.proxyUuidExpiresAt === "string" && + payload.proxyUuidExpiresAt.trim().length > 0 ? payload.proxyUuidExpiresAt.trim() - : undefined + : undefined; // Shape this as a pseudo-session user for the Guest/Demo experience. return { @@ -118,135 +133,162 @@ async function fetchSandboxGuest(): Promise { uuid: proxyUuid, proxyUuid, proxyUuidExpiresAt, - name: 'Guest user', - username: 'guest', - email: 'sandbox@svc.plus', - role: 'guest', - groups: ['guest', 'sandbox'], - permissions: ['read'], + name: "Guest user", + username: "guest", + email: "sandbox@svc.plus", + role: "guest", + groups: ["guest", "sandbox"], + permissions: ["read"], readOnly: true, - tenantId: 'guest-sandbox', - tenants: [{ id: 'guest-sandbox', name: 'Guest Sandbox', role: 'guest' }], + tenantId: "guest-sandbox", + tenants: [{ id: "guest-sandbox", name: "Guest Sandbox", role: "guest" }], mfaEnabled: false, mfaPending: false, - } + }; } catch (error) { - console.error('Sandbox guest session proxy failed', error) - return null + console.error("Sandbox guest session proxy failed", error); + return null; } } export async function GET(request: NextRequest) { - void request - const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value + void request; + const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value; if (!token) { - const sandboxGuest = await fetchSandboxGuest() - return NextResponse.json({ user: sandboxGuest }) + const sandboxGuest = await fetchSandboxGuest(); + return NextResponse.json({ user: sandboxGuest }); } - const { response, data } = await fetchSession(token) + const requestHost = request.headers.get("host"); + const { response, data } = await fetchSession(token, requestHost); if (!response || !response.ok || !data?.user) { - const res = NextResponse.json({ user: null }) - clearSessionCookie(res) - return res + const res = NextResponse.json({ user: null }); + clearSessionCookie(res, requestHost ?? undefined); + return res; } - const rawUser = data.user as AccountUser + const rawUser = data.user as AccountUser; const identifier = - typeof rawUser.uuid === 'string' && rawUser.uuid.trim().length > 0 + typeof rawUser.uuid === "string" && rawUser.uuid.trim().length > 0 ? rawUser.uuid.trim() - : typeof rawUser.id === 'string' + : typeof rawUser.id === "string" ? rawUser.id.trim() - : undefined + : undefined; - const rawMfa = rawUser.mfa ?? {} - const derivedMfaEnabled = Boolean(rawUser.mfaEnabled ?? rawMfa.totpEnabled) + const rawMfa = rawUser.mfa ?? {}; + const derivedMfaEnabled = Boolean(rawUser.mfaEnabled ?? rawMfa.totpEnabled); const derivedMfaPendingSource = - typeof rawUser.mfaPending === 'boolean' + typeof rawUser.mfaPending === "boolean" ? rawUser.mfaPending - : typeof rawMfa.totpPending === 'boolean' + : typeof rawMfa.totpPending === "boolean" ? rawMfa.totpPending - : false - const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled + : false; + const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled; - const normalizedRole = normalizeRole(rawUser.role) - const rawRole = typeof rawUser.role === 'string' ? rawUser.role.trim().toLowerCase() : '' + const normalizedRole = normalizeRole(rawUser.role); + const rawRole = + typeof rawUser.role === "string" ? rawUser.role.trim().toLowerCase() : ""; const normalizedGroups = Array.isArray(rawUser.groups) ? rawUser.groups - .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) - .map((value) => value.trim()) - : [] + .filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ) + .map((value) => value.trim()) + : []; const normalizedPermissions = Array.isArray(rawUser.permissions) ? rawUser.permissions - .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) - .map((value) => value.trim()) - : [] - const normalizedUsernameLower = String(rawUser.username ?? '').trim().toLowerCase() - const normalizedNameLower = String(rawUser.name ?? '').trim().toLowerCase() - const identifierLower = (identifier ?? '').toLowerCase() + .filter( + (value): value is string => + typeof value === "string" && value.trim().length > 0, + ) + .map((value) => value.trim()) + : []; + const normalizedUsernameLower = String(rawUser.username ?? "") + .trim() + .toLowerCase(); + const normalizedNameLower = String(rawUser.name ?? "") + .trim() + .toLowerCase(); + const identifierLower = (identifier ?? "").toLowerCase(); const normalizedReadOnly = Boolean(rawUser.readOnly) || - normalizedGroups.some((group) => group.toLowerCase() === 'readonly role') || - rawRole === 'readonly' || - rawRole === 'read_only' || - String(rawUser.email ?? '').trim().toLowerCase() === 'sandbox@svc.plus' + normalizedGroups.some((group) => group.toLowerCase() === "readonly role") || + rawRole === "readonly" || + rawRole === "read_only" || + String(rawUser.email ?? "") + .trim() + .toLowerCase() === "sandbox@svc.plus"; const normalizedProxyUuid = - typeof rawUser.proxyUuid === 'string' && rawUser.proxyUuid.trim().length > 0 + typeof rawUser.proxyUuid === "string" && rawUser.proxyUuid.trim().length > 0 ? rawUser.proxyUuid.trim() - : undefined + : undefined; const normalizedProxyUuidExpiresAt = - typeof rawUser.proxyUuidExpiresAt === 'string' && rawUser.proxyUuidExpiresAt.trim().length > 0 + typeof rawUser.proxyUuidExpiresAt === "string" && + rawUser.proxyUuidExpiresAt.trim().length > 0 ? rawUser.proxyUuidExpiresAt.trim() - : undefined + : undefined; const normalizedTenantId = - typeof rawUser.tenantId === 'string' && rawUser.tenantId.trim().length > 0 + typeof rawUser.tenantId === "string" && rawUser.tenantId.trim().length > 0 ? rawUser.tenantId.trim() - : undefined + : undefined; const normalizedTenants = Array.isArray(rawUser.tenants) ? rawUser.tenants - .map((tenant) => { - if (!tenant || typeof tenant !== 'object') { - return null - } + .map((tenant) => { + if (!tenant || typeof tenant !== "object") { + return null; + } - const identifier = - typeof tenant.id === 'string' && tenant.id.trim().length > 0 - ? tenant.id.trim() - : undefined - if (!identifier) { - return null - } + const identifier = + typeof tenant.id === "string" && tenant.id.trim().length > 0 + ? tenant.id.trim() + : undefined; + if (!identifier) { + return null; + } - const normalizedTenant: { id: string; name?: string; role?: string } = { - id: identifier, - } + const normalizedTenant: { id: string; name?: string; role?: string } = + { + id: identifier, + }; - if (typeof tenant.name === 'string' && tenant.name.trim().length > 0) { - normalizedTenant.name = tenant.name.trim() - } + if ( + typeof tenant.name === "string" && + tenant.name.trim().length > 0 + ) { + normalizedTenant.name = tenant.name.trim(); + } - if (typeof tenant.role === 'string' && tenant.role.trim().length > 0) { - normalizedTenant.role = tenant.role.trim().toLowerCase() - } + if ( + typeof tenant.role === "string" && + tenant.role.trim().length > 0 + ) { + normalizedTenant.role = tenant.role.trim().toLowerCase(); + } - return normalizedTenant - }) - .filter((tenant): tenant is { id: string; name?: string; role?: string } => Boolean(tenant)) - : undefined + return normalizedTenant; + }) + .filter( + (tenant): tenant is { id: string; name?: string; role?: string } => + Boolean(tenant), + ) + : undefined; const normalizedMfa = Object.keys(rawMfa).length ? { - ...rawMfa, - totpEnabled: Boolean(rawMfa.totpEnabled ?? derivedMfaEnabled), - totpPending: Boolean(rawMfa.totpPending ?? derivedMfaPending), - } + ...rawMfa, + totpEnabled: Boolean(rawMfa.totpEnabled ?? derivedMfaEnabled), + totpPending: Boolean(rawMfa.totpPending ?? derivedMfaPending), + } : { - totpEnabled: derivedMfaEnabled, - totpPending: derivedMfaPending, - } + totpEnabled: derivedMfaEnabled, + totpPending: derivedMfaPending, + }; - const normalizedUser = identifier ? { ...rawUser, id: identifier, uuid: identifier } : rawUser + const normalizedUser = identifier + ? { ...rawUser, id: identifier, uuid: identifier } + : rawUser; return NextResponse.json({ user: { @@ -263,24 +305,24 @@ export async function GET(request: NextRequest) { tenantId: normalizedTenantId, tenants: normalizedTenants, }, - }) + }); } export async function DELETE(request: NextRequest) { - void request - const cookieStore = await cookies() - const token = cookieStore.get(SESSION_COOKIE_NAME)?.value + void request; + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; if (token) { await fetch(`${ACCOUNT_API_BASE}/session`, { - method: 'DELETE', + method: "DELETE", headers: { Authorization: `Bearer ${token}`, }, - cache: 'no-store', - }).catch(() => null) + cache: "no-store", + }).catch(() => null); } - const response = NextResponse.json({ success: true }) - clearSessionCookie(response) - return response + const response = NextResponse.json({ success: true }); + clearSessionCookie(response, request.headers.get("host") ?? undefined); + return response; } diff --git a/src/app/api/auth/token/exchange/route.ts b/src/app/api/auth/token/exchange/route.ts index 7b506f2..45ae789 100644 --- a/src/app/api/auth/token/exchange/route.ts +++ b/src/app/api/auth/token/exchange/route.ts @@ -1,53 +1,74 @@ -import { NextRequest, NextResponse } from 'next/server' -import { applySessionCookie, deriveMaxAgeFromExpires } from '@lib/authGateway' -import { getAccountServiceApiBaseUrl } from '@server/serviceConfig' +import { NextRequest, NextResponse } from "next/server"; +import { applySessionCookie, deriveMaxAgeFromExpires } from "@lib/authGateway"; +import { getAccountServiceApiBaseUrl } from "@server/serviceConfig"; -const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); export async function POST(request: NextRequest) { - try { - const payload = await request.json() - const { exchangeCode } = payload + try { + const payload = await request.json(); + const { exchangeCode } = payload; - if (!exchangeCode || typeof exchangeCode !== 'string') { - return NextResponse.json({ success: false, error: 'invalid_request' }, { status: 400 }) - } - - const response = await fetch(`${ACCOUNT_API_BASE}/token/exchange`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - exchange_code: exchangeCode, - }), - cache: 'no-store', - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - return NextResponse.json({ success: false, error: errorData.error || 'exchange_failed' }, { status: response.status }) - } - - const data = await response.json() - const sessionToken = typeof data.token === 'string' && data.token.trim().length > 0 - ? data.token.trim() - : typeof data.access_token === 'string' && data.access_token.trim().length > 0 - ? data.access_token.trim() - : '' - - if (!sessionToken) { - return NextResponse.json({ success: false, error: 'invalid_response' }, { status: 502 }) - } - - const result = NextResponse.json({ success: true }) - const maxAge = - typeof data.expires_in === 'number' ? data.expires_in : deriveMaxAgeFromExpires(data.expiresAt) - applySessionCookie(result, sessionToken, maxAge) - - return result - } catch (error) { - console.error('Token exchange proxy failed', error) - return NextResponse.json({ success: false, error: 'internal_error' }, { status: 500 }) + if (!exchangeCode || typeof exchangeCode !== "string") { + return NextResponse.json( + { success: false, error: "invalid_request" }, + { status: 400 }, + ); } + + const response = await fetch(`${ACCOUNT_API_BASE}/token/exchange`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + exchange_code: exchangeCode, + }), + cache: "no-store", + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return NextResponse.json( + { success: false, error: errorData.error || "exchange_failed" }, + { status: response.status }, + ); + } + + const data = await response.json(); + const sessionToken = + typeof data.token === "string" && data.token.trim().length > 0 + ? data.token.trim() + : typeof data.access_token === "string" && + data.access_token.trim().length > 0 + ? data.access_token.trim() + : ""; + + if (!sessionToken) { + return NextResponse.json( + { success: false, error: "invalid_response" }, + { status: 502 }, + ); + } + + const result = NextResponse.json({ success: true }); + const maxAge = + typeof data.expires_in === "number" + ? data.expires_in + : deriveMaxAgeFromExpires(data.expiresAt); + applySessionCookie( + result, + sessionToken, + maxAge, + request.headers.get("host") ?? undefined, + ); + + return result; + } catch (error) { + console.error("Token exchange proxy failed", error); + return NextResponse.json( + { success: false, error: "internal_error" }, + { status: 500 }, + ); + } } diff --git a/src/app/api/sandbox/assume/revert/route.ts b/src/app/api/sandbox/assume/revert/route.ts index 4f1cec0..e9c9d0b 100644 --- a/src/app/api/sandbox/assume/revert/route.ts +++ b/src/app/api/sandbox/assume/revert/route.ts @@ -1,82 +1,97 @@ -export const dynamic = 'force-dynamic' +export const dynamic = "force-dynamic"; -import { NextRequest, NextResponse } from 'next/server' +import { NextRequest, NextResponse } from "next/server"; -import { applySessionCookie } from '@lib/authGateway' -import { getAccountServiceApiBaseUrl } from '@server/serviceConfig' +import { applySessionCookie } from "@lib/authGateway"; +import { getAccountServiceApiBaseUrl } from "@server/serviceConfig"; -const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); -const ROOT_BACKUP_COOKIE = 'xc_session_root' +const ROOT_BACKUP_COOKIE = "xc_session_root"; type ErrorPayload = { - error: string -} + error: string; +}; function secureCookies(): boolean { - if (process.env.NODE_ENV === 'production') { - return true + if (process.env.NODE_ENV === "production") { + return true; } - const baseUrl = process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || '' - return baseUrl.toLowerCase().startsWith('https://') + const baseUrl = + process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || ""; + return baseUrl.toLowerCase().startsWith("https://"); } async function verifyRootToken(token: string): Promise { try { const res = await fetch(`${ACCOUNT_API_BASE}/session`, { - method: 'GET', + method: "GET", headers: { Authorization: `Bearer ${token}`, - Accept: 'application/json', + Accept: "application/json", }, - cache: 'no-store', - }) + cache: "no-store", + }); if (!res.ok) { - return false + return false; } - const payload = (await res.json().catch(() => null)) as any - const email = typeof payload?.user?.email === 'string' ? payload.user.email.trim().toLowerCase() : '' - return email === 'admin@svc.plus' + const payload = (await res.json().catch(() => null)) as any; + const email = + typeof payload?.user?.email === "string" + ? payload.user.email.trim().toLowerCase() + : ""; + return email === "admin@svc.plus"; } catch { - return false + return false; } } export async function POST(request: NextRequest) { - const rootToken = request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim() ?? '' + const rootToken = + request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim() ?? ""; if (!rootToken) { - return NextResponse.json({ error: 'not_assuming' }, { status: 400 }) + return NextResponse.json( + { error: "not_assuming" }, + { status: 400 }, + ); } if (!(await verifyRootToken(rootToken))) { - return NextResponse.json({ error: 'root_token_invalid' }, { status: 403 }) + return NextResponse.json( + { error: "root_token_invalid" }, + { status: 403 }, + ); } // Best-effort audit log on accounts.svc.plus. (Cookies are owned by console.) try { await fetch(`${ACCOUNT_API_BASE}/admin/assume/revert`, { - method: 'POST', + method: "POST", headers: { Authorization: `Bearer ${rootToken}`, - Accept: 'application/json', + Accept: "application/json", }, - cache: 'no-store', - }) + cache: "no-store", + }); } catch (error) { - console.error('Failed to audit assume revert', error) + console.error("Failed to audit assume revert", error); } - const response = NextResponse.json({ ok: true }) - applySessionCookie(response, rootToken) + const response = NextResponse.json({ ok: true }); + applySessionCookie( + response, + rootToken, + undefined, + request.headers.get("host") ?? undefined, + ); response.cookies.set({ name: ROOT_BACKUP_COOKIE, - value: '', + value: "", httpOnly: true, secure: secureCookies(), - sameSite: 'lax', - path: '/', + sameSite: "lax", + path: "/", maxAge: 0, - }) - return response + }); + return response; } - diff --git a/src/app/api/sandbox/assume/route.ts b/src/app/api/sandbox/assume/route.ts index 8007147..d1594dc 100644 --- a/src/app/api/sandbox/assume/route.ts +++ b/src/app/api/sandbox/assume/route.ts @@ -1,76 +1,90 @@ -export const dynamic = 'force-dynamic' +export const dynamic = "force-dynamic"; -import { NextRequest, NextResponse } from 'next/server' +import { NextRequest, NextResponse } from "next/server"; -import { applySessionCookie, deriveMaxAgeFromExpires } from '@lib/authGateway' -import { evaluateAccountAdminAccess } from '@server/account/adminAccess' -import { getAccountServiceApiBaseUrl } from '@server/serviceConfig' -import { getAccountSession } from '@server/account/session' -import type { AccountUserRole } from '@server/account/session' +import { applySessionCookie, deriveMaxAgeFromExpires } from "@lib/authGateway"; +import { evaluateAccountAdminAccess } from "@server/account/adminAccess"; +import { getAccountServiceApiBaseUrl } from "@server/serviceConfig"; +import { getAccountSession } from "@server/account/session"; +import type { AccountUserRole } from "@server/account/session"; -const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl() -const REQUIRED_ROLES: AccountUserRole[] = ['admin'] -const WRITE_PERMISSIONS = ['admin.settings.write'] +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); +const REQUIRED_ROLES: AccountUserRole[] = ["admin"]; +const WRITE_PERMISSIONS = ["admin.settings.write"]; -const ROOT_BACKUP_COOKIE = 'xc_session_root' -const SANDBOX_EMAIL = 'sandbox@svc.plus' +const ROOT_BACKUP_COOKIE = "xc_session_root"; +const SANDBOX_EMAIL = "sandbox@svc.plus"; type ErrorPayload = { - error: string -} + error: string; +}; function secureCookies(): boolean { - if (process.env.NODE_ENV === 'production') { - return true + if (process.env.NODE_ENV === "production") { + return true; } - const baseUrl = process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || '' - return baseUrl.toLowerCase().startsWith('https://') + const baseUrl = + process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || ""; + return baseUrl.toLowerCase().startsWith("https://"); } export async function POST(request: NextRequest) { - const session = await getAccountSession(request) - const user = session.user + const session = await getAccountSession(request); + const user = session.user; if (!user || !session.token) { - return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }) + return NextResponse.json( + { error: "unauthenticated" }, + { status: 401 }, + ); } const access = await evaluateAccountAdminAccess(user, { roles: REQUIRED_ROLES, permissions: WRITE_PERMISSIONS, rootOnly: true, - }) + }); if (!access.allowed) { - return NextResponse.json({ error: access.reason ?? 'forbidden' }, { status: 403 }) + return NextResponse.json( + { error: access.reason ?? "forbidden" }, + { status: 403 }, + ); } try { const upstream = await fetch(`${ACCOUNT_API_BASE}/admin/assume`, { - method: 'POST', + method: "POST", headers: { Authorization: `Bearer ${session.token}`, - Accept: 'application/json', - 'Content-Type': 'application/json', + Accept: "application/json", + "Content-Type": "application/json", }, body: JSON.stringify({ email: SANDBOX_EMAIL }), - cache: 'no-store', - }) + cache: "no-store", + }); - const contentType = upstream.headers.get('content-type') ?? '' - if (!contentType.toLowerCase().includes('application/json')) { - const text = await upstream.text().catch(() => '') + const contentType = upstream.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().includes("application/json")) { + const text = await upstream.text().catch(() => ""); return NextResponse.json( - { error: 'upstream_non_json', upstreamStatus: upstream.status, upstreamBody: text.slice(0, 2048) } as any, + { + error: "upstream_non_json", + upstreamStatus: upstream.status, + upstreamBody: text.slice(0, 2048), + } as any, { status: 502 }, - ) + ); } - const payload = (await upstream.json().catch(() => null)) as any - if (!payload || typeof payload.token !== 'string') { - return NextResponse.json({ error: 'invalid_response' }, { status: 502 }) + const payload = (await upstream.json().catch(() => null)) as any; + if (!payload || typeof payload.token !== "string") { + return NextResponse.json( + { error: "invalid_response" }, + { status: 502 }, + ); } - const response = NextResponse.json({ ok: true, assumed: SANDBOX_EMAIL }) + const response = NextResponse.json({ ok: true, assumed: SANDBOX_EMAIL }); // Backup current root session token only if it's NOT already an assumed session. // Check if the current user is NOT the sandbox user. @@ -80,18 +94,26 @@ export async function POST(request: NextRequest) { value: session.token, httpOnly: true, secure: secureCookies(), - sameSite: 'lax', - path: '/', + sameSite: "lax", + path: "/", maxAge: deriveMaxAgeFromExpires(payload.expiresAt), - }) + }); } // Switch main session to sandbox token. - applySessionCookie(response, payload.token, deriveMaxAgeFromExpires(payload.expiresAt)) + applySessionCookie( + response, + payload.token, + deriveMaxAgeFromExpires(payload.expiresAt), + request.headers.get("host") ?? undefined, + ); - return response + return response; } catch (error) { - console.error('Failed to assume sandbox', error) - return NextResponse.json({ error: 'upstream_unreachable' }, { status: 502 }) + console.error("Failed to assume sandbox", error); + return NextResponse.json( + { error: "upstream_unreachable" }, + { status: 502 }, + ); } } diff --git a/src/app/api/xworkmate/profile/route.ts b/src/app/api/xworkmate/profile/route.ts new file mode 100644 index 0000000..018f027 --- /dev/null +++ b/src/app/api/xworkmate/profile/route.ts @@ -0,0 +1,81 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +import { SESSION_COOKIE_NAME } from "@/lib/authGateway"; +import { getAccountServiceApiBaseUrl } from "@/server/serviceConfig"; + +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); + +function buildProxyHeaders( + token: string, + requestHost?: string | null, +): HeadersInit { + return { + Accept: "application/json", + Authorization: `Bearer ${token}`, + ...(requestHost && requestHost.trim().length > 0 + ? { + "X-Forwarded-Host": requestHost.trim(), + } + : {}), + }; +} + +export async function GET(request: NextRequest) { + const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim(); + if (!token) { + return NextResponse.json( + { error: "session_token_required" }, + { status: 401 }, + ); + } + + try { + const response = await fetch(`${ACCOUNT_API_BASE}/xworkmate/profile`, { + method: "GET", + headers: buildProxyHeaders(token, request.headers.get("host")), + cache: "no-store", + }); + + const payload = await response.json().catch(() => ({})); + return NextResponse.json(payload, { status: response.status }); + } catch (error) { + console.error("xworkmate profile proxy failed", error); + return NextResponse.json( + { error: "account_service_unreachable" }, + { status: 502 }, + ); + } +} + +export async function PUT(request: NextRequest) { + const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim(); + if (!token) { + return NextResponse.json( + { error: "session_token_required" }, + { status: 401 }, + ); + } + + const rawBody = await request.text(); + try { + const response = await fetch(`${ACCOUNT_API_BASE}/xworkmate/profile`, { + method: "PUT", + headers: { + ...buildProxyHeaders(token, request.headers.get("host")), + "Content-Type": "application/json", + }, + body: rawBody, + cache: "no-store", + }); + + const payload = await response.json().catch(() => ({})); + return NextResponse.json(payload, { status: response.status }); + } catch (error) { + console.error("xworkmate profile update proxy failed", error); + return NextResponse.json( + { error: "account_service_unreachable" }, + { status: 502 }, + ); + } +} diff --git a/src/app/xworkmate/admin/page.tsx b/src/app/xworkmate/admin/page.tsx new file mode 100644 index 0000000..dff0a3a --- /dev/null +++ b/src/app/xworkmate/admin/page.tsx @@ -0,0 +1,56 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { XWorkmateProfileEditor } from "@/components/xworkmate/XWorkmateProfileEditor"; +import { + buildSharedXWorkmateUrl, + isLegacyConsoleXWorkmateHost, + isSharedXWorkmateHost, + normalizeXWorkmateHost, +} from "@/lib/xworkmate/host"; +import { buildXWorkmateScopeKey } from "@/lib/xworkmate/types"; +import { getXWorkmateSessionContext } from "@/server/xworkmate/profile"; + +export const metadata = { + title: "XWorkmate Shared Integrations", + description: "Manage the shared XWorkmate integrations profile", +}; + +export default async function XWorkmateAdminPage() { + const requestHeaders = await headers(); + const requestHost = normalizeXWorkmateHost( + requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"), + ); + + if (isLegacyConsoleXWorkmateHost(requestHost)) { + redirect(buildSharedXWorkmateUrl("/xworkmate/admin")); + } + + const { user, profile } = await getXWorkmateSessionContext(requestHost); + if (!profile) { + redirect("/xworkmate"); + } + if (!isSharedXWorkmateHost(requestHost)) { + redirect("/xworkmate/integrations"); + } + if ( + profile.profileScope !== "tenant-shared" || + !profile.canEditIntegrations + ) { + redirect("/xworkmate"); + } + + const scopeKey = buildXWorkmateScopeKey(profile, user?.id, requestHost); + + return ( +
+
+ +
+
+ ); +} diff --git a/src/app/xworkmate/integrations/page.tsx b/src/app/xworkmate/integrations/page.tsx new file mode 100644 index 0000000..ba03674 --- /dev/null +++ b/src/app/xworkmate/integrations/page.tsx @@ -0,0 +1,53 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +import { XWorkmateProfileEditor } from "@/components/xworkmate/XWorkmateProfileEditor"; +import { + buildSharedXWorkmateUrl, + isLegacyConsoleXWorkmateHost, + isSharedXWorkmateHost, + normalizeXWorkmateHost, +} from "@/lib/xworkmate/host"; +import { buildXWorkmateScopeKey } from "@/lib/xworkmate/types"; +import { getXWorkmateSessionContext } from "@/server/xworkmate/profile"; + +export const metadata = { + title: "XWorkmate Personal Integrations", + description: "Manage the personal XWorkmate integrations profile", +}; + +export default async function XWorkmateIntegrationsPage() { + const requestHeaders = await headers(); + const requestHost = normalizeXWorkmateHost( + requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"), + ); + + if (isLegacyConsoleXWorkmateHost(requestHost)) { + redirect(buildSharedXWorkmateUrl("/xworkmate/integrations")); + } + + const { user, profile } = await getXWorkmateSessionContext(requestHost); + if (!profile) { + redirect("/xworkmate"); + } + if (isSharedXWorkmateHost(requestHost)) { + redirect(profile.canEditIntegrations ? "/xworkmate/admin" : "/xworkmate"); + } + if (profile.profileScope !== "user-private") { + redirect("/xworkmate"); + } + + const scopeKey = buildXWorkmateScopeKey(profile, user?.id, requestHost); + + return ( +
+
+ +
+
+ ); +} diff --git a/src/app/xworkmate/page.tsx b/src/app/xworkmate/page.tsx index c25e9c1..60fbe89 100644 --- a/src/app/xworkmate/page.tsx +++ b/src/app/xworkmate/page.tsx @@ -1,21 +1,51 @@ import { Suspense } from "react"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; import { XWorkmateLoading } from "@/app/xworkmate/XWorkmateLoading"; import { XWorkmateWorkspacePage } from "@/components/xworkmate/XWorkmateWorkspacePage"; +import { + buildSharedXWorkmateUrl, + isLegacyConsoleXWorkmateHost, + normalizeXWorkmateHost, +} from "@/lib/xworkmate/host"; +import { + buildXWorkmateScopeKey, + toXWorkmateIntegrationDefaults, +} from "@/lib/xworkmate/types"; import { getConsoleIntegrationDefaults } from "@/server/consoleIntegrations"; +import { getXWorkmateSessionContext } from "@/server/xworkmate/profile"; export const metadata = { title: "XWorkmate", description: "Online XWorkmate workspace powered by OpenClaw gateway", }; -export default function XWorkmatePage() { - const defaults = getConsoleIntegrationDefaults(); +export default async function XWorkmatePage() { + const requestHeaders = await headers(); + const requestHost = normalizeXWorkmateHost( + requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"), + ); + + if (isLegacyConsoleXWorkmateHost(requestHost)) { + redirect(buildSharedXWorkmateUrl("/xworkmate")); + } + + const { user, profile } = await getXWorkmateSessionContext(requestHost); + const defaults = profile + ? toXWorkmateIntegrationDefaults(profile) + : getConsoleIntegrationDefaults(); + const scopeKey = buildXWorkmateScopeKey(profile, user?.id, requestHost); return (
}> - +
); diff --git a/src/components/xworkmate/XWorkmateProfileEditor.tsx b/src/components/xworkmate/XWorkmateProfileEditor.tsx new file mode 100644 index 0000000..d16c026 --- /dev/null +++ b/src/components/xworkmate/XWorkmateProfileEditor.tsx @@ -0,0 +1,524 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { CheckCircle2, Loader2, RefreshCw, ShieldCheck } from "lucide-react"; +import Link from "next/link"; + +import type { IntegrationDefaults } from "@/lib/openclaw/types"; +import type { XWorkmateProfileResponse } from "@/lib/xworkmate/types"; +import { toXWorkmateIntegrationDefaults } from "@/lib/xworkmate/types"; +import { useOpenClawConsoleStore } from "@/state/openclawConsoleStore"; + +type ProbeTarget = "openclaw" | "vault" | "apisix"; + +type ProbeState = { + ok: boolean; + status?: number; + error?: string; + body?: string; +}; + +type XWorkmateProfileEditorProps = { + payload: XWorkmateProfileResponse; + scopeKey: string; + workspaceHref: string; +}; + +function StatusBadge({ ok, label }: { ok: boolean; label: string }) { + return ( + + + {label} + + ); +} + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function inputClassName(type: "input" | "textarea" = "input"): string { + return [ + "w-full rounded-[var(--radius-xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text)] outline-none transition", + "focus:border-[color:var(--color-primary)] focus:ring-2 focus:ring-[color:var(--color-primary-muted)]", + type === "textarea" ? "min-h-[120px] resize-y" : "", + ] + .filter(Boolean) + .join(" "); +} + +export function XWorkmateProfileEditor({ + payload, + scopeKey, + workspaceHref, +}: XWorkmateProfileEditorProps) { + const defaults = useMemo( + () => toXWorkmateIntegrationDefaults(payload), + [payload], + ); + const setScope = useOpenClawConsoleStore((state) => state.setScope); + const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl); + const openclawOrigin = useOpenClawConsoleStore( + (state) => state.openclawOrigin, + ); + const openclawToken = useOpenClawConsoleStore((state) => state.openclawToken); + const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl); + const vaultNamespace = useOpenClawConsoleStore( + (state) => state.vaultNamespace, + ); + const vaultToken = useOpenClawConsoleStore((state) => state.vaultToken); + const vaultSecretPath = useOpenClawConsoleStore( + (state) => state.vaultSecretPath, + ); + const vaultSecretKey = useOpenClawConsoleStore( + (state) => state.vaultSecretKey, + ); + const apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl); + const apisixToken = useOpenClawConsoleStore((state) => state.apisixToken); + const setOpenclawUrl = useOpenClawConsoleStore( + (state) => state.setOpenclawUrl, + ); + const setOpenclawOrigin = useOpenClawConsoleStore( + (state) => state.setOpenclawOrigin, + ); + const setOpenclawToken = useOpenClawConsoleStore( + (state) => state.setOpenclawToken, + ); + const setVaultUrl = useOpenClawConsoleStore((state) => state.setVaultUrl); + const setVaultNamespace = useOpenClawConsoleStore( + (state) => state.setVaultNamespace, + ); + const setVaultToken = useOpenClawConsoleStore((state) => state.setVaultToken); + const setVaultSecretPath = useOpenClawConsoleStore( + (state) => state.setVaultSecretPath, + ); + const setVaultSecretKey = useOpenClawConsoleStore( + (state) => state.setVaultSecretKey, + ); + const setApisixUrl = useOpenClawConsoleStore((state) => state.setApisixUrl); + const setApisixToken = useOpenClawConsoleStore( + (state) => state.setApisixToken, + ); + + const [saving, setSaving] = useState(false); + const [loadingTarget, setLoadingTarget] = useState(null); + const [saveState, setSaveState] = useState(""); + const [probeResults, setProbeResults] = useState< + Record + >({ + openclaw: { ok: false }, + vault: { ok: false }, + apisix: { ok: false }, + }); + + useEffect(() => { + setScope(scopeKey, defaults); + }, [defaults, scopeKey, setScope]); + + const summary = useMemo( + () => [ + { + key: "openclaw", + label: "OpenClaw", + configured: Boolean(openclawUrl.trim()), + tokenConfigured: + payload.tokenConfigured.openclaw || + Boolean(vaultSecretPath.trim()) || + Boolean(openclawToken.trim()), + }, + { + key: "vault", + label: "Vault", + configured: Boolean(vaultUrl.trim()), + tokenConfigured: + payload.tokenConfigured.vault || Boolean(vaultToken.trim()), + }, + { + key: "apisix", + label: "APISIX", + configured: Boolean(apisixUrl.trim()), + tokenConfigured: + payload.tokenConfigured.apisix || Boolean(apisixToken.trim()), + }, + ], + [ + apisixToken, + apisixUrl, + openclawToken, + openclawUrl, + payload.tokenConfigured.apisix, + payload.tokenConfigured.openclaw, + payload.tokenConfigured.vault, + vaultSecretPath, + vaultToken, + vaultUrl, + ], + ); + + async function probe(target: ProbeTarget) { + setLoadingTarget(target); + try { + const response = await fetch("/api/integrations/probe", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + target, + gatewayUrl: openclawUrl, + gatewayOrigin: openclawOrigin, + gatewayToken: openclawToken, + vaultUrl, + vaultNamespace, + vaultToken, + vaultSecretPath, + vaultSecretKey, + apisixUrl, + apisixToken, + }), + }); + + const payload = (await response.json().catch(() => ({}))) as ProbeState; + setProbeResults((current) => ({ + ...current, + [target]: { + ok: Boolean(response.ok && payload.ok), + status: payload.status ?? response.status, + error: payload.error, + body: typeof payload.body === "string" ? payload.body : "", + }, + })); + } finally { + setLoadingTarget(null); + } + } + + async function saveProfile() { + setSaving(true); + setSaveState(""); + try { + const response = await fetch("/api/xworkmate/profile", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + profile: { + openclawUrl, + openclawOrigin, + vaultUrl, + vaultNamespace, + vaultSecretPath, + vaultSecretKey, + apisixUrl, + }, + }), + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(payload.error ?? "save_failed"); + } + + setSaveState("已保存配置。临时 token 仍只保留在当前浏览器会话。"); + } catch (error) { + console.error("Failed to save xworkmate profile", error); + setSaveState("保存失败,请检查权限或服务连接。"); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+
+
+ + + item.configured)} + label={`${payload.tenant.name} · ${payload.tenant.domain}`} + /> +
+
+

+ {payload.profileScope === "tenant-shared" + ? "共享集成配置" + : "我的集成配置"} +

+

+ {payload.profileScope === "tenant-shared" + ? "这组配置对 svc.plus/xworkmate 的共享工作台生效,只有管理员可编辑。" + : "这组配置只对当前租户域名下的你自己生效,不影响其他成员。"} +

+
+
+
+ + 返回工作台 + + +
+
+ {saveState ? ( +

+ {saveState} +

+ ) : null} +
+ +
+ {summary.map((item) => ( +
+
+
+

+ {item.label} +

+

+ {item.configured ? "已填写连接信息" : "等待配置"} +

+
+ +
+
+ + +
+
+ ))} +
+ +
+
+ + setOpenclawUrl(event.target.value)} + placeholder="wss://openclaw.svc.plus" + className={inputClassName()} + /> + + + setOpenclawOrigin(event.target.value)} + placeholder={`https://${payload.tenant.domain}`} + className={inputClassName()} + /> + + + setOpenclawToken(event.target.value)} + placeholder="Session token only" + className={inputClassName()} + /> + +
+
+

+ 探测 OpenClaw +

+

+ {probeResults.openclaw.error || "检查网关连接和会话 token。"} +

+
+ +
+
+ +
+ + setVaultUrl(event.target.value)} + placeholder="https://vault.svc.plus" + className={inputClassName()} + /> + + + setVaultNamespace(event.target.value)} + placeholder="admin" + className={inputClassName()} + /> + + + setVaultToken(event.target.value)} + placeholder="Session token only" + className={inputClassName()} + /> + + + setVaultSecretPath(event.target.value)} + placeholder="kv/openclaw" + className={inputClassName()} + /> + + + setVaultSecretKey(event.target.value)} + placeholder="token" + className={inputClassName()} + /> + +
+
+

+ 探测 Vault +

+

+ {probeResults.vault.error || + "验证 Vault 地址、namespace 与 token。"} +

+
+ +
+
+
+ +
+
+ + setApisixUrl(event.target.value)} + placeholder="https://ai-gateway.svc.plus" + className={inputClassName()} + /> + + + setApisixToken(event.target.value)} + placeholder="Session token only" + className={inputClassName()} + /> + +
+
+
+

+ 探测 APISIX +

+

+ {probeResults.apisix.error || + "验证 AI Gateway 地址和临时 token。"} +

+
+ +
+
+
+ ); +} diff --git a/src/components/xworkmate/XWorkmateWorkspacePage.tsx b/src/components/xworkmate/XWorkmateWorkspacePage.tsx index 1c758a6..6e53fcf 100644 --- a/src/components/xworkmate/XWorkmateWorkspacePage.tsx +++ b/src/components/xworkmate/XWorkmateWorkspacePage.tsx @@ -18,13 +18,13 @@ import { Shield, Sparkles, UserCircle2, - X, } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useLanguage } from "@/i18n/LanguageProvider"; import type { IntegrationDefaults } from "@/lib/openclaw/types"; +import type { XWorkmateProfileResponse } from "@/lib/xworkmate/types"; import { cn } from "@/lib/utils"; -import { IntegrationsConsole } from "@/modules/extensions/builtin/user-center/components/IntegrationsConsole"; import { useOpenClawConsoleStore } from "@/state/openclawConsoleStore"; type WorkspaceDestination = @@ -165,7 +165,10 @@ function createSections(isChinese: boolean): SectionDefinition[] { icon: Sparkles, tabs: [ { key: "installed", label: pickCopy(isChinese, "已安装", "Installed") }, - { key: "recommended", label: pickCopy(isChinese, "推荐", "Recommended") }, + { + key: "recommended", + label: pickCopy(isChinese, "推荐", "Recommended"), + }, { key: "clawhub", label: "ClawHub" }, ], cards: [ @@ -276,11 +279,18 @@ function createSections(isChinese: boolean): SectionDefinition[] { tabs: [ { key: "skills", label: pickCopy(isChinese, "技能", "Skills") }, { key: "templates", label: pickCopy(isChinese, "模板", "Templates") }, - { key: "connectors", label: pickCopy(isChinese, "连接器", "Connectors") }, + { + key: "connectors", + label: pickCopy(isChinese, "连接器", "Connectors"), + }, ], cards: [ { - title: pickCopy(isChinese, "模板与连接器", "Templates and Connectors"), + title: pickCopy( + isChinese, + "模板与连接器", + "Templates and Connectors", + ), description: pickCopy( isChinese, "ClawHub 不再只是技能列表,而是统一承接扩展分发。", @@ -355,7 +365,10 @@ function createSections(isChinese: boolean): SectionDefinition[] { { key: "general", label: pickCopy(isChinese, "通用", "General") }, { key: "workspace", label: pickCopy(isChinese, "工作区", "Workspace") }, { key: "gateway", label: pickCopy(isChinese, "集成", "Integrations") }, - { key: "diagnostics", label: pickCopy(isChinese, "诊断", "Diagnostics") }, + { + key: "diagnostics", + label: pickCopy(isChinese, "诊断", "Diagnostics"), + }, ], cards: [ { @@ -489,6 +502,10 @@ function AssistantHome({ prompt, onPromptChange, onOpenConnections, + primaryActionLabel, + secondaryActionLabel, + connectionHint, + actionDisabled, }: { isChinese: boolean; tabs: SectionTab[]; @@ -497,6 +514,10 @@ function AssistantHome({ prompt: string; onPromptChange: (value: string) => void; onOpenConnections: () => void; + primaryActionLabel: string; + secondaryActionLabel: string; + connectionHint?: string; + actionDisabled?: boolean; }) { return ( <> @@ -506,7 +527,9 @@ function AssistantHome({
- +

{pickCopy(isChinese, "默认任务", "Default Task")} @@ -520,7 +543,11 @@ function AssistantHome({

{tabs.map((tab, index) => ( - + ))}
@@ -545,22 +572,29 @@ function AssistantHome({ "Connect first to start chatting, create tasks, and view results in the current conversation.", )}

+ {connectionHint ? ( +

+ {connectionHint} +

+ ) : null}
@@ -581,7 +615,9 @@ function AssistantHome({
- + @@ -589,10 +625,11 @@ function AssistantHome({
@@ -625,12 +662,20 @@ function SectionOverview({

{section.tabs.map((tab, index) => ( - + ))}
- {pickCopy(isChinese, "已对齐最新桌面结构", "Aligned with latest desktop IA")} + {pickCopy( + isChinese, + "已对齐最新桌面结构", + "Aligned with latest desktop IA", + )}
@@ -650,24 +695,32 @@ function SectionOverview({ export function XWorkmateWorkspacePage({ defaults, + profile, + scopeKey, + requestHost, }: { defaults: IntegrationDefaults; + profile?: XWorkmateProfileResponse | null; + scopeKey: string; + requestHost?: string; }) { const { language } = useLanguage(); const isChinese = language === "zh"; + const router = useRouter(); const [activeSection, setActiveSection] = useState("assistant"); const [composerValue, setComposerValue] = useState(""); - const [showConnections, setShowConnections] = useState(false); + const setScope = useOpenClawConsoleStore((state) => state.setScope); const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults); const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl); const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl); const apisixUrl = useOpenClawConsoleStore((state) => state.apisixUrl); useEffect(() => { + setScope(scopeKey, defaults); applyDefaults(defaults); - }, [applyDefaults, defaults]); + }, [applyDefaults, defaults, scopeKey, setScope]); const sections = useMemo(() => createSections(isChinese), [isChinese]); const activeDefinition = @@ -678,9 +731,11 @@ export function XWorkmateWorkspacePage({ pickCopy(isChinese, "未连接目标", "No target"), ); const connected = Boolean(openclawEndpoint.trim()); - const configuredCount = [openclawEndpoint, vaultUrl || defaults.vaultUrl, apisixUrl || defaults.apisixUrl].filter( - (item) => item.trim().length > 0, - ).length; + const configuredCount = [ + openclawEndpoint, + vaultUrl || defaults.vaultUrl, + apisixUrl || defaults.apisixUrl, + ].filter((item) => item.trim().length > 0).length; const primarySections = sections.filter((section) => ["assistant", "tasks", "skills"].includes(section.key), @@ -694,6 +749,51 @@ export function XWorkmateWorkspacePage({ const footerSections = sections.filter((section) => ["settings", "account"].includes(section.key), ); + const integrationRoute = + profile?.profileScope === "tenant-shared" + ? "/xworkmate/admin" + : "/xworkmate/integrations"; + const canEditIntegrations = Boolean(profile?.canEditIntegrations); + const profileModeLabel = + profile?.profileScope === "tenant-shared" + ? pickCopy(isChinese, "共享配置", "Shared Profile") + : pickCopy(isChinese, "个人配置", "Personal Profile"); + const connectionHint = profile + ? profile.profileScope === "tenant-shared" && !profile.canEditIntegrations + ? pickCopy( + isChinese, + "当前是共享版工作台。只有管理员能修改连接配置,普通成员可直接使用已发布能力。", + "This is the shared workspace. Only administrators can change integrations, while members can use the published workspace.", + ) + : profile.profileScope === "tenant-shared" + ? pickCopy( + isChinese, + "你正在维护共享版连接配置,保存后会影响 svc.plus/xworkmate 的共享工作台。", + "You are editing the shared integrations profile for svc.plus/xworkmate.", + ) + : pickCopy( + isChinese, + "你正在使用租户独享工作台,连接配置只对当前用户生效。", + "You are using a tenant-private workspace, and the profile only affects the current member.", + ) + : pickCopy( + isChinese, + "未检测到租户配置,当前仍会回退到浏览器会话内的默认连接。", + "No tenant profile was resolved yet, so the workspace falls back to browser-session defaults.", + ); + const primaryActionLabel = canEditIntegrations + ? pickCopy(isChinese, "打开配置页", "Open Config") + : pickCopy(isChinese, "查看状态", "View Status"); + const secondaryActionLabel = canEditIntegrations + ? pickCopy(isChinese, "管理连接", "Manage Integrations") + : pickCopy(isChinese, "等待管理员配置", "Await Admin Setup"); + + const openConnections = () => { + if (!canEditIntegrations) { + return; + } + router.push(integrationRoute); + }; return (
@@ -763,6 +863,28 @@ export function XWorkmateWorkspacePage({
+ {profile ? ( +
+ + + {profile.edition === "shared_public" + ? pickCopy(isChinese, "共享版", "Shared Edition") + : pickCopy(isChinese, "租户独享版", "Tenant Edition")} + + · + {profile.tenant.name} + · + {profile.membershipRole} + · + {profileModeLabel} + {requestHost ? ( + <> + · + {requestHost} + + ) : null} +
+ ) : null} {activeSection === "assistant" ? ( setShowConnections(true)} + onOpenConnections={openConnections} + primaryActionLabel={primaryActionLabel} + secondaryActionLabel={secondaryActionLabel} + connectionHint={connectionHint} + actionDisabled={!canEditIntegrations} /> ) : ( - + )}
@@ -787,42 +916,6 @@ export function XWorkmateWorkspacePage({ ? `${pickCopy(isChinese, "在线网关", "Gateway Online")} · ${configuredCount}/3` : `${pickCopy(isChinese, "集成概况", "Integrations")} · ${configuredCount}/3`}
- - {showConnections ? ( -
-
-
-
-

- {pickCopy(isChinese, "编辑 Gateway 连接", "Edit Gateway Connections")} -

-

- {pickCopy( - isChinese, - "沿用当前在线版的配置、探测和会话级覆盖逻辑。", - "Reuse the current web configuration, probe, and session override flow.", - )} -

-
- -
- { - setActiveSection("assistant"); - setShowConnections(false); - }} - /> -
-
- ) : null} ); } diff --git a/src/lib/authGateway.ts b/src/lib/authGateway.ts index fa3451b..2e33247 100644 --- a/src/lib/authGateway.ts +++ b/src/lib/authGateway.ts @@ -1,90 +1,127 @@ -import { NextResponse } from 'next/server' +import { NextResponse } from "next/server"; -export const SESSION_COOKIE_NAME = 'xc_session' -export const MFA_COOKIE_NAME = 'xc_mfa_challenge' +export const SESSION_COOKIE_NAME = "xc_session"; +export const MFA_COOKIE_NAME = "xc_mfa_challenge"; -const SESSION_DEFAULT_MAX_AGE = 60 * 60 * 24 // 24 hours -const MFA_DEFAULT_MAX_AGE = 60 * 10 // 10 minutes +const SESSION_DEFAULT_MAX_AGE = 60 * 60 * 24; // 24 hours +const MFA_DEFAULT_MAX_AGE = 60 * 10; // 10 minutes function readEnvValue(key: string): string | undefined { - const value = process.env[key] - if (typeof value !== 'string') { - return undefined + const value = process.env[key]; + if (typeof value !== "string") { + return undefined; } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : undefined + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; } function parseBoolean(value: string | undefined): boolean | undefined { if (!value) { - return undefined + return undefined; } - const normalized = value.trim().toLowerCase() - if (['1', 'true', 'yes', 'on'].includes(normalized)) { - return true + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) { + return true; } - if (['0', 'false', 'no', 'off'].includes(normalized)) { - return false + if (["0", "false", "no", "off"].includes(normalized)) { + return false; } - return undefined + return undefined; } function shouldUseSecureCookies(): boolean { const explicit = - parseBoolean(readEnvValue('SESSION_COOKIE_SECURE')) ?? - parseBoolean(readEnvValue('NEXT_PUBLIC_SESSION_COOKIE_SECURE')) + parseBoolean(readEnvValue("SESSION_COOKIE_SECURE")) ?? + parseBoolean(readEnvValue("NEXT_PUBLIC_SESSION_COOKIE_SECURE")); if (explicit !== undefined) { - return explicit + return explicit; } - if (process.env.NODE_ENV === 'production') { - return true + if (process.env.NODE_ENV === "production") { + return true; } const baseUrl = - readEnvValue('NEXT_PUBLIC_APP_BASE_URL') ?? - readEnvValue('APP_BASE_URL') ?? - readEnvValue('NEXT_PUBLIC_SITE_URL') + readEnvValue("NEXT_PUBLIC_APP_BASE_URL") ?? + readEnvValue("APP_BASE_URL") ?? + readEnvValue("NEXT_PUBLIC_SITE_URL"); - if (typeof baseUrl === 'string' && baseUrl.toLowerCase().startsWith('https://')) { - return true + if ( + typeof baseUrl === "string" && + baseUrl.toLowerCase().startsWith("https://") + ) { + return true; } - return false + return false; } const secureCookieBase = { httpOnly: true, secure: shouldUseSecureCookies(), - sameSite: 'lax' as const, // Change to lax to support cross-subdomain - path: '/', -} + sameSite: "lax" as const, // Change to lax to support cross-subdomain + path: "/", +}; /** * Resolves the cookie domain based on the current environment. * If running on a .svc.plus subdomain, returns '.svc.plus' to allow SSO. */ -function resolveCookieDomain(): string | undefined { - if (typeof window !== 'undefined') { - const host = window.location.hostname - if (host.endsWith('.svc.plus')) { - return '.svc.plus' +function normalizeHostname(value?: string | null): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim().toLowerCase(); + if (!trimmed) { + return undefined; + } + const withoutProtocol = trimmed.replace(/^https?:\/\//, ""); + const withoutPath = withoutProtocol.split("/")[0] ?? ""; + const withoutPort = withoutPath.replace(/:\d+$/, ""); + return withoutPort || undefined; +} + +function resolveCookieDomain(requestHost?: string): string | undefined { + const normalizedRequestHost = normalizeHostname(requestHost); + if (normalizedRequestHost) { + if ( + normalizedRequestHost === "svc.plus" || + normalizedRequestHost.endsWith(".svc.plus") + ) { + return ".svc.plus"; + } + return undefined; + } + + if (typeof window !== "undefined") { + const host = window.location.hostname; + if (host.endsWith(".svc.plus")) { + return ".svc.plus"; } } // For server-side, check headers or environment - const baseUrl = process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || '' - if (baseUrl.includes('.svc.plus')) { - return '.svc.plus' + const baseUrl = + process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || ""; + if (baseUrl.includes(".svc.plus")) { + return ".svc.plus"; } - return undefined + return undefined; } -export function applySessionCookie(response: NextResponse, token: string, maxAge?: number) { - const resolvedMaxAge = Number.isFinite(maxAge) && maxAge && maxAge > 0 ? Math.floor(maxAge) : SESSION_DEFAULT_MAX_AGE - const domain = resolveCookieDomain() +export function applySessionCookie( + response: NextResponse, + token: string, + maxAge?: number, + requestHost?: string, +) { + const resolvedMaxAge = + Number.isFinite(maxAge) && maxAge && maxAge > 0 + ? Math.floor(maxAge) + : SESSION_DEFAULT_MAX_AGE; + const domain = resolveCookieDomain(requestHost); response.cookies.set({ name: SESSION_COOKIE_NAME, @@ -92,71 +129,84 @@ export function applySessionCookie(response: NextResponse, token: string, maxAge ...secureCookieBase, maxAge: resolvedMaxAge, ...(domain ? { domain } : {}), - }) + }); } -export function clearSessionCookie(response: NextResponse) { - const domain = resolveCookieDomain() +export function clearSessionCookie( + response: NextResponse, + requestHost?: string, +) { + const domain = resolveCookieDomain(requestHost); // Always clear the host-only cookie. response.cookies.set({ name: SESSION_COOKIE_NAME, - value: '', + value: "", ...secureCookieBase, maxAge: 0, - }) + }); // Also clear the domain-scoped cookie if we can resolve the domain. if (domain) { response.cookies.set({ name: SESSION_COOKIE_NAME, - value: '', + value: "", ...secureCookieBase, maxAge: 0, domain, - }) + }); } } -export function applyMfaCookie(response: NextResponse, token: string, maxAge?: number) { - const resolvedMaxAge = Number.isFinite(maxAge) && maxAge && maxAge > 0 ? Math.floor(maxAge) : MFA_DEFAULT_MAX_AGE +export function applyMfaCookie( + response: NextResponse, + token: string, + maxAge?: number, +) { + const resolvedMaxAge = + Number.isFinite(maxAge) && maxAge && maxAge > 0 + ? Math.floor(maxAge) + : MFA_DEFAULT_MAX_AGE; response.cookies.set({ name: MFA_COOKIE_NAME, value: token, ...secureCookieBase, maxAge: resolvedMaxAge, - }) + }); } export function clearMfaCookie(response: NextResponse) { // Clear host-only response.cookies.set({ name: MFA_COOKIE_NAME, - value: '', + value: "", ...secureCookieBase, maxAge: 0, - }) + }); // Clear domain-scoped if resolved - const domain = resolveCookieDomain() + const domain = resolveCookieDomain(); if (domain) { response.cookies.set({ name: MFA_COOKIE_NAME, - value: '', + value: "", ...secureCookieBase, maxAge: 0, domain, - }) + }); } } -export function deriveMaxAgeFromExpires(expiresAt?: string | number | Date | null, fallback = SESSION_DEFAULT_MAX_AGE) { +export function deriveMaxAgeFromExpires( + expiresAt?: string | number | Date | null, + fallback = SESSION_DEFAULT_MAX_AGE, +) { if (!expiresAt) { - return fallback + return fallback; } - const date = expiresAt instanceof Date ? expiresAt : new Date(expiresAt) - const msUntilExpiry = date.getTime() - Date.now() + const date = expiresAt instanceof Date ? expiresAt : new Date(expiresAt); + const msUntilExpiry = date.getTime() - Date.now(); if (!Number.isFinite(msUntilExpiry) || msUntilExpiry <= 0) { - return fallback + return fallback; } - return Math.floor(msUntilExpiry / 1000) + return Math.floor(msUntilExpiry / 1000); } diff --git a/src/lib/xworkmate/host.ts b/src/lib/xworkmate/host.ts new file mode 100644 index 0000000..660680b --- /dev/null +++ b/src/lib/xworkmate/host.ts @@ -0,0 +1,39 @@ +const SHARED_HOSTS = new Set([ + "svc.plus", + "www.svc.plus", + "console.svc.plus", + "localhost", + "127.0.0.1", + "[::1]", +]); + +export function normalizeXWorkmateHost(value?: string | null): string { + const trimmed = String(value ?? "") + .trim() + .toLowerCase(); + if (!trimmed) { + return ""; + } + + const withoutProtocol = trimmed.replace(/^https?:\/\//, ""); + const withoutPath = withoutProtocol.split("/")[0] ?? ""; + const withoutPort = withoutPath.replace(/:\d+$/, ""); + return withoutPort.replace(/\.+$/, ""); +} + +export function isSharedXWorkmateHost(host?: string | null): boolean { + const normalized = normalizeXWorkmateHost(host); + if (!normalized) { + return true; + } + return SHARED_HOSTS.has(normalized); +} + +export function isLegacyConsoleXWorkmateHost(host?: string | null): boolean { + return normalizeXWorkmateHost(host) === "console.svc.plus"; +} + +export function buildSharedXWorkmateUrl(pathname: string): string { + const normalizedPath = pathname.startsWith("/") ? pathname : `/${pathname}`; + return `https://svc.plus${normalizedPath}`; +} diff --git a/src/lib/xworkmate/types.ts b/src/lib/xworkmate/types.ts new file mode 100644 index 0000000..624463d --- /dev/null +++ b/src/lib/xworkmate/types.ts @@ -0,0 +1,76 @@ +import type { IntegrationDefaults } from "@/lib/openclaw/types"; + +export type XWorkmateEdition = "shared_public" | "tenant_private"; +export type XWorkmateProfileScope = "tenant-shared" | "user-private"; +export type XWorkmateMembershipRole = "admin" | "user"; + +export type XWorkmateProfile = { + openclawUrl: string; + openclawOrigin: string; + vaultUrl: string; + vaultNamespace: string; + vaultSecretPath: string; + vaultSecretKey: string; + apisixUrl: string; +}; + +export type XWorkmateProfileResponse = { + edition: XWorkmateEdition; + tenant: { + id: string; + name: string; + domain: string; + }; + membershipRole: XWorkmateMembershipRole; + profileScope: XWorkmateProfileScope; + canEditIntegrations: boolean; + canManageTenant: boolean; + profile: XWorkmateProfile; + tokenConfigured: { + openclaw: boolean; + vault: boolean; + apisix: boolean; + }; +}; + +export function toXWorkmateIntegrationDefaults( + payload: XWorkmateProfileResponse | null | undefined, +): IntegrationDefaults { + return { + openclawUrl: payload?.profile.openclawUrl ?? "", + openclawOrigin: payload?.profile.openclawOrigin ?? "", + openclawTokenConfigured: Boolean(payload?.tokenConfigured.openclaw), + vaultUrl: payload?.profile.vaultUrl ?? "", + vaultNamespace: payload?.profile.vaultNamespace ?? "", + vaultTokenConfigured: Boolean(payload?.tokenConfigured.vault), + vaultSecretPath: payload?.profile.vaultSecretPath ?? "", + vaultSecretKey: payload?.profile.vaultSecretKey ?? "", + apisixUrl: payload?.profile.apisixUrl ?? "", + apisixTokenConfigured: Boolean(payload?.tokenConfigured.apisix), + }; +} + +export function buildXWorkmateScopeKey( + payload: XWorkmateProfileResponse | null | undefined, + userId?: string | null, + host?: string | null, +): string { + const normalizedHost = + String(host ?? "") + .trim() + .toLowerCase() || "shared"; + const normalizedTenant = payload?.tenant.id?.trim() || "anonymous"; + const normalizedScope = payload?.profileScope?.trim() || "guest"; + const normalizedUser = + payload?.profileScope === "tenant-shared" + ? "shared" + : String(userId ?? "").trim() || "anonymous"; + + return [ + "xworkmate", + normalizedHost, + normalizedTenant, + normalizedUser, + normalizedScope, + ].join(":"); +} diff --git a/src/server/account/session.ts b/src/server/account/session.ts index 291cf5e..2a0b09c 100644 --- a/src/server/account/session.ts +++ b/src/server/account/session.ts @@ -214,6 +214,20 @@ async function resolveTokenFromRequest( return undefined; } +function resolveForwardedHost(request?: NextRequest): string | undefined { + if (!request) { + return undefined; + } + + const hostHeader = + request.headers.get("x-forwarded-host") ?? request.headers.get("host"); + if (!hostHeader) { + return undefined; + } + const trimmed = hostHeader.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + export async function userHasRole( user: AccountSessionUser | null, roles: AccountUserRole[], @@ -263,6 +277,7 @@ export async function getAccountSession( if (!token) { return { token: undefined, user: null }; } + const requestHost = resolveForwardedHost(request); try { const response = await fetch(`${ACCOUNT_API_BASE}/session`, { @@ -270,6 +285,11 @@ export async function getAccountSession( headers: { Authorization: `Bearer ${token}`, Accept: "application/json", + ...(requestHost + ? { + "X-Forwarded-Host": requestHost, + } + : {}), }, cache: "no-store", }); diff --git a/src/server/xworkmate/profile.ts b/src/server/xworkmate/profile.ts new file mode 100644 index 0000000..d2046b5 --- /dev/null +++ b/src/server/xworkmate/profile.ts @@ -0,0 +1,73 @@ +import "server-only"; + +import { cookies } from "next/headers"; + +import { SESSION_COOKIE_NAME } from "@/lib/authGateway"; +import type { AccountSessionUser } from "@/server/account/session"; +import { getAccountServiceApiBaseUrl } from "@/server/serviceConfig"; +import type { XWorkmateProfileResponse } from "@/lib/xworkmate/types"; + +const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl(); + +type AccountSessionResponse = { + user?: AccountSessionUser | null; +}; + +function buildForwardHeaders( + token: string, + host?: string | null, +): Record { + const headers: Record = { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }; + const normalizedHost = String(host ?? "").trim(); + if (normalizedHost) { + headers["X-Forwarded-Host"] = normalizedHost; + } + return headers; +} + +export async function getXWorkmateSessionContext( + host?: string | null, +): Promise<{ + user: AccountSessionUser | null; + profile: XWorkmateProfileResponse | null; +}> { + const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim(); + if (!token) { + return { user: null, profile: null }; + } + + const requestHeaders = buildForwardHeaders(token, host); + + const [sessionResponse, profileResponse] = await Promise.all([ + fetch(`${ACCOUNT_API_BASE}/session`, { + method: "GET", + headers: requestHeaders, + cache: "no-store", + }).catch(() => null), + fetch(`${ACCOUNT_API_BASE}/xworkmate/profile`, { + method: "GET", + headers: requestHeaders, + cache: "no-store", + }).catch(() => null), + ]); + + let user: AccountSessionUser | null = null; + if (sessionResponse?.ok) { + const payload = (await sessionResponse + .json() + .catch(() => null)) as AccountSessionResponse | null; + user = payload?.user ?? null; + } + + let profile: XWorkmateProfileResponse | null = null; + if (profileResponse?.ok) { + profile = (await profileResponse + .json() + .catch(() => null)) as XWorkmateProfileResponse | null; + } + + return { user, profile }; +} diff --git a/src/state/openclawConsoleStore.ts b/src/state/openclawConsoleStore.ts index 033cccb..175270e 100644 --- a/src/state/openclawConsoleStore.ts +++ b/src/state/openclawConsoleStore.ts @@ -1,108 +1,264 @@ -'use client' +"use client"; -import { create } from 'zustand' -import { createJSONStorage, persist } from 'zustand/middleware' +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; -import type { AssistantMode, IntegrationDefaults, ThinkingLevel } from '@/lib/openclaw/types' +import type { + AssistantMode, + IntegrationDefaults, + ThinkingLevel, +} from "@/lib/openclaw/types"; -type OpenClawConsoleState = { - defaultsLoaded: boolean - openclawUrl: string - openclawOrigin: string - openclawToken: string - vaultUrl: string - vaultNamespace: string - vaultToken: string - vaultSecretPath: string - vaultSecretKey: string - apisixUrl: string - apisixToken: string - assistantMode: AssistantMode - thinking: ThinkingLevel - selectedAgentId: string - selectedSessionKey: string - applyDefaults: (defaults: IntegrationDefaults) => void - setOpenclawUrl: (value: string) => void - setOpenclawOrigin: (value: string) => void - setOpenclawToken: (value: string) => void - setVaultUrl: (value: string) => void - setVaultNamespace: (value: string) => void - setVaultToken: (value: string) => void - setVaultSecretPath: (value: string) => void - setVaultSecretKey: (value: string) => void - setApisixUrl: (value: string) => void - setApisixToken: (value: string) => void - setAssistantMode: (value: AssistantMode) => void - setThinking: (value: ThinkingLevel) => void - setSelectedAgentId: (value: string) => void - setSelectedSessionKey: (value: string) => void +type OpenClawScopedSnapshot = { + openclawUrl: string; + openclawOrigin: string; + openclawToken: string; + vaultUrl: string; + vaultNamespace: string; + vaultToken: string; + vaultSecretPath: string; + vaultSecretKey: string; + apisixUrl: string; + apisixToken: string; + assistantMode: AssistantMode; + thinking: ThinkingLevel; + selectedAgentId: string; + selectedSessionKey: string; +}; + +type OpenClawConsoleState = OpenClawScopedSnapshot & { + defaultsLoaded: boolean; + scopeKey: string; + scopedSessions: Record; + applyDefaults: (defaults: IntegrationDefaults) => void; + setScope: (scopeKey: string, defaults?: IntegrationDefaults) => void; + setOpenclawUrl: (value: string) => void; + setOpenclawOrigin: (value: string) => void; + setOpenclawToken: (value: string) => void; + setVaultUrl: (value: string) => void; + setVaultNamespace: (value: string) => void; + setVaultToken: (value: string) => void; + setVaultSecretPath: (value: string) => void; + setVaultSecretKey: (value: string) => void; + setApisixUrl: (value: string) => void; + setApisixToken: (value: string) => void; + setAssistantMode: (value: AssistantMode) => void; + setThinking: (value: ThinkingLevel) => void; + setSelectedAgentId: (value: string) => void; + setSelectedSessionKey: (value: string) => void; +}; + +const DEFAULT_SCOPE_KEY = "global"; + +const EMPTY_SCOPE: OpenClawScopedSnapshot = { + openclawUrl: "", + openclawOrigin: "", + openclawToken: "", + vaultUrl: "", + vaultNamespace: "", + vaultToken: "", + vaultSecretPath: "", + vaultSecretKey: "", + apisixUrl: "", + apisixToken: "", + assistantMode: "ask", + thinking: "high", + selectedAgentId: "", + selectedSessionKey: "", +}; + +function normalizeScopeKey(value: string): string { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : DEFAULT_SCOPE_KEY; +} + +function buildScopedDefaults( + defaults?: IntegrationDefaults, +): OpenClawScopedSnapshot { + return { + ...EMPTY_SCOPE, + openclawUrl: defaults?.openclawUrl ?? "", + openclawOrigin: defaults?.openclawOrigin ?? "", + vaultUrl: defaults?.vaultUrl ?? "", + vaultNamespace: defaults?.vaultNamespace ?? "", + vaultSecretPath: defaults?.vaultSecretPath ?? "", + vaultSecretKey: defaults?.vaultSecretKey ?? "", + apisixUrl: defaults?.apisixUrl ?? "", + }; +} + +function mergeScopeSnapshot( + snapshot: OpenClawScopedSnapshot | undefined, + defaults?: IntegrationDefaults, +): OpenClawScopedSnapshot { + const base = buildScopedDefaults(defaults); + if (!snapshot) { + return base; + } + + return { + ...snapshot, + openclawUrl: snapshot.openclawUrl || base.openclawUrl, + openclawOrigin: snapshot.openclawOrigin || base.openclawOrigin, + vaultUrl: snapshot.vaultUrl || base.vaultUrl, + vaultNamespace: snapshot.vaultNamespace || base.vaultNamespace, + vaultSecretPath: snapshot.vaultSecretPath || base.vaultSecretPath, + vaultSecretKey: snapshot.vaultSecretKey || base.vaultSecretKey, + apisixUrl: snapshot.apisixUrl || base.apisixUrl, + }; +} + +function snapshotFromState( + state: OpenClawConsoleState, +): OpenClawScopedSnapshot { + return { + openclawUrl: state.openclawUrl, + openclawOrigin: state.openclawOrigin, + openclawToken: state.openclawToken, + vaultUrl: state.vaultUrl, + vaultNamespace: state.vaultNamespace, + vaultToken: state.vaultToken, + vaultSecretPath: state.vaultSecretPath, + vaultSecretKey: state.vaultSecretKey, + apisixUrl: state.apisixUrl, + apisixToken: state.apisixToken, + assistantMode: state.assistantMode, + thinking: state.thinking, + selectedAgentId: state.selectedAgentId, + selectedSessionKey: state.selectedSessionKey, + }; } export const useOpenClawConsoleStore = create()( persist( - (set, get) => ({ - defaultsLoaded: false, - openclawUrl: '', - openclawOrigin: '', - openclawToken: '', - vaultUrl: '', - vaultNamespace: '', - vaultToken: '', - vaultSecretPath: '', - vaultSecretKey: '', - apisixUrl: '', - apisixToken: '', - assistantMode: 'ask', - thinking: 'high', - selectedAgentId: '', - selectedSessionKey: '', - applyDefaults: (defaults) => { - const current = get() + (set, get) => { + const updateScopedSession = ( + partial: Partial, + options?: { defaultsLoaded?: boolean }, + ) => { + const current = get(); + const scopeKey = normalizeScopeKey(current.scopeKey); + const currentSnapshot = mergeScopeSnapshot( + current.scopedSessions[scopeKey], + ); + const nextSnapshot = { + ...currentSnapshot, + ...partial, + }; + set({ - defaultsLoaded: true, - openclawUrl: current.openclawUrl || defaults.openclawUrl, - openclawOrigin: current.openclawOrigin || defaults.openclawOrigin, - vaultUrl: current.vaultUrl || defaults.vaultUrl, - vaultNamespace: current.vaultNamespace || defaults.vaultNamespace, - vaultSecretPath: current.vaultSecretPath || defaults.vaultSecretPath, - vaultSecretKey: current.vaultSecretKey || defaults.vaultSecretKey, - apisixUrl: current.apisixUrl || defaults.apisixUrl, - }) - }, - setOpenclawUrl: (openclawUrl) => set({ openclawUrl }), - setOpenclawOrigin: (openclawOrigin) => set({ openclawOrigin }), - setOpenclawToken: (openclawToken) => set({ openclawToken }), - setVaultUrl: (vaultUrl) => set({ vaultUrl }), - setVaultNamespace: (vaultNamespace) => set({ vaultNamespace }), - setVaultToken: (vaultToken) => set({ vaultToken }), - setVaultSecretPath: (vaultSecretPath) => set({ vaultSecretPath }), - setVaultSecretKey: (vaultSecretKey) => set({ vaultSecretKey }), - setApisixUrl: (apisixUrl) => set({ apisixUrl }), - setApisixToken: (apisixToken) => set({ apisixToken }), - setAssistantMode: (assistantMode) => set({ assistantMode }), - setThinking: (thinking) => set({ thinking }), - setSelectedAgentId: (selectedAgentId) => set({ selectedAgentId }), - setSelectedSessionKey: (selectedSessionKey) => set({ selectedSessionKey }), - }), + ...partial, + defaultsLoaded: + options?.defaultsLoaded !== undefined + ? options.defaultsLoaded + : current.defaultsLoaded, + scopedSessions: { + ...current.scopedSessions, + [scopeKey]: nextSnapshot, + }, + }); + }; + + return { + defaultsLoaded: false, + scopeKey: DEFAULT_SCOPE_KEY, + scopedSessions: { + [DEFAULT_SCOPE_KEY]: EMPTY_SCOPE, + }, + ...EMPTY_SCOPE, + applyDefaults: (defaults) => { + const current = get(); + const scopeKey = normalizeScopeKey(current.scopeKey); + const nextSnapshot = mergeScopeSnapshot( + current.scopedSessions[scopeKey], + defaults, + ); + set({ + defaultsLoaded: true, + ...nextSnapshot, + scopedSessions: { + ...current.scopedSessions, + [scopeKey]: nextSnapshot, + }, + }); + }, + setScope: (scopeKey, defaults) => { + const current = get(); + const normalizedScopeKey = normalizeScopeKey(scopeKey); + const nextSnapshot = mergeScopeSnapshot( + current.scopedSessions[normalizedScopeKey], + defaults, + ); + + set({ + scopeKey: normalizedScopeKey, + defaultsLoaded: current.defaultsLoaded || Boolean(defaults), + ...nextSnapshot, + scopedSessions: { + ...current.scopedSessions, + [normalizedScopeKey]: nextSnapshot, + }, + }); + }, + setOpenclawUrl: (openclawUrl) => updateScopedSession({ openclawUrl }), + setOpenclawOrigin: (openclawOrigin) => + updateScopedSession({ openclawOrigin }), + setOpenclawToken: (openclawToken) => + updateScopedSession({ openclawToken }), + setVaultUrl: (vaultUrl) => updateScopedSession({ vaultUrl }), + setVaultNamespace: (vaultNamespace) => + updateScopedSession({ vaultNamespace }), + setVaultToken: (vaultToken) => updateScopedSession({ vaultToken }), + setVaultSecretPath: (vaultSecretPath) => + updateScopedSession({ vaultSecretPath }), + setVaultSecretKey: (vaultSecretKey) => + updateScopedSession({ vaultSecretKey }), + setApisixUrl: (apisixUrl) => updateScopedSession({ apisixUrl }), + setApisixToken: (apisixToken) => updateScopedSession({ apisixToken }), + setAssistantMode: (assistantMode) => + updateScopedSession({ assistantMode }), + setThinking: (thinking) => updateScopedSession({ thinking }), + setSelectedAgentId: (selectedAgentId) => + updateScopedSession({ selectedAgentId }), + setSelectedSessionKey: (selectedSessionKey) => + updateScopedSession({ selectedSessionKey }), + }; + }, { - name: 'openclaw-console-session', + name: "openclaw-console-session", storage: createJSONStorage(() => sessionStorage), partialize: (state) => ({ - openclawUrl: state.openclawUrl, - openclawOrigin: state.openclawOrigin, - openclawToken: state.openclawToken, - vaultUrl: state.vaultUrl, - vaultNamespace: state.vaultNamespace, - vaultToken: state.vaultToken, - vaultSecretPath: state.vaultSecretPath, - vaultSecretKey: state.vaultSecretKey, - apisixUrl: state.apisixUrl, - apisixToken: state.apisixToken, - assistantMode: state.assistantMode, - thinking: state.thinking, - selectedAgentId: state.selectedAgentId, - selectedSessionKey: state.selectedSessionKey, + scopeKey: state.scopeKey, + scopedSessions: state.scopedSessions, + defaultsLoaded: state.defaultsLoaded, + ...snapshotFromState(state), }), + merge: (persistedState, currentState) => { + const persisted = persistedState as + | Partial + | undefined; + const mergedState = { + ...currentState, + ...persisted, + } as OpenClawConsoleState; + + const mergedScopeKey = normalizeScopeKey(mergedState.scopeKey); + const hydratedSnapshot = mergeScopeSnapshot( + mergedState.scopedSessions?.[mergedScopeKey] ?? + snapshotFromState(mergedState), + ); + + return { + ...mergedState, + scopeKey: mergedScopeKey, + scopedSessions: { + [DEFAULT_SCOPE_KEY]: EMPTY_SCOPE, + ...(mergedState.scopedSessions ?? {}), + [mergedScopeKey]: hydratedSnapshot, + }, + ...hydratedSnapshot, + }; + }, }, ), -) +); From e62df8322cdefc94c16c9c10048eb5edd76c50ea Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 17 Mar 2026 16:22:42 +0800 Subject: [PATCH 02/10] fix(auth): align console MFA proxy with accounts contract --- docs/usage/deployment.md | 149 ++++++++++++++++++++++++++- src/app/api/auth/login/route.ts | 21 +++- src/app/api/auth/mfa/verify/route.ts | 16 ++- 3 files changed, 173 insertions(+), 13 deletions(-) diff --git a/docs/usage/deployment.md b/docs/usage/deployment.md index 8fd0e9e..8a11d05 100644 --- a/docs/usage/deployment.md +++ b/docs/usage/deployment.md @@ -1,9 +1,148 @@ -# Deployment +# Deployment Runbook -## Purpose +## Scope -- TODO: Add content specific to Deployment. +- Runtime: `console.svc.plus` +- Frontend host: Vercel +- Edge: Cloudflare +- Auth backend: `https://accounts.svc.plus` -## Notes +This runbook is the minimum checklist for production incidents where login or MFA stops working and browser devtools show `/api/auth/login` or `/api/auth/mfa/*` failures. -- TODO: Link to related documents in this section. +## Expected Request Flow + +1. Browser loads `https://console.svc.plus/login` +2. Browser calls same-origin Next routes on `console.svc.plus` +3. Next route proxies server-side to `https://accounts.svc.plus/api/auth/*` +4. `accounts.svc.plus` returns either a session token or an MFA challenge + +The browser should not call `accounts.svc.plus` directly for login. + +## Fast Triage + +Run these checks first: + +```bash +curl -si https://console.svc.plus/login | sed -n '1,20p' +curl -si https://console.svc.plus/api/auth/login | sed -n '1,20p' +curl -si https://accounts.svc.plus/healthz | sed -n '1,20p' +curl -si https://accounts.svc.plus/api/auth/login | sed -n '1,20p' +``` + +Interpretation: + +- `console.svc.plus` returns `403` with `cf-mitigated: challenge` + Cloudflare is blocking the page or auth API before Vercel sees it. +- `console.svc.plus/api/auth/login` returns `404` + Vercel production is not serving the expected Next route, or Cloudflare is pointing at the wrong origin/deployment behavior. +- `accounts.svc.plus/healthz` fails + Back-end outage. Fix backend first. +- `accounts.svc.plus/api/auth/login` returns `200` with `mfaRequired` + Backend is healthy; continue on console/Vercel/Cloudflare. + +## Application Checks + +Verify the current build still contains the auth routes: + +```bash +cd /Users/shenlan/workspaces/cloud-neutral-toolkit/console.svc.plus +yarn build +cat .next/app-path-routes-manifest.json | jq 'with_entries(select(.key|test("/api/auth/")))' +``` + +Verify the login page still uses same-origin routes: + +```bash +nl -ba 'src/app/(auth)/login/LoginForm.tsx' | sed -n '64,180p' +nl -ba 'src/app/api/auth/login/route.ts' | sed -n '1,180p' +nl -ba 'src/app/api/auth/mfa/verify/route.ts' | sed -n '1,180p' +``` + +Expected behavior: + +- `LoginForm` posts to `/api/auth/login` +- login proxy accepts backend `mfaRequired` / `mfaTicket` +- MFA verify proxy calls `/api/auth/mfa/verify` + +## Vercel Checks + +In the Vercel project for `console-svc-plus`, verify: + +1. The production deployment corresponds to the intended git commit. +2. Framework preset is `Next.js`. +3. Build command is `yarn build` or the project default, not a static export command. +4. Output is not being overridden to static export. +5. Production Functions include `app/api/auth/login` and the other `app/api/auth/*` handlers. +6. Required runtime env vars are present for the auth proxy path if they are managed in Vercel. + +If the route exists locally but Vercel returns `404`, suspect: + +- wrong production deployment selected +- wrong root directory/project link +- stale alias or domain assignment +- build output mismatch between local and Vercel + +## Cloudflare Checks + +If `curl` shows `cf-mitigated: challenge`, check Cloudflare first. + +Look for: + +1. Managed Challenge or WAF custom rules affecting `/login` +2. Managed Challenge or WAF custom rules affecting `/api/auth/*` +3. Bot Fight Mode or Super Bot Fight Mode interactions +4. Transform/redirect/cache rules that alter `/api/auth/*` +5. Page Rules or Ruleset Engine policies applied only to the production hostname + +Recommended policy for auth API: + +- Do not cache `/api/auth/*` +- Do not apply JS challenge to `/api/auth/*` +- Keep standard security headers, but let requests reach Vercel + +## Backend Verification + +Use the backend directly to prove whether auth is healthy: + +```bash +cd /Users/shenlan/workspaces/cloud-neutral-toolkit/accounts.svc.plus +set -a; source .env; set +a +payload=$(printf '{"identifier":"admin@svc.plus","password":"%s"}' "$SUPERADMIN_PASSWORD") +curl -sS -X POST https://accounts.svc.plus/api/auth/login \ + -H 'Content-Type: application/json' \ + -d "$payload" +``` + +Expected for an MFA-enabled admin: + +- HTTP `200` +- response contains `mfaRequired` +- response contains `mfaTicket` or `mfaToken` + +## Known Failure Signatures + +- `POST https://console.svc.plus/api/auth/login 404` + Likely Vercel deployment mismatch or route not published. +- `403` with `cf-mitigated: challenge` + Cloudflare blocked request before Vercel. +- login returns generic failure even though backend returns MFA challenge + Console auth proxy is not parsing MFA fields correctly. +- MFA code accepted by authenticator but web login still fails + Console proxy may be calling the setup endpoint instead of the login MFA endpoint. + +## Rollback Strategy + +When a release breaks auth: + +1. Remove or relax Cloudflare rules affecting `/login` and `/api/auth/*` +2. Re-point domain to last known-good Vercel production deployment +3. Roll back `console.svc.plus` +4. Only then consider `accounts.svc.plus` rollback + +## Related Files + +- `src/app/(auth)/login/LoginForm.tsx` +- `src/app/api/auth/login/route.ts` +- `src/app/api/auth/mfa/status/route.ts` +- `src/app/api/auth/mfa/verify/route.ts` +- `src/server/serviceConfig.ts` diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 042553a..159d24b 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -27,7 +27,9 @@ type AccountLoginResponse = { expiresAt?: string; error?: string; mfaToken?: string; + mfaTicket?: string; needMfa?: boolean; + mfaRequired?: boolean; mfaEnabled?: boolean; }; @@ -108,22 +110,33 @@ export async function POST(request: NextRequest) { } const errorCode = - typeof data?.error === "string" ? data.error : "authentication_failed"; + typeof data?.error === "string" + ? data.error + : data?.mfaRequired + ? "mfa_required" + : "authentication_failed"; const needsMfa = Boolean( data?.needMfa || + data?.mfaRequired || errorCode === "mfa_required" || errorCode === "mfa_setup_required", ); + const mfaToken = + typeof data?.mfaToken === "string" && data.mfaToken.trim().length > 0 + ? data.mfaToken + : typeof data?.mfaTicket === "string" && data.mfaTicket.trim().length > 0 + ? data.mfaTicket + : undefined; if ( - (response.status === 401 || response.status === 403 || needsMfa) && - typeof data?.mfaToken === "string" + (response.status === 401 || response.status === 403 || response.ok || needsMfa) && + typeof mfaToken === "string" ) { const result = NextResponse.json( { success: false, error: errorCode, needMfa: true }, { status: 401 }, ); - applyMfaCookie(result, data.mfaToken); + applyMfaCookie(result, mfaToken); clearSessionCookie(result, request.headers.get("host") ?? undefined); return result; } diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts index a57789b..e36f595 100644 --- a/src/app/api/auth/mfa/verify/route.ts +++ b/src/app/api/auth/mfa/verify/route.ts @@ -23,6 +23,7 @@ type AccountVerifyResponse = { token?: string; expiresAt?: string; mfaToken?: string; + mfaTicket?: string; error?: string; retryAt?: string; user?: Record | null; @@ -69,12 +70,12 @@ export async function POST(request: NextRequest) { } try { - const response = await fetch(`${ACCOUNT_API_BASE}/mfa/totp/verify`, { + const response = await fetch(`${ACCOUNT_API_BASE}/mfa/verify`, { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ token, code }), + body: JSON.stringify({ mfaToken: token, code }), cache: "no-store", }); @@ -110,8 +111,15 @@ export async function POST(request: NextRequest) { { status: response.status || 400 }, ); - if (typeof data?.mfaToken === "string" && data.mfaToken.trim()) { - applyMfaCookie(result, data.mfaToken); + const nextToken = + typeof data?.mfaToken === "string" && data.mfaToken.trim() + ? data.mfaToken + : typeof data?.mfaTicket === "string" && data.mfaTicket.trim() + ? data.mfaTicket + : ""; + + if (nextToken) { + applyMfaCookie(result, nextToken); } else { applyMfaCookie(result, token); } From 4329953274c595bfbd8acc0a9dcc6b7677ce71eb Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:22:50 +0000 Subject: [PATCH 03/10] docs: add ui refactor proposal Add docs/ui-refactor-proposal.md to outline the UI theme and style refactoring plan, including design system tokens, typography, responsive layout strategies, semantic navigation menu refactoring, and accessibility testing guidelines as requested. Co-authored-by: cloud-neutral <4133689+cloud-neutral@users.noreply.github.com> --- docs/ui-refactor-proposal.md | 218 +++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 docs/ui-refactor-proposal.md diff --git a/docs/ui-refactor-proposal.md b/docs/ui-refactor-proposal.md new file mode 100644 index 0000000..60f0e30 --- /dev/null +++ b/docs/ui-refactor-proposal.md @@ -0,0 +1,218 @@ +# UI 主题与风格重构方案 + +作为资深 Web UI 设计师和前端工程师,针对我们 SaaS 控制台当前的 Next.js + Tailwind CSS 架构,结合 WCAG 可访问性标准和跨端响应式需求,特制定此 UI 重构方案。方案旨在提升整体视觉一致性、深色模式的可访问性,并优化桌面与移动端的用户体验(特别是 iOS/Android 浏览器的触控和渲染差异)。 + +--- + +## 1. 审查现有界面 + +在审查当前的界面代码(如 `tailwind.config.js`, `src/app/globals.css`, `src/components/theme/` 及 `Navbar.tsx`)后,我发现了以下改进点: + +* **色彩硬编码与对比度**:在部分文件(如 `designTokens.ts` 和 `Navbar.tsx` 的内联类名)中仍然存在类似 `#3467e9`, `bg-[#f6f7f9]` 的硬编码颜色,这破坏了主题切换的完整性。同时,深色模式下的次级文本(如 `text-muted` `#cbd5f5`)在深色背景(`#0f172a`)上的对比度可能无法满足 WCAG AA 级 4.5:1 的标准。 +* **语义化不足**:`Navbar.tsx` 中的菜单项过度使用了 `
` 和普通的 `` 标签,缺少 `
-
-
-
-

- {isChinese ? "主要入口" : "Launch paths"} -

-

- {isChinese - ? "从接入、托管到观测,保留原有入口,但改成更轻的阅读节奏。" - : "Keep the same entry points, but present them with a calmer editorial rhythm."} -

-
- - {t.heroCards.length} {isChinese ? "个入口" : "entry paths"} - -
- -
- {t.heroCards.map((card) => { - const Icon = getIcon(card.title, PlusCircle); - return ( - - ); - })} -
+
+ card.title)} + isChinese={isChinese} + />
@@ -498,6 +471,113 @@ type LatestBlogPost = { date?: string; }; +function HeroVideoShell({ + items, + isChinese, +}: { + items: string[]; + isChinese: boolean; +}) { + return ( +
+
+
+
+

+ {isChinese ? "产品演示" : "Product demo"} +

+

+ {isChinese + ? "这里预留为视频展示区,后续可以直接替换成产品介绍、工作流演示或 onboarding 视频。" + : "Reserved for a video showcase. You can later replace it with a product intro, workflow demo, or onboarding clip."} +

+
+ + {isChinese ? "16:9 占位" : "16:9 shell"} + +
+
+ +
+
+
+
+
+
+
+ + + {isChinese ? "视频待接入" : "Video pending"} + + + 00:00 / 02:18 + +
+ +
+ +
+

+ {isChinese + ? "用一段视频解释从灵感到上线的完整路径" + : "Show the full path from idea to launch in one video"} +

+

+ {isChinese + ? "建议后续放 60 到 120 秒的产品导览、集成配置流程,或真实部署 walkthrough。" + : "Best used for a 60-120 second product tour, integration setup flow, or real deployment walkthrough."} +

+
+
+ +
+
+
+
+
+ + {isChinese ? "开场介绍" : "Intro"} + + + {isChinese ? "集成配置" : "Setup"} + + + {isChinese ? "上线演示" : "Launch"} + +
+
+
+
+ +
+ {items.map((item) => ( + + {item} + + ))} +
+
+
+ ); +} + function LogoPill({ label }: { label: string }) { return ( From d6d062daa91d962c584968bfd76f6c436dd9ec68 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 17 Mar 2026 20:02:45 +0800 Subject: [PATCH 07/10] refactor(public-pages): unify docs services and about styling --- src/app/about/page.tsx | 298 ++++++++++--------- src/app/docs/Feedback.tsx | 68 +++-- src/app/docs/[collection]/[...slug]/page.tsx | 132 ++++---- src/app/docs/page.tsx | 173 +++++++++-- src/app/globals.css | 150 ++++++++++ src/app/services/page.tsx | 292 +++++++----------- src/components/doc/DocArticle.tsx | 10 +- src/components/doc/DocMetaPanel.tsx | 39 ++- src/components/public/PublicPageShell.tsx | 82 +++++ 9 files changed, 779 insertions(+), 465 deletions(-) create mode 100644 src/components/public/PublicPageShell.tsx diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 44acc0c..f8156bb 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,161 +1,167 @@ "use client"; -import React from "react"; -import { translations } from "../../i18n/translations"; -import { useLanguage } from "../../i18n/LanguageProvider"; -import UnifiedNavigation from "../../components/UnifiedNavigation"; -import Footer from "../../components/Footer"; +import { AlertTriangle, ArrowUpRight, Heart, Sparkles } from "lucide-react"; + +import { + PublicPageIntro, + PublicPageShell, +} from "@/components/public/PublicPageShell"; +import { useLanguage } from "@/i18n/LanguageProvider"; +import { translations } from "@/i18n/translations"; +import { cn } from "@/lib/utils"; export default function AboutPage() { const { language } = useLanguage(); + const isChinese = language === "zh"; const t = translations[language].about; return ( -
-
+ +
+
+ -
- - -
-
- {/* Header */} -
-

- {t.title} -

-

{t.subtitle}

-
- - {/* Disclaimer Section */} -
-
-
- - - - - -
-
-

- Disclaimer -

-

- {t.disclaimer} -

-
-
-
- - {/* Acknowledgments */} -
+
+
-
-
-
+
+
+
+ +
+
+

+ {isChinese ? "免责声明" : "Disclaimer"} +

+

+ {t.disclaimer} +

+
+
+
+ +
+ +
+ +
+
+
+ +
+
+

+ {isChinese ? "开源协作" : "Open source"} +

+

+ {t.opensource} +

+
+
+
+ ); } diff --git a/src/app/docs/Feedback.tsx b/src/app/docs/Feedback.tsx index 61b2514..bab4f0d 100644 --- a/src/app/docs/Feedback.tsx +++ b/src/app/docs/Feedback.tsx @@ -1,36 +1,44 @@ -'use client' +"use client"; -import { useState } from 'react' -import { ThumbsUp, ThumbsDown } from 'lucide-react' +import { useState } from "react"; +import { ThumbsDown, ThumbsUp } from "lucide-react"; export default function Feedback() { - const [voted, setVoted] = useState<'yes' | 'no' | null>(null) + const [voted, setVoted] = useState<"yes" | "no" | null>(null); - return ( -
-
-

Is this page helpful?

- {voted === null ? ( -
- - -
- ) : ( -

Thanks for your feedback!

- )} -
+ return ( +
+
+
+

+ Feedback +

+

+ Is this page helpful? +

- ) + + {voted === null ? ( +
+ + +
+ ) : ( +

Thanks for your feedback.

+ )} +
+
+ ); } diff --git a/src/app/docs/[collection]/[...slug]/page.tsx b/src/app/docs/[collection]/[...slug]/page.tsx index 0b2ecbd..8c50355 100644 --- a/src/app/docs/[collection]/[...slug]/page.tsx +++ b/src/app/docs/[collection]/[...slug]/page.tsx @@ -1,104 +1,128 @@ -export const dynamic = 'error' -export const revalidate = false +export const dynamic = "error"; +export const revalidate = false; -import { notFound } from 'next/navigation' -import type { Metadata } from 'next' +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ChevronRight } from "lucide-react"; -import DocArticle from '@/components/doc/DocArticle' -import DocMetaPanel from '@/components/doc/DocMetaPanel' -import Feedback from '../../Feedback' -import { getDocVersionParams, getDocVersion } from '../../resources.server' -import { isFeatureEnabled } from '@lib/featureToggles' -import Link from 'next/link' -import { ChevronRight } from 'lucide-react' +import DocArticle from "@/components/doc/DocArticle"; +import DocMetaPanel from "@/components/doc/DocMetaPanel"; +import { PublicPageIntro } from "@/components/public/PublicPageShell"; +import { isFeatureEnabled } from "@lib/featureToggles"; -// Simple Breadcrumbs Component inline (or could be separate) -function DocsBreadcrumbs({ items }: { items: { label: string; href: string }[] }) { +import Feedback from "../../Feedback"; +import { getDocVersion, getDocVersionParams } from "../../resources.server"; + +function DocsBreadcrumbs({ + items, +}: { + items: { label: string; href: string }[]; +}) { return ( -

+

{service.description}

- + {isChinese ? "打开" : "Open"} @@ -92,111 +71,44 @@ const ServiceCard = ({ rel="noopener noreferrer" className="block" > - {cardContent} + {content} ); } return ( - {cardContent} + {content} ); -}; - -const PlaceholderCard = ({ - view, - isChinese, -}: { - view: "classic" | "material"; - isChinese: boolean; -}) => { - const isMaterial = view === "material"; - const placeholderLabel = isChinese - ? "更多服务即将上线" - : "More services coming soon"; - const placeholderDescription = isChinese - ? "预留卡片位置,持续扩充入口。" - : "Reserved slots for new service entries."; +} +function PlaceholderCard({ isChinese }: { isChinese: boolean }) { return ( -
-
-
+
+
+
-
- {placeholderLabel} +
+

+ {isChinese ? "更多服务即将上线" : "More services coming soon"} +

+

+ {isChinese + ? "预留卡片位置,持续扩充入口。" + : "Reserved slots for new service entries."} +

-

- {placeholderDescription} -

- + {isChinese ? "敬请期待" : "Stay tuned"}
); -}; - -const ServiceGrid = ({ - view, - services, - isChinese, -}: { - view: "classic" | "material"; - services: ServiceCardData[]; - isChinese: boolean; -}) => { - return ( -
- {services.map((service) => ( - - ))} - {Array.from({ length: placeholderCount }).map((_, index) => ( - - ))} -
- ); -}; - -const ClawdbotLogo = (props: any) => ( - Clawdbot -); +} export default function ServicesPage() { - const { view, isHydrated } = useViewStore(); const { language } = useLanguage(); const isChinese = language === "zh"; @@ -299,71 +211,83 @@ export default function ServicesPage() { external: true, }, { - key: "moltbot", + key: "xworkmate", name: "XWorkmate", description: isChinese ? "在线版 XWorkmate 工作区,底层由 OpenClaw gateway 驱动。" : "Online XWorkmate workspace powered by the OpenClaw gateway.", href: "/xworkmate", - icon: ClawdbotLogo, + icon: Command, }, ]; - if (!isHydrated) { - return null; - } - - if (view === "material") { - return ( - -
-

- Service Overview -

-

- Real-time metrics and system health for your current production - environment. -

-
- -
- ); - } - return ( -
-
-
- -
-
-

- {isChinese ? "更多服务" : "More services"} -

-

- {isChinese ? "扩展服务与工具箱" : "Extended Services & Toolbox"} -

-

- {isChinese - ? "汇聚开发辅助、运维监控与核心制品,构建无缝衔接的云原生工作台。" - : "A unified hub for development aids, operations monitoring, and core artifacts."} -

-
- +
+
+ -
-
-
-
+ +
+

+ {isChinese ? "页面原则" : "Page rhythm"} +

+

+ {isChinese + ? "保持结构不变,但去掉 classic / material 的风格分裂。" + : "Keep the structure, remove the classic/material visual split."} +

+
+
+ + +
+
+
+

+ {isChinese ? "服务目录" : "Service directory"} +

+

+ {isChinese + ? "每个入口都使用同一种卡片语法:白底、细边框、轻阴影、明确标题。" + : "Every entry now follows the same card grammar: pale surface, fine border, light shadow, and clear hierarchy."} +

+
+ + {services.length} {isChinese ? "个入口" : "entries"} + +
+ +
+ {services.map((service) => ( + + ))} + {Array.from({ length: placeholderCount }).map((_, index) => ( + + ))} +
+
+ ); } diff --git a/src/components/doc/DocArticle.tsx b/src/components/doc/DocArticle.tsx index fc0af0d..fb7e9b1 100644 --- a/src/components/doc/DocArticle.tsx +++ b/src/components/doc/DocArticle.tsx @@ -1,17 +1,17 @@ -import { marked } from 'marked' +import { marked } from "marked"; interface DocArticleProps { - content: string + content: string; } export default async function DocArticle({ content }: DocArticleProps) { // Convert markdown to HTML - const htmlContent = await marked(content) + const htmlContent = await marked(content); return (
- ) + ); } diff --git a/src/components/doc/DocMetaPanel.tsx b/src/components/doc/DocMetaPanel.tsx index bec7fb4..41407a4 100644 --- a/src/components/doc/DocMetaPanel.tsx +++ b/src/components/doc/DocMetaPanel.tsx @@ -1,29 +1,40 @@ -import ClientTime from '@/app/components/ClientTime' +import ClientTime from "@/app/components/ClientTime"; interface DocMetaPanelProps { - description?: string - updatedAt?: string - tags?: string[] + description?: string; + updatedAt?: string; + tags?: string[]; } -export default function DocMetaPanel({ description, updatedAt, tags }: DocMetaPanelProps) { +export default function DocMetaPanel({ + description, + updatedAt, + tags, +}: DocMetaPanelProps) { return ( -
- {description &&

{description}

} - {tags && tags.length > 0 && ( +
+ {description ? ( +

{description}

+ ) : null} + + {tags && tags.length > 0 ? (
{tags.map((tag) => ( - + {tag} ))}
- )} - {updatedAt && ( -

+ ) : null} + + {updatedAt ? ( +

Updated

- )} + ) : null}
- ) + ); } diff --git a/src/components/public/PublicPageShell.tsx b/src/components/public/PublicPageShell.tsx new file mode 100644 index 0000000..b4b360a --- /dev/null +++ b/src/components/public/PublicPageShell.tsx @@ -0,0 +1,82 @@ +import Footer from "@/components/Footer"; +import UnifiedNavigation from "@/components/UnifiedNavigation"; +import { cn } from "@/lib/utils"; + +type PublicPageShellProps = { + children: React.ReactNode; + mainClassName?: string; + containerClassName?: string; +}; + +type PublicPageIntroProps = { + eyebrow?: string; + title: string; + subtitle?: string; + titleClassName?: string; + className?: string; +}; + +export function PublicPageShell({ + children, + mainClassName, + containerClassName, +}: PublicPageShellProps) { + return ( +
+
+
+ +
+
+ {children} +
+
+
+
+
+ ); +} + +export function PublicPageIntro({ + eyebrow, + title, + subtitle, + titleClassName, + className, +}: PublicPageIntroProps) { + return ( +
+ {eyebrow ? ( +

+ {eyebrow} +

+ ) : null} +

+ {title} +

+ {subtitle ? ( +

+ {subtitle} +

+ ) : null} +
+ ); +} From 3af115cf5b1c3d613426c16bdbde6929662faae6 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 17 Mar 2026 20:03:27 +0800 Subject: [PATCH 08/10] Add configurable media data for hero video shell --- src/app/page.tsx | 155 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 119 insertions(+), 36 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 6f0de82..2ef19ad 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -67,6 +67,51 @@ const iconMap: Record = { const getIcon = (key: string, fallback: any) => iconMap[key] || fallback; +type HeroVideoMedia = { + posterUrl?: string; + videoUrl?: string; + title: { + zh: string; + en: string; + }; + description: { + zh: string; + en: string; + }; + statusLabel: { + zh: string; + en: string; + }; + durationLabel: string; + chapters: { + zh: string; + en: string; + }[]; +}; + +const heroVideoMedia: HeroVideoMedia = { + posterUrl: "", + videoUrl: "", + title: { + zh: "用一段视频解释从灵感到上线的完整路径", + en: "Show the full path from idea to launch in one video", + }, + description: { + zh: "建议后续放 60 到 120 秒的产品导览、集成配置流程,或真实部署 walkthrough。", + en: "Best used for a 60-120 second product tour, integration setup flow, or real deployment walkthrough.", + }, + statusLabel: { + zh: "视频待接入", + en: "Video pending", + }, + durationLabel: "00:00 / 02:18", + chapters: [ + { zh: "开场介绍", en: "Intro" }, + { zh: "集成配置", en: "Setup" }, + { zh: "上线演示", en: "Launch" }, + ], +}; + export default function HomePage() { const { mode, isOpen } = useMoltbotStore(); @@ -185,6 +230,7 @@ export function HeroSection() { card.title)} isChinese={isChinese} + media={heroVideoMedia} />
@@ -474,10 +520,28 @@ type LatestBlogPost = { function HeroVideoShell({ items, isChinese, + media, }: { items: string[]; isChinese: boolean; + media: HeroVideoMedia; }) { + const mediaTitle = isChinese ? media.title.zh : media.title.en; + const mediaDescription = isChinese + ? media.description.zh + : media.description.en; + const mediaStatusLabel = isChinese + ? media.statusLabel.zh + : media.statusLabel.en; + const hasVideo = Boolean(media.videoUrl); + const previewStyle = media.posterUrl + ? { + backgroundImage: `linear-gradient(180deg,rgba(15,23,42,0.18),rgba(15,23,42,0.52)), url(${media.posterUrl})`, + backgroundSize: "cover", + backgroundPosition: "center", + } + : undefined; + return (
@@ -499,47 +563,67 @@ function HeroVideoShell({
-
-
-
-
+
+ {hasVideo ? ( + + ) : ( + <> +
+
+
+ + )} +
- {isChinese ? "视频待接入" : "Video pending"} + {mediaStatusLabel} - 00:00 / 02:18 + {media.durationLabel}
- + {!hasVideo ? ( + + ) : null}

- {isChinese - ? "用一段视频解释从灵感到上线的完整路径" - : "Show the full path from idea to launch in one video"} + {mediaTitle}

- {isChinese - ? "建议后续放 60 到 120 秒的产品导览、集成配置流程,或真实部署 walkthrough。" - : "Best used for a 60-120 second product tour, integration setup flow, or real deployment walkthrough."} + {mediaDescription}

@@ -549,15 +633,14 @@ function HeroVideoShell({
- - {isChinese ? "开场介绍" : "Intro"} - - - {isChinese ? "集成配置" : "Setup"} - - - {isChinese ? "上线演示" : "Launch"} - + {media.chapters.map((chapter) => ( + + {isChinese ? chapter.zh : chapter.en} + + ))}
From 70004b0d0f638291fb964d7ec4d0b6b7e02be25d Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 17 Mar 2026 20:10:23 +0800 Subject: [PATCH 09/10] Extract hero video media config --- src/app/page.tsx | 49 +++------------------------------- src/lib/home/heroVideoMedia.ts | 44 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 45 deletions(-) create mode 100644 src/lib/home/heroVideoMedia.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index 2ef19ad..b8a503c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -24,6 +24,10 @@ import Footer from "../components/Footer"; import UnifiedNavigation from "../components/UnifiedNavigation"; import { useLanguage } from "../i18n/LanguageProvider"; import { translations } from "../i18n/translations"; +import { + heroVideoMedia, + type HeroVideoMedia, +} from "../lib/home/heroVideoMedia"; import { useMoltbotStore } from "../lib/moltbotStore"; import { useUserStore } from "../lib/userStore"; import { cn } from "../lib/utils"; @@ -67,51 +71,6 @@ const iconMap: Record = { const getIcon = (key: string, fallback: any) => iconMap[key] || fallback; -type HeroVideoMedia = { - posterUrl?: string; - videoUrl?: string; - title: { - zh: string; - en: string; - }; - description: { - zh: string; - en: string; - }; - statusLabel: { - zh: string; - en: string; - }; - durationLabel: string; - chapters: { - zh: string; - en: string; - }[]; -}; - -const heroVideoMedia: HeroVideoMedia = { - posterUrl: "", - videoUrl: "", - title: { - zh: "用一段视频解释从灵感到上线的完整路径", - en: "Show the full path from idea to launch in one video", - }, - description: { - zh: "建议后续放 60 到 120 秒的产品导览、集成配置流程,或真实部署 walkthrough。", - en: "Best used for a 60-120 second product tour, integration setup flow, or real deployment walkthrough.", - }, - statusLabel: { - zh: "视频待接入", - en: "Video pending", - }, - durationLabel: "00:00 / 02:18", - chapters: [ - { zh: "开场介绍", en: "Intro" }, - { zh: "集成配置", en: "Setup" }, - { zh: "上线演示", en: "Launch" }, - ], -}; - export default function HomePage() { const { mode, isOpen } = useMoltbotStore(); diff --git a/src/lib/home/heroVideoMedia.ts b/src/lib/home/heroVideoMedia.ts new file mode 100644 index 0000000..1f34c30 --- /dev/null +++ b/src/lib/home/heroVideoMedia.ts @@ -0,0 +1,44 @@ +export type HeroVideoMedia = { + posterUrl?: string; + videoUrl?: string; + title: { + zh: string; + en: string; + }; + description: { + zh: string; + en: string; + }; + statusLabel: { + zh: string; + en: string; + }; + durationLabel: string; + chapters: { + zh: string; + en: string; + }[]; +}; + +export const heroVideoMedia: HeroVideoMedia = { + posterUrl: "", + videoUrl: "", + title: { + zh: "用一段视频解释从灵感到上线的完整路径", + en: "Show the full path from idea to launch in one video", + }, + description: { + zh: "建议后续放 60 到 120 秒的产品导览、集成配置流程,或真实部署 walkthrough。", + en: "Best used for a 60-120 second product tour, integration setup flow, or real deployment walkthrough.", + }, + statusLabel: { + zh: "视频待接入", + en: "Video pending", + }, + durationLabel: "00:00 / 02:18", + chapters: [ + { zh: "开场介绍", en: "Intro" }, + { zh: "集成配置", en: "Setup" }, + { zh: "上线演示", en: "Launch" }, + ], +}; From 9d2fcd635c040845cdff9f54d16a64663a5ab762 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 04:08:22 +0000 Subject: [PATCH 10/10] feat(xworkmate): redesign console to a minimalist layout with chat input at bottom - Removed rounded corners and excess padding for a compact, simple feel. - Added a collapsible sidebar to preserve space while keeping existing icons. - Re-architected XWorkmateWorkspacePage layout to put chat/action bar at the bottom with a flex-grow central space. - Added suggested chips (Slides, Video Gen, Deep Research, etc.) for quick tasks. - Abstracted `pickCopy` to use generics to fix type errors. - Added Next.js `force-dynamic` explicit rule to `/xworkmate` to allow `headers()` resolution statically conflicting with `dynamic = 'error'` in root layout. Co-authored-by: cloud-neutral <4133689+cloud-neutral@users.noreply.github.com> --- next_output.log | Bin 0 -> 1876 bytes src/app/xworkmate/layout.tsx | 9 + .../xworkmate/XWorkmateWorkspacePage.tsx | 330 ++++++++++-------- update_layout.patch | 114 ++++++ 4 files changed, 299 insertions(+), 154 deletions(-) create mode 100644 next_output.log create mode 100644 src/app/xworkmate/layout.tsx create mode 100644 update_layout.patch diff --git a/next_output.log b/next_output.log new file mode 100644 index 0000000000000000000000000000000000000000..1fffe55c59fff55ecf3566bd5038d528d3bcebe4 GIT binary patch literal 1876 zcmeHG!EVz)5asM&;4%t_*iyZAoF+|NK`9Wmm8!IAgQ!$Nvb8tKw)U>sU6TaLS0HiX z1GpjaN%<0H6E~s=i8E67vi8iInfIQ@%dbD7&*p|)YiP7dgS5dNl>^xdT?PMmHjjc87!{S_7Tsw=ZyRs4`qk2qCNY zGe*+|Z~+l%YUfgw!tk8=i49C$F64_aD$xN~##nWN8=#b2~ey z_&DQc3Zrx(XwDOO3N>bdwGkw;%u-P|aK%6|kurrnwV{Til5BzDgqE5CH-J|Pqbagt zO1s{Oz&T@(ONCi6mN3QI7_3B%RtR+OS`(=zK`Ij+(4q*gN}i>t&|k<>`54J?is4cO z>(Q7B*0X&oQoo}6q`LhL$g#Fl0~c>9eW!H7z||^QjoHW!3XBt@=W(3s zR~v4D%?qgvq-;#f%mCF3k-)eViKTSEx$D6v=NUvhSO#dC&<5>#UoveD*M^{}bVkvp zjX}FBnyUiK_h09orPcXMe_SVQE~Lt7#y_%+=tE4nwy0apxKD^om~m@!e=OCEDqI!c zE@sVxy}duEuaWEQ7B*~#tA^e#YPMoi{H*nU?_w}KAM6i~UJeg>{o#xKqvMm;gSst9 z`2>wXP}FI+qDI*5cAob-QK#;FcHp2l_2FZZI>)Oqx99vpaZ;D2?X`ip-739cb0 literal 0 HcmV?d00001 diff --git a/src/app/xworkmate/layout.tsx b/src/app/xworkmate/layout.tsx new file mode 100644 index 0000000..9a6b1c8 --- /dev/null +++ b/src/app/xworkmate/layout.tsx @@ -0,0 +1,9 @@ +export const dynamic = "force-dynamic"; + +export default function XWorkmateLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/components/xworkmate/XWorkmateWorkspacePage.tsx b/src/components/xworkmate/XWorkmateWorkspacePage.tsx index 6e53fcf..2da26c6 100644 --- a/src/components/xworkmate/XWorkmateWorkspacePage.tsx +++ b/src/components/xworkmate/XWorkmateWorkspacePage.tsx @@ -12,12 +12,15 @@ import { Grip, KeyRound, ListTodo, + Paperclip, Puzzle, RefreshCw, + Send, Settings2, Shield, Sparkles, UserCircle2, + Zap, } from "lucide-react"; import { useRouter } from "next/navigation"; @@ -73,7 +76,7 @@ type DetailCardProps = { meta: string; }; -function pickCopy(isChinese: boolean, zh: string, en: string): string { +function pickCopy(isChinese: boolean, zh: T, en: T): T { return isChinese ? zh : en; } @@ -506,6 +509,7 @@ function AssistantHome({ secondaryActionLabel, connectionHint, actionDisabled, + isSharedProfile, }: { isChinese: boolean; tabs: SectionTab[]; @@ -518,122 +522,122 @@ function AssistantHome({ secondaryActionLabel: string; connectionHint?: string; actionDisabled?: boolean; + isSharedProfile?: boolean; }) { - return ( - <> -
-
-
-
- - - -
-

- {pickCopy(isChinese, "默认任务", "Default Task")} -

-

- {pickCopy( - isChinese, - "连接 Gateway 后,当前对话会自动作为默认任务开始执行。", - "After connecting the gateway, the current conversation starts as the default task.", - )} -

-
- {tabs.map((tab, index) => ( - - ))} -
-
-
- {connected - ? `${pickCopy(isChinese, "在线", "Online")} · ${endpointLabel}` - : pickCopy(isChinese, "离线 · 未连接目标", "Offline · No target")} -
-
-
+ const suggestions = pickCopy( + isChinese, + [ + "幻灯片", + "视频生成", + "深度研究", + "文档处理", + "数据分析", + "可视化", + "金融服务", + "产品管理", + "设计", + "邮件编辑", + ], + [ + "Slides", + "Video Gen", + "Deep Research", + "Docs Processing", + "Data Analysis", + "Visualization", + "Finance", + "Product Management", + "Design", + "Email Edit", + ] + ); -
-
-
-

- {pickCopy(isChinese, "先连接 Gateway", "Connect Gateway First")} -

-

- {pickCopy( - isChinese, - "连接后可直接对话、创建任务,并在当前会话查看结果。", - "Connect first to start chatting, create tasks, and view results in the current conversation.", - )} -

- {connectionHint ? ( -

- {connectionHint} -

- ) : null} -
+ return ( +
+
+ {!isSharedProfile && ( +
+
+
+

+ {pickCopy(isChinese, "未连接 Gateway", "Gateway Disconnected")} +

+

+ {connectionHint || pickCopy(isChinese, "请连接 Gateway 以获取完整能力。", "Please connect Gateway for full capabilities.")} +

+
-
-
+ )}
-
-