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 (
+
+
+
{label}
+ {hint ? (
+
{hint}
+ ) : null}
+
+ {children}
+
+ );
+}
+
+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 的共享工作台生效,只有管理员可编辑。"
+ : "这组配置只对当前租户域名下的你自己生效,不影响其他成员。"}
+
+
+
+
+
+ 返回工作台
+
+
+ {saving ? (
+
+ ) : (
+
+ )}
+ 保存配置
+
+
+
+ {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。"}
+
+
+
probe("openclaw")}
+ className="inline-flex h-10 items-center gap-2 rounded-[12px] border border-[color:var(--color-surface-border)] bg-white px-4 text-sm font-semibold text-[var(--color-heading)] transition hover:bg-[var(--color-surface-hover)]"
+ >
+ {loadingTarget === "openclaw" ? (
+
+ ) : (
+
+ )}
+ 测试
+
+
+
+
+
+
+ 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。"}
+
+
+
probe("vault")}
+ className="inline-flex h-10 items-center gap-2 rounded-[12px] border border-[color:var(--color-surface-border)] bg-white px-4 text-sm font-semibold text-[var(--color-heading)] transition hover:bg-[var(--color-surface-hover)]"
+ >
+ {loadingTarget === "vault" ? (
+
+ ) : (
+
+ )}
+ 测试
+
+
+
+
+
+
+
+
+ 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。"}
+
+
+
probe("apisix")}
+ className="inline-flex h-10 items-center gap-2 rounded-[12px] border border-[color:var(--color-surface-border)] bg-white px-4 text-sm font-semibold text-[var(--color-heading)] transition hover:bg-[var(--color-surface-hover)]"
+ >
+ {loadingTarget === "apisix" ? (
+
+ ) : (
+
+ )}
+ 测试
+
+
+
+
+ );
+}
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}
- {pickCopy(isChinese, "重新连接", "Reconnect")}
+ {primaryActionLabel}
- {pickCopy(isChinese, "编辑连接", "Edit Connection")}
+ {secondaryActionLabel}
@@ -581,7 +615,9 @@ function AssistantHome({
-
+
@@ -589,10 +625,11 @@ function AssistantHome({
- {pickCopy(isChinese, "重连", "Reconnect")}
+ {primaryActionLabel}
@@ -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.",
- )}
-
-
-
setShowConnections(false)}
- className="inline-flex h-10 w-10 items-center justify-center rounded-[14px] border border-[color:var(--color-surface-border)] bg-white text-[var(--color-text-subtle)] transition hover:text-[var(--color-heading)]"
- >
-
-
-
-
{
- 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,
+ };
+ },
},
),
-)
+);