From 5d9de8ed1f49d41148b349ffaf20a0ed0963fe3c Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 17 Mar 2026 13:24:41 +0800 Subject: [PATCH] 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, + }; + }, }, ), -) +);